diff --git a/.env.example b/.env.example index a110ad94..74d400de 100644 --- a/.env.example +++ b/.env.example @@ -74,6 +74,18 @@ NUXT_PUBLIC_SITE_URL=http://localhost:3000 # POSTHOG_PUBLIC_KEY=phc_... # EU data center (default). Use https://us.i.posthog.com for US. # POSTHOG_HOST=https://eu.i.posthog.com +# Personal API key with "Feature Flags: read" scope. When set, the server +# evaluates feature flags locally (no per-request HTTP round trip). +# POSTHOG_FEATURE_FLAGS_KEY=phx_... + +# ─── Optional: Feature Flag Overrides (no PostHog required) ───────────────── +# Force any flag on or off without running PostHog. The full list of available +# flags lives in shared/feature-flags.ts. Variable name pattern: +# FEATURE_FLAG_ +# Accepted values: true / false / 1 / 0 / on / off (or a variant key for +# multivariate flags). Env overrides win over PostHog rollouts. +# Example — enable the new chatbot experience for everyone on this instance: +# FEATURE_FLAG_CHATBOT_EXPERIENCE=true # ─── Optional: OIDC SSO (Keycloak, Authentik, Authelia, Okta, etc.) ────────── # Enable Single Sign-On via any OIDC-compliant identity provider. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 925ac202..d8a9c034 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -3,6 +3,8 @@ - What does this PR change? - Why is this needed? +> **PR title must follow [Conventional Commits](https://www.conventionalcommits.org/)** — e.g. `feat(jobs): add bulk import` or `fix: handle null salary`. The squash-merged title is what release-please uses to generate the changelog and pick the next version. PRs with non-conventional titles are blocked by CI. + ## Type of change - [ ] Bug fix diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml index 59b13bdd..97c2194c 100644 --- a/.github/workflows/dependabot-automerge.yml +++ b/.github/workflows/dependabot-automerge.yml @@ -53,3 +53,21 @@ jobs: env: PR_URL: ${{ github.event.pull_request.html_url }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Production dependency PATCHES are also safe to automerge — patch + # releases are by definition backwards-compatible bug fixes. The merge + # still only happens once branch-protection checks pass (build, + # typecheck, audit, E2E, docker-readme-validation), so a regression in + # a patch will block the merge. + # + # MINOR production updates intentionally remain manual: even though + # semver says they are backwards-compatible, in practice they often + # introduce new behaviour worth a human glance for an ATS. + - name: Enable automerge (prod deps, patch only) + if: | + steps.metadata.outputs.dependency-type == 'direct:production' && + steps.metadata.outputs.update-type == 'version-update:semver-patch' + run: gh pr merge --auto --squash "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index b6df875c..8dc9cfa9 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -8,6 +8,10 @@ on: permissions: contents: read packages: write + # id-token: needed by cosign for keyless OIDC signing. + # attestations: needed to publish build attestations to GHCR. + id-token: write + attestations: write concurrency: group: docker-publish-${{ github.ref }} @@ -63,6 +67,7 @@ jobs: # ── 5. Build & push ──────────────────────────────────────────────────── - name: Build and push + id: build uses: docker/build-push-action@v7 with: context: . @@ -77,3 +82,34 @@ jobs: sbom: true env: DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index + + # ── 6. Keyless cosign signature (Sigstore OIDC) ──────────────────────── + # Self-hosters can verify the image they pulled was actually built by + # this workflow with: + # cosign verify ghcr.io/reqcore-inc/reqcore@ \ + # --certificate-identity-regexp 'https://github.com/reqcore-inc/reqcore/.github/workflows/docker-publish.yml@.*' \ + # --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' + - name: Install cosign + uses: sigstore/cosign-installer@v3 + + - name: Sign published images by digest + env: + DIGEST: ${{ steps.build.outputs.digest }} + TAGS: ${{ steps.meta.outputs.tags }} + run: | + set -euo pipefail + # cosign signs by immutable digest; the tags only resolve the digest. + # We sign each unique image reference so verification works against + # any tag the user pulled (latest, semver, edge, sha-…). + # `${tag%:*}` (single %) strips only the trailing :tag so registries + # with ports (e.g. registry.local:5000/repo:tag) survive intact. + # Pipe through `sort -u` so the same image+digest is signed once + # even when the tag list contains many aliases. + printf '%s\n' $TAGS \ + | awk 'NF' \ + | sed -E 's#:[^:/]+$##' \ + | sort -u \ + | while IFS= read -r image; do + echo "Signing ${image}@${DIGEST}" + cosign sign --yes "${image}@${DIGEST}" + done diff --git a/.github/workflows/pr-title-lint.yml b/.github/workflows/pr-title-lint.yml new file mode 100644 index 00000000..9602134e --- /dev/null +++ b/.github/workflows/pr-title-lint.yml @@ -0,0 +1,49 @@ +name: PR Title Lint + +# Enforce Conventional Commit syntax in PR titles so release-please can +# always derive a clean changelog and the correct semver bump from the +# squash-merged commit. Without this, a single mis-titled PR silently +# disappears from the release notes. +# +# Examples that pass: +# feat: add candidate bulk import +# fix(jobs): handle null salary range +# chore(deps): bump nuxt from 4.3.1 to 4.3.2 +# +# Examples that fail: +# Update stuff +# WIP + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +permissions: + pull-requests: read + +jobs: + lint: + name: Validate PR title + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@v6 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + # Keep types aligned with .github/release-please-config.json + types: | + feat + fix + perf + security + docs + refactor + test + build + ci + chore + requireScope: false + subjectPattern: ^[A-Za-z0-9].+$ + subjectPatternError: | + The PR title subject must start with a letter or number and not + be empty. Example: `feat(jobs): add bulk import`. diff --git a/.github/workflows/release-verification.yml b/.github/workflows/release-verification.yml new file mode 100644 index 00000000..7b33ce8b --- /dev/null +++ b/.github/workflows/release-verification.yml @@ -0,0 +1,182 @@ +name: Release Verification + +# Fires after release-please publishes a GitHub Release (which also pushes a +# `v*` tag and triggers docker-publish.yml). This workflow is the last gate +# in the chain and provides two guarantees: +# +# 1. smoke-test: the *published* image (not a locally-built one) actually +# starts cleanly using the same setup.sh + docker-compose flow that +# self-hosters follow. If this fails, the release is auto-marked as a +# pre-release so it stops being advertised as the "Latest" release. +# +# 2. bundle: attach a self-hoster bundle (docker-compose.production.yml +# with the image tag pinned + setup.sh) to the GitHub Release so users +# can `curl -L .../releases/download/v1.4.0/reqcore-1.4.0.tar.gz` and +# get a deterministic, version-locked install. + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: "Release tag to verify (e.g. v1.4.0)" + required: true + type: string + +permissions: + contents: write + +concurrency: + group: release-verification-${{ github.event.release.tag_name || inputs.tag }} + cancel-in-progress: false + +jobs: + smoke-test: + name: Smoke-test published image + runs-on: ubuntu-latest + timeout-minutes: 35 + steps: + - name: Resolve release tag + id: tag + run: | + set -euo pipefail + tag="${{ github.event.release.tag_name || inputs.tag }}" + version="${tag#v}" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Checkout release tag + uses: actions/checkout@v6 + with: + ref: ${{ steps.tag.outputs.tag }} + + - name: Pin compose file to the released image tag + run: | + set -euo pipefail + sed -i \ + "s|ghcr.io/reqcore-inc/reqcore:latest|ghcr.io/reqcore-inc/reqcore:${{ steps.tag.outputs.version }}|" \ + docker-compose.production.yml + grep "ghcr.io/reqcore-inc/reqcore" docker-compose.production.yml + + - name: Wait for the published image to be available on GHCR + run: | + set -euo pipefail + # docker-publish.yml is triggered by the same tag push, so it may + # still be running when this job starts. Poll for up to 20 minutes. + for i in $(seq 60); do + if docker manifest inspect "ghcr.io/reqcore-inc/reqcore:${{ steps.tag.outputs.version }}" > /dev/null 2>&1; then + echo "✅ Image is available" + exit 0 + fi + echo " attempt $i/60 — image not yet published, waiting 20s..." + sleep 20 + done + echo "❌ Image ghcr.io/reqcore-inc/reqcore:${{ steps.tag.outputs.version }} never appeared" + exit 1 + + - name: Generate .env via setup.sh + run: | + chmod +x ./setup.sh + ./setup.sh + + - name: Start full stack against the published image + run: docker compose -f docker-compose.production.yml up -d + + - name: Wait for app to be reachable + run: | + set -euo pipefail + for i in $(seq 60); do + if curl -fs http://localhost:3000 > /dev/null 2>&1; then + echo "✅ App reachable" + exit 0 + fi + sleep 3 + done + echo "❌ App did not become reachable" + docker compose -f docker-compose.production.yml logs app --tail=200 + exit 1 + + - name: Assert migrations + S3 bucket ready + run: | + set -euo pipefail + # Startup messages can land slightly after the HTTP port opens, so + # poll instead of one-shot grepping to avoid flaky failures. + for i in $(seq 40); do + logs="$(docker compose -f docker-compose.production.yml logs app || true)" + if grep -q "Database migrations applied successfully" <<<"$logs" \ + && grep -q 'S3 bucket "reqcore" is ready' <<<"$logs"; then + echo "✅ Migrations + S3 ready messages found (attempt $i)" + exit 0 + fi + sleep 3 + done + echo "❌ Required startup messages missing after polling" + docker compose -f docker-compose.production.yml logs app + exit 1 + + - name: Demote release to pre-release on failure + if: failure() && github.event_name == 'release' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release edit "${{ steps.tag.outputs.tag }}" --prerelease --latest=false + gh release view "${{ steps.tag.outputs.tag }}" --json isPrerelease,isLatest + + bundle: + name: Attach self-hoster bundle + needs: smoke-test + runs-on: ubuntu-latest + steps: + - name: Resolve release tag + id: tag + run: | + set -euo pipefail + tag="${{ github.event.release.tag_name || inputs.tag }}" + version="${tag#v}" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Checkout release tag + uses: actions/checkout@v6 + with: + ref: ${{ steps.tag.outputs.tag }} + + - name: Build version-pinned bundle + run: | + set -euo pipefail + mkdir -p "bundle/reqcore-${{ steps.tag.outputs.version }}" + cp setup.sh "bundle/reqcore-${{ steps.tag.outputs.version }}/" + cp SELF-HOSTING.md "bundle/reqcore-${{ steps.tag.outputs.version }}/" + # Pin the compose file to the exact released image tag so users + # who download the bundle get a deterministic install. + sed \ + "s|ghcr.io/reqcore-inc/reqcore:latest|ghcr.io/reqcore-inc/reqcore:${{ steps.tag.outputs.version }}|" \ + docker-compose.production.yml \ + > "bundle/reqcore-${{ steps.tag.outputs.version }}/docker-compose.production.yml" + + cat > "bundle/reqcore-${{ steps.tag.outputs.version }}/INSTALL.txt" < "reqcore-${{ steps.tag.outputs.version }}.tar.gz.sha256" + + - name: Attach bundle to the GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "${{ steps.tag.outputs.tag }}" \ + "reqcore-${{ steps.tag.outputs.version }}.tar.gz" \ + "reqcore-${{ steps.tag.outputs.version }}.tar.gz.sha256" \ + --clobber diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..d220bda6 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,59 @@ +{ + // VS Code tasks for the release / dependency workflow. + // + // Run any of these via: Ctrl+Shift+P → "Tasks: Run Task" + // + // The whole release flow is automated by GitHub Actions (release-please + + // docker-publish + release-smoke-test). These tasks are only for visibility + // and one-off local actions — you should never *need* them in steady state. + "version": "2.0.0", + "tasks": [ + { + "label": "Release: watch latest run", + "type": "shell", + "command": "gh run list --workflow=release-please.yml --limit 5 && gh run watch", + "problemMatcher": [], + "presentation": { "reveal": "always", "panel": "dedicated" }, + "detail": "Show recent release-please runs and tail the latest." + }, + { + "label": "Release: open release-please PR", + "type": "shell", + "command": "gh pr list --label autorelease:pending --json url --jq '.[0].url' | ForEach-Object { Start-Process $_ }", + "windows": { + "command": "gh pr list --label \"autorelease: pending\" --json url --jq '.[0].url' | ForEach-Object { Start-Process $_ }" + }, + "problemMatcher": [], + "detail": "Open the pending release-please PR in the browser." + }, + { + "label": "Release: dry-run notes (local)", + "type": "shell", + "command": "npx release-please release-pr --dry-run --token=$env:GITHUB_TOKEN --repo-url=https://github.com/reqcore-inc/reqcore --config-file=.github/release-please-config.json --manifest-file=.release-please-manifest.json", + "problemMatcher": [], + "detail": "Preview what the next release would contain, without creating anything." + }, + { + "label": "Dependabot: list open PRs", + "type": "shell", + "command": "gh pr list --author 'app/dependabot' --state open", + "problemMatcher": [], + "detail": "Show all open Dependabot PRs and their auto-merge status." + }, + { + "label": "Dependabot: enable automerge on current branch PR", + "type": "shell", + "command": "gh pr merge --auto --squash", + "problemMatcher": [], + "detail": "Enable auto-merge for the PR associated with the current branch (gates on CI)." + }, + { + "label": "CI: tail latest workflow run", + "type": "shell", + "command": "gh run watch", + "problemMatcher": [], + "presentation": { "reveal": "always", "panel": "dedicated" }, + "detail": "Tail the most recently triggered workflow run for this repo." + } + ] +} diff --git a/SELF-HOSTING.md b/SELF-HOSTING.md index cc487ccd..2810a594 100644 --- a/SELF-HOSTING.md +++ b/SELF-HOSTING.md @@ -17,9 +17,10 @@ Everything you need to deploy, manage, and update your own Reqcore applicant tra 9. [Custom Domain & HTTPS](#custom-domain--https) 10. [Email Configuration](#email-configuration) 11. [Security Best Practices](#security-best-practices) -12. [Monitoring & Health Checks](#monitoring--health-checks) -13. [Troubleshooting](#troubleshooting) -14. [FAQ](#faq) +12. [Feature Flags](#feature-flags) +13. [Monitoring & Health Checks](#monitoring--health-checks) +14. [Troubleshooting](#troubleshooting) +15. [FAQ](#faq) --- @@ -82,7 +83,30 @@ All of these providers offer one-click Docker installation when creating a serve ## Quick Start — Pre-built Image (Fastest) -Use the official pre-built Docker image from GitHub Container Registry. No cloning, no building — just pull and run: +Use the official pre-built Docker image from GitHub Container Registry. No cloning, no building — just pull and run. + +### Option A — Versioned release bundle (recommended) + +Every [GitHub Release](https://github.com/reqcore-inc/reqcore/releases/latest) ships with a `reqcore-.tar.gz` bundle that contains `setup.sh` and a `docker-compose.production.yml` with the image tag already pinned to that exact version. This is the most reliable way to install or upgrade. + +```bash +# 1. Download and extract the latest release bundle +curl -fsSL -o reqcore.tar.gz https://github.com/reqcore-inc/reqcore/releases/latest/download/reqcore-$(curl -fsSL https://api.github.com/repos/reqcore-inc/reqcore/releases/latest | grep tag_name | cut -d '"' -f 4 | sed 's/^v//').tar.gz +tar -xzf reqcore.tar.gz && cd reqcore-* + +# 2. Generate secure passwords (one-time) +./setup.sh + +# 3. Start everything +docker compose -f docker-compose.production.yml up -d + +# 4. Open your browser +# → http://localhost:3000 +``` + +To upgrade later, download the newer release bundle into a new directory, copy your existing `.env` over, and run `docker compose up -d`. + +### Option B — Pull straight from `main` ```bash # 1. Download just the files you need @@ -110,6 +134,18 @@ app: image: ghcr.io/reqcore-inc/reqcore:1.3.0 ``` +### Verifying image authenticity (optional) + +Every published image is signed with [cosign](https://github.com/sigstore/cosign) using GitHub's keyless OIDC. To verify the image you pulled was actually built by the official release workflow: + +```bash +cosign verify ghcr.io/reqcore-inc/reqcore: \ + --certificate-identity-regexp 'https://github.com/reqcore-inc/reqcore/.github/workflows/docker-publish.yml@.*' \ + --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' +``` + +A successful verification confirms the image is unmodified and was produced by the official CI pipeline. + --- ## Quick Start — Build from Source (5 Minutes) @@ -592,6 +628,45 @@ The SSO button appears automatically on the sign-in and sign-up pages. --- +## Feature Flags + +Reqcore ships some features behind **feature flags** so they can be tested in production before being released to everyone. The full list of flags lives in [`shared/feature-flags.ts`](shared/feature-flags.ts). + +### How it works for self-hosters + +Every flag has a safe **default value** baked into the code. You get that default automatically — **no PostHog account or external service required**. + +If you want to opt into an experimental feature (or disable a stable one), set an environment variable matching the pattern: + +```bash +FEATURE_FLAG_=true +``` + +Examples: + +```bash +# Enable the new chatbot experience for everyone on this instance +FEATURE_FLAG_CHATBOT_EXPERIENCE=true + +# Force-disable a flag that defaults to on +FEATURE_FLAG_SOMETHING_ELSE=false +``` + +Restart the container after editing `.env`. Env-var overrides win over any PostHog rollout, so this is the authoritative knob for self-hosters. + +### Resolution order + +1. URL query string (e.g. `?ff_chatbot-experience=true`) — handy for QA +2. Env var override (`FEATURE_FLAG_*`) — what you'll use 99% of the time +3. PostHog rollout — only applies when `POSTHOG_PUBLIC_KEY` is set +4. Registry default from `shared/feature-flags.ts` + +### I want to use PostHog for gradual rollout + +Optional. Set `POSTHOG_PUBLIC_KEY` and `POSTHOG_HOST` in `.env`, then create a flag in your PostHog project with a key matching the registry (e.g. `chatbot-experience`). For server-side flags without per-request HTTP calls, also set `POSTHOG_FEATURE_FLAGS_KEY` to a personal API key with the **Feature Flags: read** scope. + +--- + ## Monitoring & Health Checks ### Built-in System Health Dashboard diff --git a/app/components/AiConfigForm.vue b/app/components/AiConfigForm.vue new file mode 100644 index 00000000..54d826f9 --- /dev/null +++ b/app/components/AiConfigForm.vue @@ -0,0 +1,609 @@ + + + diff --git a/app/components/AppTopBar.vue b/app/components/AppTopBar.vue index e87f0f03..0bdbb6e0 100644 --- a/app/components/AppTopBar.vue +++ b/app/components/AppTopBar.vue @@ -6,6 +6,7 @@ import { ChevronDown, Menu, X, Users, ChevronLeft, LayoutDashboard, Calendar, ArrowUpCircle, Cloud, Server, Sparkles, Radio, History, + MessageCircle, } from 'lucide-vue-next' const route = useRoute() @@ -103,6 +104,8 @@ const { data: feedbackConfig } = useFetch('/api/feedback/config', { const isFeedbackEnabled = computed(() => feedbackConfig.value?.enabled === true) +const showChatbot = useFeatureFlagEnabled('chatbot-experience') + const jobTabs = computed(() => { if (!activeJobId.value) return [] const base = `/dashboard/jobs/${activeJobId.value}` @@ -131,6 +134,28 @@ const mainNav: Array<{ label: string; to: string; icon: typeof Briefcase; exact: { label: 'Settings', to: '/dashboard/settings', icon: Settings, exact: false }, ] +// Items shown only when their feature flag is enabled. Filtered into mainNav +// reactively so the gating happens at render time (PostHog flags load async). +const flaggedNav = computed(() => { + const items: Array<{ label: string; to: string; icon: typeof Briefcase; exact: boolean; afterLabel: string }> = [] + if (showChatbot.value) { + items.push({ label: 'Assistant', to: '/dashboard/chatbot', icon: MessageCircle, exact: false, afterLabel: 'AI Analysis' }) + } + return items +}) + +const navItems = computed(() => { + const merged = [...mainNav] + for (const item of flaggedNav.value) { + const idx = merged.findIndex((n) => n.label === item.afterLabel) + const insertAt = idx >= 0 ? idx + 1 : merged.length + merged.splice(insertAt, 0, { + label: item.label, to: item.to, icon: item.icon, exact: item.exact, + }) + } + return merged +}) + function isActiveRoute(to: string, exact: boolean) { const localizedTo = localePath(to) if (exact) return route.path === localizedTo @@ -196,7 +221,7 @@ onUnmounted(() => {
-
+
@@ -492,7 +517,7 @@ onUnmounted(() => { >