diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile deleted file mode 100644 index 28726b28e..000000000 --- a/.devcontainer/Dockerfile +++ /dev/null @@ -1,43 +0,0 @@ -FROM ruby:4.0.9-alpine3.24 - -SHELL ["/bin/sh", "-o", "pipefail", "-c"] - -RUN apk add --no-cache \ - bash \ - build-base \ - chromium \ - curl \ - git \ - harfbuzz \ - libxml2-dev \ - libxslt-dev \ - nodejs \ - nss \ - npm \ - openssl-dev \ - python3 \ - ttf-freefont \ - yaml-dev \ - tzdata - -RUN if ! command -v corepack >/dev/null 2>&1; then npm install -g corepack; fi \ - && corepack enable \ - && corepack prepare "pnpm@latest" --activate - -ARG USER=vscode -ARG UID=1000 -ARG GID=1000 - -RUN addgroup -g "$GID" "$USER" \ - && adduser -D -G "$USER" -u "$UID" "$USER" - -WORKDIR /workspace -RUN chown -R "$USER":"$USER" /workspace - -ENV BUNDLE_PATH=/usr/local/bundle - -USER "$USER" - -EXPOSE 4000 - -CMD ["sleep", "infinity"] diff --git a/.devcontainer/README.md b/.devcontainer/README.md deleted file mode 100644 index 60e5d7a46..000000000 --- a/.devcontainer/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# Dev Container Workflow - -This repository ships a single Dev Container for development. Open the project in VS Code (Dev -Containers extension) or GitHub Codespaces and use that environment for all work. - -## What gets created - -The devcontainer starts one service named `app` and exposes: - -- **Port 4000:** Ruby app -- **Port 4001:** Vite dev server - -The repo is mounted at `/workspace`. Bundler gems are cached in a Docker volume to speed up -future launches. - -## Bootstrap - -On first open, the Dev Container runs: - -``` -make setup -``` - -This installs Ruby and frontend dependencies inside the container. - -If setup fails due to missing network access (e.g., GitHub DNS), rerun `make setup` once -network access is available. - -## Common commands (run inside the container) - -``` -make dev # Ruby + Vite -make dev-ruby # Ruby only -make dev-frontend # frontend only -make test # Ruby + frontend tests -make ready # RuboCop + RSpec (pre-commit gate) -``` - -## Lint and tests - -``` -bundle exec rubocop -F -bundle exec rspec -``` - -## Notes - -- All commands are expected to run inside the Dev Container. -- The default service command is `sleep infinity`; use the Make targets above to start servers. diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index 1be83e9c7..000000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "name": "html2rss-web", - "dockerComposeFile": "docker-compose.yml", - "service": "app", - "workspaceFolder": "/workspace", - "shutdownAction": "stopCompose", - "customizations": { - "vscode": { - "extensions": [ - "rebornix.ruby", - "esbenp.prettier-vscode", - "dbaeumer.vscode-eslint" - ], - "settings": { - "editor.formatOnSave": true, - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.codeActionsOnSave": { - "source.fixAll.eslint": "explicit" - }, - "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"], - "prettier.configPath": "./frontend/prettier.config.js", - "ruby.format": "rubocop", - "ruby.lint": { "rubocop": true }, - "editor.tabSize": 2, - "editor.insertSpaces": true, - "files.trimTrailingWhitespace": true, - "files.insertFinalNewline": true - } - } - }, - "forwardPorts": [4000, 4001], - "portsAttributes": { - "4000": { - "label": "Ruby App", - "onAutoForward": "notify" - }, - "4001": { - "label": "Vite Dev Server", - "onAutoForward": "silent" - } - }, - "postCreateCommand": "bash -lc \"make setup || (echo 'make setup failed; rerun once network access is available.' && exit 0)\"", - "remoteUser": "vscode" -} diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml deleted file mode 100644 index 2e23f37f4..000000000 --- a/.devcontainer/docker-compose.yml +++ /dev/null @@ -1,22 +0,0 @@ -services: - app: - build: - context: .. - dockerfile: .devcontainer/Dockerfile - volumes: - - ../:/workspace:cached - - bundle-cache:/usr/local/bundle - ports: - - "4000:4000" - - "4001:4001" - environment: - - RACK_ENV=development - - BUNDLE_PATH=/usr/local/bundle - - PORT=4000 - - BOTASAURUS_SCRAPER_URL=${BOTASAURUS_SCRAPER_URL:-http://host.docker.internal:4010} - command: sleep infinity - user: vscode - working_dir: /workspace - -volumes: - bundle-cache: diff --git a/.dockerignore b/.dockerignore index 46755b10a..16484a606 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,18 +6,14 @@ .mergify.yml .rspec .rubocop.yml -.ruby-version .ruby-lsp/ .yardoc/ -app.json bin/ !bin/docker-healthcheck coverage/ docs/ Dockerfile -Procfile Rakefile -renovate.json spec/ tmp/ work/ @@ -25,4 +21,4 @@ frontend/dist/ frontend/node_modules/ frontend/test-results/ frontend/.astro/ - +.pnpm-store/ diff --git a/.github/actions/dev-image/action.yml b/.github/actions/dev-image/action.yml new file mode 100644 index 000000000..ac33540fe --- /dev/null +++ b/.github/actions/dev-image/action.yml @@ -0,0 +1,28 @@ +name: dev image +description: Build the `dev` stage of the root Dockerfile (layer-cached) as html2rss/web:dev and restore the pnpm store; run gates afterwards with `make ` + +runs: + using: composite + steps: + - name: Match container user to runner user + shell: bash + run: | + echo "DEV_UID=$(id -u)" >> "$GITHUB_ENV" + echo "DEV_GID=$(id -g)" >> "$GITHUB_ENV" + + - uses: actions/cache@v6 + with: + path: .pnpm-store + key: pnpm-${{ hashFiles('frontend/pnpm-lock.yaml') }} + restore-keys: pnpm- + + - uses: docker/setup-buildx-action@v4 + + - uses: docker/bake-action@v7 + with: + files: docker-compose.dev.yml + targets: dev + load: true + set: | + dev.cache-from=type=gha,scope=dev + dev.cache-to=type=gha,mode=max,scope=dev diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 986e9027d..0db28a75d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -21,6 +21,10 @@ updates: interval: daily time: "09:00" open-pull-requests-limit: 10 + groups: + docker: + patterns: + - "*" - package-ecosystem: github-actions directory: "/" diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index dfe599fa7..1ff54df22 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -12,6 +12,9 @@ permissions: contents: write pull-requests: write +env: + CI: "true" + jobs: bump_version: runs-on: ubuntu-latest @@ -42,36 +45,18 @@ jobs: fi echo "TARGET_VERSION=${version}" >> "$GITHUB_ENV" - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - bundler-cache: true - - - name: Setup pnpm - uses: pnpm/action-setup@v6.1.0 - with: - cache: true - cache_dependency_path: frontend/pnpm-lock.yaml - package_json_file: frontend/package.json - - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version-file: ".tool-versions" - - - name: Install frontend dependencies - run: pnpm install --frozen-lockfile - working-directory: frontend + - uses: ./.github/actions/dev-image - name: Perform Version Bump id: perform_bump run: | - bundle exec ruby bin/bump-version "$TARGET_VERSION" - NEW_VERSION=$(ruby -I. -e "require 'config/version'; puts Html2rss::Web::VERSION") + docker compose -f docker-compose.dev.yml run --rm -T dev bundle exec ruby bin/bump-version "$TARGET_VERSION" + NEW_VERSION=$(docker compose -f docker-compose.dev.yml run --rm -T dev ruby -I. -e "require 'config/version'; puts Html2rss::Web::VERSION") echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT" - name: Regenerate OpenAPI spec and client run: | + make frontend-setup make openapi make openapi-client diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4257a73a1..efa7d7a4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,9 @@ concurrency: group: ci-${{ github.ref }} cancel-in-progress: true +env: + CI: "true" # makes `make` on the runner delegate to the dev container without a TTY + jobs: hadolint: runs-on: ubuntu-latest @@ -25,8 +28,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - name: Make compose contract verifier executable - run: chmod +x bin/compose-contract-verify - name: Verify compose contracts run: bin/compose-contract-verify @@ -34,19 +35,13 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + - uses: ./.github/actions/dev-image - - uses: ruby/setup-ruby@v1 - with: - bundler-cache: true - - - name: Run RuboCop - run: bundle exec rubocop -F - - - name: Verify Yard documentation - run: bundle exec yard doc --fail-on-warning --no-output + - name: Lint (RuboCop, Zeitwerk, YARD) + run: make lint-ruby - name: Run RSpec - run: bundle exec rspec + run: make test-ruby - name: Upload coverage report uses: actions/upload-artifact@v7 @@ -60,30 +55,11 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + - uses: ./.github/actions/dev-image - - uses: ruby/setup-ruby@v1 - with: - bundler-cache: true - - - name: Setup pnpm - uses: pnpm/action-setup@v6.1.0 - with: - cache: true - cache_dependency_path: frontend/pnpm-lock.yaml - package_json_file: frontend/package.json - - - name: Setup Node.js for OpenAPI lint tooling - uses: actions/setup-node@v7 - with: - node-version-file: ".tool-versions" - - - name: Install frontend dependencies for OpenAPI client verification - run: pnpm install --frozen-lockfile - working-directory: frontend - + - run: make frontend-setup - name: Verify generated OpenAPI spec and client are up to date run: make openapi-verify - - name: Lint OpenAPI contract run: make openapi-lint @@ -98,68 +74,28 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v6.1.0 with: - cache: true - cache_dependency_path: frontend/pnpm-lock.yaml package_json_file: frontend/package.json - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version-file: ".tool-versions" - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - name: Audit dependencies run: pnpm audit --audit-level=moderate frontend: runs-on: ubuntu-latest - defaults: - run: - working-directory: frontend steps: - uses: actions/checkout@v7 + - uses: ./.github/actions/dev-image - - name: Setup pnpm - uses: pnpm/action-setup@v6.1.0 - with: - cache: true - cache_dependency_path: frontend/pnpm-lock.yaml - package_json_file: frontend/package.json - - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version-file: ".tool-versions" - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Typecheck frontend - run: pnpm run typecheck - - - name: Lint CSS with Stylelint - run: pnpm exec stylelint "**/*.css" - - - name: Check formatting - run: pnpm run format:check - + - run: make frontend-setup + - name: Typecheck, lint, format + run: make lint-js - name: Run frontend tests - run: pnpm run test:ci - - - name: Install Playwright Chromium - run: pnpm exec playwright install --with-deps chromium - + run: make test-frontend - name: Run frontend smoke test - run: pnpm run test:e2e + run: make test-frontend-e2e docker-build-smoke-image: needs: - hadolint - - ruby - - openapi - - frontend runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -170,8 +106,8 @@ jobs: context: . load: true tags: html2rss/web - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=runtime + cache-to: type=gha,mode=max,scope=runtime docker-test: needs: @@ -183,12 +119,7 @@ jobs: smoke_auto_source_enabled: ["false", "true"] steps: - uses: actions/checkout@v7 - - - uses: ruby/setup-ruby@v1 - with: - bundler-cache: true - - - uses: docker/setup-buildx-action@v4 + - uses: ./.github/actions/dev-image - name: Load Docker smoke image from cache uses: docker/build-push-action@v7 @@ -196,10 +127,10 @@ jobs: context: . load: true tags: html2rss/web - cache-from: type=gha + cache-from: type=gha,scope=runtime - name: Run Docker smoke test env: DOCKER_SMOKE_SKIP_BUILD: "true" SMOKE_AUTO_SOURCE_ENABLED: ${{ matrix.smoke_auto_source_enabled }} - run: bundle exec rake + run: rake diff --git a/.rubocop.yml b/.rubocop.yml index 1f326cc6f..9618e1015 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -11,7 +11,6 @@ AllCops: Exclude: - '**/*.yml' - '**/*.yaml' - - '**/.tool-versions' - 'coverage/**/*' - 'frontend/node_modules/**/*' - 'frontend/test-results/**/*' diff --git a/.tool-versions b/.tool-versions deleted file mode 100644 index df7c4c12a..000000000 --- a/.tool-versions +++ /dev/null @@ -1,2 +0,0 @@ -ruby 4.0.9 -nodejs 22.22.0 diff --git a/.yardopts b/.yardopts index f3c98a332..359a47531 100644 --- a/.yardopts +++ b/.yardopts @@ -6,5 +6,4 @@ --exclude public/ --exclude config/ --exclude bin/ ---exclude .devcontainer/ --exclude .github/ diff --git a/AGENTS.md b/AGENTS.md index 2f0ef81fe..9e5758012 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,8 +8,8 @@ Canonical references: ## Execution & Coding Contracts - **Posture:** Time matters. Reduce LOC when possible. No legacy code, no backward compatibility, no shims. -- **Dev Container only:** Run all commands inside the Dev Container via `make` or `bundle exec`. Never execute on the host. -- **Quality gate:** Never push or open a PR without a green Dev Container gate (`make ready` minimum, which includes `pnpm run format:check` via `lint-js`; `make ci-ready` before push/PR for frontend or API/OpenAPI contract changes). Never mark validation done or open a PR with "gate skipped / unavailable" — start the Dev Container and run the gate, or stop and report the blocker plus next command. +- **Docker-only:** `make ` from the host re-runs the target in the `dev` compose service (the `dev` stage of the root `Dockerfile`); inside the container it runs directly. One-off commands: `docker compose -f docker-compose.dev.yml run --rm dev `. Never run bare `bundle`/`pnpm`/`rspec` on the host. +- **Quality gate:** Never push or open a PR without a green `dev`-image gate (`make ready` minimum, which includes `pnpm run format:check` via `lint-js`; `make ci-ready` before push/PR for frontend or API/OpenAPI contract changes). Never mark validation done or open a PR with "gate skipped / unavailable" — start the `dev` service and run the gate, or stop and report the blocker plus next command. - **Frontend verification:** With `make dev` running, verify touched paths in `chrome-devtools` MCP at `http://127.0.0.1:4001/`. Confirm affected states (guest/member/result) share the same frame, keep one primary task and one canonical control per outcome, and preserve autofocus/return-focus on load and transitions ([docs/design-system.md](docs/design-system.md)). - **Security & YARD:** No leaking secrets or insecure patterns ([docs/README.md#security--safety-rules](docs/README.md#security--safety-rules)). Every public Ruby method in `app/` requires a YARD docstring with typed `@param` and `@return` ([docs/README.md#architectural-constraints](docs/README.md#architectural-constraints)). - **Response format:** `Changes → Commands → Results → Next steps`, ending with a one-line summary. @@ -22,7 +22,7 @@ When a task that should ship merges to `main`, ask whether to bump the app versi 1. Wait until `html2rss/html2rss`'s `release gem` workflow for the tag is `success` (a merged version PR is not on RubyGems yet; `bundle update html2rss` keeps the old pin). 2. `git fetch origin main` and branch from `origin/main`. Never continue a feature branch or commit the lockfile onto `main`. -3. In the Dev Container, run `bundle update html2rss`, then `make ready`. Do not run a full `bundle update` unless asked. Keep the Gemfile constraint `~> 0.30` unless resolution fails. +3. In the `dev` image, run `bundle update html2rss`, then `make ready`. Do not run a full `bundle update` unless asked. Keep the Gemfile constraint `~> 0.30` unless resolution fails. 4. Open a lockfile PR and watch required checks (`frontend`, `ruby`, `docker-test (false)`, `docker-test (true)`) with `gh pr checks --watch`. 5. Mergify queues only Dependabot. When asked to merge and checks are green, run `gh pr merge --squash --delete-branch` (merge commits disabled). 6. Stop and ask whether to bump the app version. Dispatch the version workflow only after the gem PR is on `main`. diff --git a/Dockerfile b/Dockerfile index 53b7e5ec1..0fd2cdabd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,12 @@ -ARG RUBY_BASE_IMAGE=ruby:4.0.9-alpine3.24@sha256:50d04650231808a465271b4eedcb78634c2306afb0157b10e9d628373bd5d002 -ARG NODE_BASE_IMAGE=node:22-alpine@sha256:8094c002d08262dba12645a3b4a15cd6cd627d30bc782f53229a2ec13ee22a00 +# Single source of truth for toolchain versions: Dependabot bumps these two digest-pinned bases. +FROM node:22-alpine@sha256:fe4f6d74e20370acb04ad889077d14d0f64a9c8ea6a42bc60cddf1e844699914 AS node -# Stage 1: Frontend Build (arch-independent assets; native builder, not QEMU) -FROM --platform=$BUILDPLATFORM ${NODE_BASE_IMAGE} AS frontend-builder +FROM ruby:4.0.9-alpine3.24@sha256:50d04650231808a465271b4eedcb78634c2306afb0157b10e9d628373bd5d002 AS ruby + +LABEL maintainer="Gil Desmarais " + +# Frontend Build (arch-independent assets; native builder, not QEMU) +FROM --platform=$BUILDPLATFORM node AS frontend-builder WORKDIR /app/frontend COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./ @@ -10,10 +14,69 @@ RUN corepack enable && pnpm install --frozen-lockfile COPY frontend/ ./ RUN pnpm run build -# Stage 2: Ruby Build -FROM ${RUBY_BASE_IMAGE} AS builder +# Local + CI toolchain (docker-compose.dev.yml) +FROM ruby AS dev -LABEL maintainer="Gil Desmarais " +SHELL ["/bin/ash", "-o", "pipefail", "-c"] + +# hadolint ignore=DL3018 +RUN apk add --no-cache \ + bash \ + build-base \ + chromium \ + curl \ + git \ + harfbuzz \ + libxml2-dev \ + libxslt-dev \ + nss \ + openssl-dev \ + python3 \ + ttf-freefont \ + yaml-dev \ + tzdata + +COPY --from=node /usr/local/bin/node /usr/local/bin/node +COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules +# COPY materializes binaries as files; restore upstream symlinks so dist/lib resolves. +RUN ln -sf ../lib/node_modules/corepack/dist/corepack.js /usr/local/bin/corepack \ + && ln -sf ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \ + && ln -sf ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx + +ENV BUNDLE_PATH=/usr/local/bundle \ + COREPACK_HOME=/usr/local/share/corepack + +COPY frontend/package.json /tmp/package.json +RUN corepack enable \ + && corepack prepare "$(node -p "require('/tmp/package.json').packageManager")" --activate \ + && rm /tmp/package.json + +ARG USER=dev +ARG UID=1000 +ARG GID=1000 + +RUN addgroup -g "$GID" "$USER" \ + && adduser -D -G "$USER" -u "$UID" "$USER" \ + && mkdir -p /workspace /usr/local/bundle /usr/local/share/corepack + +WORKDIR /workspace + +COPY Gemfile Gemfile.lock ./ + +# hadolint ignore=SC2046 +RUN gem install bundler:$(tail -1 Gemfile.lock | tr -d ' ') \ + && bundle install --retry=5 --jobs=$(nproc) \ + && rm -rf /usr/local/bundle/cache \ + && chown -R "$USER":"$USER" /workspace /usr/local/bundle /usr/local/share/corepack + +USER $UID + +EXPOSE 4000 4001 + +CMD ["sleep", "infinity"] + +# Ruby Build +FROM ruby AS builder SHELL ["/bin/ash", "-o", "pipefail", "-c"] @@ -39,10 +102,8 @@ RUN apk add --no-cache \ /usr/local/bundle/bundler/gems/*/.git \ /usr/local/bundle/cache/bundler/git -# Stage 3: Runtime -FROM ${RUBY_BASE_IMAGE} - -LABEL maintainer="Gil Desmarais " +# Runtime +FROM ruby AS runtime SHELL ["/bin/ash", "-o", "pipefail", "-c"] diff --git a/Makefile b/Makefile index c4444eb77..14ed6dc6e 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,18 @@ # frozen_string_literal: true -.PHONY: help test lint lint-js lint-ruby lintfix lintfix-js lintfix-ruby setup dev clean frontend-setup check-frontend quick-check ready ci-ready yard-verify-public-docs openapi openapi-verify openapi-client openapi-client-verify openapi-lint openapi-lint-redocly openapi-lint-spectral openai-lint-spectral test-frontend-e2e lint-css-primitives +# On the host, every target re-runs inside the `dev` compose service; below `else` is the in-container Makefile. +ifeq ($(wildcard /.dockerenv /run/.containerenv),) +.DEFAULT_GOAL := help +DEV_RUN = docker compose -f docker-compose.dev.yml run --rm $(if $(CI),-T) $(if $(filter dev%,$(MAKECMDGOALS)),--service-ports) dev + +%: + @docker info >/dev/null 2>&1 || { echo "Docker is not running. Start Docker Desktop, OrbStack, Colima or Podman." >&2; exit 1; } + @$(DEV_RUN) make $@ + +reset: ## Drop the dev containers and volumes (bundle cache); the image rebuilds on next use + docker compose -f docker-compose.dev.yml down -v +else +.PHONY: help test lint lint-js lint-ruby lintfix lintfix-js lintfix-ruby setup dev clean frontend-setup check-frontend quick-check ready ci-ready yard-verify-public-docs openapi openapi-verify openapi-client openapi-client-verify openapi-lint openapi-lint-redocly openapi-lint-spectral openai-lint-spectral test-frontend-e2e lint-css-primitives frontend-audit RUBOCOP_FLAGS ?= --cache false @@ -66,6 +78,7 @@ lint-ruby: ## Run Ruby linter (RuboCop) - errors when issues found bundle exec rake zeitwerk:verify @echo "Running YARD public-method docs check..." bundle exec rake yard:verify_public_docs + bundle exec yard doc --fail-on-warning --no-output @echo "Ruby linting complete!" lint-js: ## Run JavaScript/Frontend linting (TypeScript + ESLint + Stylelint + Prettier) - errors when issues found @@ -149,7 +162,12 @@ clean: ## Clean temporary files @cd frontend && rm -rf dist/ node_modules/ @echo "Clean complete!" +frontend-audit: ## Fail on moderate+ advisories in the pnpm lockfile + @cd frontend && pnpm audit --audit-level=moderate + frontend-setup: ## Setup frontend dependencies @echo "Setting up frontend dependencies..." @cd frontend && CI=1 pnpm install --frozen-lockfile @echo "Frontend setup complete!" + +endif diff --git a/README.md b/README.md index 8feabe258..f0bab7056 100644 --- a/README.md +++ b/README.md @@ -85,14 +85,15 @@ volumes: See [Creating Custom Feeds](https://html2rss.github.io/creating-custom-feeds/). -## Development (Dev Container) +## Development (Docker) -Run setup, lint, and tests inside the Dev Container: +Needs any Docker-compatible engine (Docker Desktop, OrbStack, Colima, Podman); the first `make` builds the image. Run setup, lint, and tests inside the `dev` compose service built from the `dev` stage of the root `Dockerfile`: ```bash make setup make dev make ready +make reset # drop dev volumes after Gemfile.lock changes misbehave ``` Feature branches that depend on an unreleased `html2rss` API may pin the gem from GitHub in `Gemfile`. Revert to the released RubyGems version before merging to `main`. diff --git a/Rakefile b/Rakefile index c62c9361c..44a9733b2 100644 --- a/Rakefile +++ b/Rakefile @@ -89,7 +89,15 @@ task :test do 'SMOKE_AUTO_SOURCE_ENABLED' => smoke_auto_source_enabled, 'RUN_DOCKER_SPECS' => 'true' } - sh smoke_env, 'bundle exec rspec --tag docker' + # Run the suite in the dev image, inside the app container's network namespace (so 127.0.0.1:4000 is the app). + sh smoke_env, + ['docker run --rm', + '--network container:html2rss-web-test', + *smoke_env.keys.map { |key| "-e #{key}" }, + "-v #{current_dir}:/workspace", + '-w /workspace', + 'html2rss/web:dev', + 'bundle exec rspec --tag docker'].join(' ') ensure if test_container_exists?('html2rss-web-test') Output.describe 'Cleaning up test container' diff --git a/bin/compose-contract-verify b/bin/compose-contract-verify index d230501be..1e5ff8ed2 100755 --- a/bin/compose-contract-verify +++ b/bin/compose-contract-verify @@ -141,6 +141,53 @@ def verify_production! assert_botasaurus_loopback_only!(bot['ports']) end +def volume_target?(volumes, target) + Array(volumes).any? do |volume| + volume.is_a?(Hash) && volume['target'].to_s == target + end +end + +def assert_dev_ports!(ports) + published = Array(ports) + [4000, 4001].each do |port_number| + match = published.find { |port| published_port?(port, published: port_number, target: port_number) } + fail!("dev must publish #{port_number}:#{port_number}") unless match + end +end + +def assert_dev_volumes!(volumes) + fail!('dev must bind-mount /workspace') unless volume_target?(volumes, '/workspace') + fail!('dev must mount /usr/local/bundle volume') unless volume_target?(volumes, '/usr/local/bundle') +end + +def assert_dev_command!(command) + joined = Array(command).join(' ') + return if joined.include?('sleep') && joined.include?('infinity') + + fail!("dev command must be sleep infinity, got #{command.inspect}") +end + +def assert_dev_build!(dev) + assert_equal!(dev['image'], 'html2rss/web:dev', 'dev image') + + build = dev.fetch('build') { fail!('dev missing build') } + assert_equal!(build['target'], 'dev', 'dev build.target') + assert_equal!(File.basename(build.fetch('dockerfile', 'Dockerfile')), 'Dockerfile', 'dev build.dockerfile') +end + +def verify_dev! + config = compose_config(File.join(REPO_ROOT, 'docker-compose.dev.yml')) + services = config.fetch('services') { fail!('dev compose missing top-level services map') } + assert_equal!(services.keys.sort, ['dev'], 'dev compose services') + + dev = fetch_service!(services, 'dev') + assert_dev_build!(dev) + assert_dev_volumes!(dev['volumes']) + assert_dev_ports!(dev['ports']) + assert_dev_command!(dev['command']) +end + verify_quickstart! verify_production! +verify_dev! puts 'compose contract verify passed' diff --git a/bin/dev-ruby b/bin/dev-ruby index 0fe5bd903..72fbcb74d 100755 --- a/bin/dev-ruby +++ b/bin/dev-ruby @@ -10,8 +10,13 @@ fi export RACK_ENV=${RACK_ENV:-development} +# BusyBox lsof in this image reports internal FDs and exits 0, so prefer netstat. +port_in_use() { + netstat -ltn 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]$1$" +} + # Prevent silent failures from port conflicts -if lsof -Pi :${PORT:-4000} -sTCP:LISTEN -t >/dev/null 2>&1; then +if port_in_use "${PORT:-4000}"; then echo "❌ Port ${PORT:-4000} is already in use. Run: pkill -f 'falcon.*html2rss-web'" exit 1 fi diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 000000000..f91be93d0 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,26 @@ +services: + dev: + image: html2rss/web:dev + build: + context: . + target: dev + args: + UID: ${DEV_UID:-1000} + GID: ${DEV_GID:-1000} + volumes: + - .:/workspace:cached + - bundle-cache:/usr/local/bundle + ports: + - "4000:4000" + - "4001:4001" + environment: + RACK_ENV: development + BUNDLE_PATH: /usr/local/bundle + npm_config_store_dir: /workspace/.pnpm-store # same filesystem as node_modules, so installs hardlink + PORT: "4000" + BOTASAURUS_SCRAPER_URL: ${BOTASAURUS_SCRAPER_URL:-http://host.docker.internal:4010} + working_dir: /workspace + command: sleep infinity + +volumes: + bundle-cache: diff --git a/docs/README.md b/docs/README.md index e238b3928..1b638410b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,7 +20,7 @@ Welcome! This is the canonical source of truth for contributing to `html2rss-web - **Backend**: Ruby + Falcon + Roda under the `Html2rss::Web` namespace. - **Frontend**: Preact + Vite, built into `frontend/dist` and served at `/` in production. - **Feed extraction**: Delegated to the `html2rss` gem (HTTPX non-blocking transport layer). -- **Distribution**: Docker Compose / Dev Container first. +- **Distribution**: Docker Compose first (`dev` target for local work). ### Source Of Truth @@ -31,12 +31,16 @@ Welcome! This is the canonical source of truth for contributing to `html2rss-web --- -## Development Setup (Dev Container) +## Development Setup (Docker) -Use the repository's [Dev Container](../.devcontainer/README.md) for all local development and tests. -Running the app directly on the host is not supported. +Run `make ` on the host: it delegates to the `dev` stage of the root `Dockerfile` via `docker-compose.dev.yml` (built on first use). Running the app directly on the host is not supported. On Linux, export `DEV_UID=$(id -u) DEV_GID=$(id -g)` before the first `make` so bind-mounted files stay owned by you. CI runs the same image via `.github/actions/dev-image`. -### Common Commands (Inside Dev Container) +```bash +make setup +make dev +``` + +### Common Commands | Command | Purpose | | ------------------------------ | ---------------------------------------------------------- | @@ -74,7 +78,7 @@ Development routing defaults: To change or add API endpoints, follow this sequence: 1. **Ruby Request Spec**: Define the new behavior or endpoint in `spec/html2rss/web/app_integration_spec.rb` or a dedicated request spec. -2. **OpenAPI Generation**: Run `make openapi` inside the Dev Container to regenerate `public/openapi.yaml` from the spec metadata. +2. **OpenAPI Generation**: Run `make openapi` inside the `dev` image to regenerate `public/openapi.yaml` from the spec metadata. 3. **Verify Contract**: Run `make openapi-verify` and `make openapi-lint` to ensure the generated file matches the specs and is valid. 4. **Frontend Client**: Keep generated client artifacts in `frontend/src/api/generated` aligned with `public/openapi.yaml`. @@ -105,7 +109,7 @@ make ci-ready | Ruby API | RSpec + Rack::Test | Feed creation, retrieval, auth paths. | | Frontend unit | Vitest + Testing Library | Component rendering and hooks with mocked fetch. | | Frontend contract | Vitest + MSW | End-to-end fetch flows against mocked API responses. | -| Docker smoke | RSpec (`:docker`) | Net::HTTP probes against the containerised service. | +| Docker smoke | `rake` (Rakefile) | HTTP probes against the built runtime image. | --- diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 81da0b8e2..52b01cc29 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -5,11 +5,7 @@ const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || resolveChromiumExecutablePath(); function resolveChromiumExecutablePath(): string | undefined { - const candidates = [ - '/usr/bin/chromium-browser', - '/usr/bin/chromium', - '/home/vscode/.cache/ms-playwright/chromium-1217/chrome-linux/chrome', - ]; + const candidates = ['/usr/bin/chromium-browser', '/usr/bin/chromium']; return candidates.find((candidate) => existsSync(candidate)); }