From d48d9c293aa1d6b8af71f7da9740d20551357156 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Wed, 23 Sep 2026 11:44:36 +0200 Subject: [PATCH 1/4] fix(frontend): queryFn signatures + Link import useQuery received api fns typed (params?) => Promise; React Query passes a QueryFunctionContext, so data degraded to {} and broke tsc. Wrap calls in () => and import Link from react-router-dom. Authored By: TDvorak Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/frontend/src/features/landing/LandingPage.tsx | 2 +- .../src/features/projects/pages/ProjectsPage.tsx | 2 +- app/frontend/src/features/secondary/pages.tsx | 10 +++++----- .../src/features/templates/pages/TemplatesPage.tsx | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/frontend/src/features/landing/LandingPage.tsx b/app/frontend/src/features/landing/LandingPage.tsx index c16f925..5e72685 100644 --- a/app/frontend/src/features/landing/LandingPage.tsx +++ b/app/frontend/src/features/landing/LandingPage.tsx @@ -28,7 +28,7 @@ export function LandingPage() { const sessionQuery = useAuthSession(); const signedIn = Boolean(sessionQuery.data); - const projectsQuery = useQuery({ queryKey: ['landing-projects'], queryFn: listProjects, staleTime: 60_000 }); + const projectsQuery = useQuery({ queryKey: ['landing-projects'], queryFn: () => listProjects(), staleTime: 60_000 }); const templatesQuery = useQuery({ queryKey: ['landing-templates'], queryFn: () => listTemplates(), staleTime: 60_000 }); const projects = projectsQuery.data ?? []; diff --git a/app/frontend/src/features/projects/pages/ProjectsPage.tsx b/app/frontend/src/features/projects/pages/ProjectsPage.tsx index 961ed41..fa72695 100644 --- a/app/frontend/src/features/projects/pages/ProjectsPage.tsx +++ b/app/frontend/src/features/projects/pages/ProjectsPage.tsx @@ -160,7 +160,7 @@ export function ProjectsPage() { const projectsQuery = useQuery({ queryKey: ['projects'], enabled: !isDemoMode, - queryFn: listProjects, + queryFn: () => listProjects(), }); const hostQuery = useQuery({ queryKey: ['host-monitoring'], diff --git a/app/frontend/src/features/secondary/pages.tsx b/app/frontend/src/features/secondary/pages.tsx index 1a31961..66caa3f 100644 --- a/app/frontend/src/features/secondary/pages.tsx +++ b/app/frontend/src/features/secondary/pages.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useNavigate, useSearchParams } from 'react-router-dom'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { getApiBaseUrl, getAgentPublicBaseUrl, @@ -698,7 +698,7 @@ export function PeoplePage() { const projectsQuery = useQuery({ queryKey: ['people-projects'], - queryFn: listProjects, + queryFn: () => listProjects(), }); const auditLogsQuery = useQuery({ queryKey: ['people-audit-logs'], @@ -1926,7 +1926,7 @@ function BindDatabasePanel({ const [varKey, setVarKey] = useState('DATABASE_URL'); const [bound, setBound] = useState(false); - const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: listProjects }); + const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: () => listProjects() }); const servicesQuery = useQuery({ queryKey: ['services', projectId], queryFn: () => listServicesByProject(projectId), @@ -2097,7 +2097,7 @@ export function HighAvailabilityPage() { const policiesQuery = useQuery({ queryKey: ['ha-policies'], queryFn: listFailoverPolicies }); const alertsQuery = useQuery({ queryKey: ['ha-alerts'], queryFn: listActiveAlerts, refetchInterval: 15_000 }); const healthQuery = useQuery({ queryKey: ['ha-health'], queryFn: listHealthResults, refetchInterval: 30_000 }); - const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: listProjects }); + const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: () => listProjects() }); const servicesQuery = useQuery({ queryKey: ['services', policyProjectId], queryFn: () => listServicesByProject(policyProjectId), @@ -2515,7 +2515,7 @@ export function SecurityPage() { const [scanType, setScanType] = useState('comprehensive'); const [scanServiceId, setScanServiceId] = useState(''); - const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: listProjects }); + const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: () => listProjects() }); const servicesQuery = useQuery({ queryKey: ['services', projectId], queryFn: () => listServicesByProject(projectId), diff --git a/app/frontend/src/features/templates/pages/TemplatesPage.tsx b/app/frontend/src/features/templates/pages/TemplatesPage.tsx index 7c5c0de..a9def7c 100644 --- a/app/frontend/src/features/templates/pages/TemplatesPage.tsx +++ b/app/frontend/src/features/templates/pages/TemplatesPage.tsx @@ -470,7 +470,7 @@ export function TemplatesPage() { const projectsQuery = useQuery({ queryKey: ['template-projects'], enabled: !isDemoMode, - queryFn: listProjects, + queryFn: () => listProjects(), }); const templates = useMemo( From 7c19af307e4d89949450f829118c3c93c0374072 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Wed, 23 Sep 2026 11:44:37 +0200 Subject: [PATCH 2/4] fix(build): reuse committed docs snapshot when docs/ is outside context Script resolved paths from repo root; inside the frontend Docker build (context = app/frontend) it scanned /docs and died on ENOENT, breaking the compose smoke job. Resolve from the frontend dir and fall back to the committed snapshot when the docs tree is absent. Authored By: TDvorak Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../scripts/generate-docs-snapshot.mjs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/app/frontend/scripts/generate-docs-snapshot.mjs b/app/frontend/scripts/generate-docs-snapshot.mjs index 6534cee..766fa4c 100644 --- a/app/frontend/scripts/generate-docs-snapshot.mjs +++ b/app/frontend/scripts/generate-docs-snapshot.mjs @@ -1,13 +1,26 @@ // Bundles the repository docs/ tree into a generated TS module so the docs // browser renders instantly and offline. The GitHub sync layer refreshes it // at runtime when network is available. -import { readdirSync, readFileSync, statSync, writeFileSync, mkdirSync } from 'node:fs'; +import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, mkdirSync } from 'node:fs'; import { join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; -const root = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..', '..'); +const frontendRoot = join(fileURLToPath(new URL('.', import.meta.url)), '..'); +const root = join(frontendRoot, '..', '..'); const docsDir = join(root, 'docs'); -const outFile = join(root, 'app/frontend/src/generated/docs-snapshot.ts'); +const outFile = join(frontendRoot, 'src/generated/docs-snapshot.ts'); + +// The published frontend image builds with only app/frontend as its Docker +// context, so the repo docs tree is absent there — reuse the committed +// snapshot instead of failing on ENOENT. +if (!existsSync(docsDir)) { + if (!existsSync(outFile)) { + console.error('docs snapshot: docs/ missing and no committed snapshot to reuse'); + process.exit(1); + } + console.warn('docs snapshot: docs/ not found, reusing committed snapshot'); + process.exit(0); +} // Directories that are internal working notes rather than user documentation. const SKIP_DIRS = new Set(['archive', 'design', 'superpowers']); From a99f2e461e8f334894f2d33299f5de13319af9f5 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Wed, 23 Sep 2026 11:44:37 +0200 Subject: [PATCH 3/4] feat(ci): release pipeline on merge to main After tests + image publish, a release job bumps the tag via conventional commits (github-tag-action, default_bump=false so docs/chore merges skip releasing), retags :main images in GHCR as semver+latest (tag pushes via GITHUB_TOKEN do not retrigger CI), cross-compiles the CLI for 5 platforms, and cuts a GitHub Release with generated notes and checksums. Manual v* tag pushes get the same release treatment. CLI --version now reports the tag via the previously dead -X ldflags. Authored By: TDvorak Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 90 ++++++++++++++++++++++++++++++++ app/backend/cmd/cli/main.go | 8 +++ app/backend/internal/cli/root.go | 6 +++ 3 files changed, 104 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f89d31b..4245aa9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -222,3 +222,93 @@ jobs: ignore-unfixed: true vuln-type: os,library severity: CRITICAL,HIGH + + release: + name: Release + runs-on: ubuntu-latest + needs: [docker] + if: github.event_name != 'pull_request' + permissions: + contents: write + packages: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Conventional-commit bump. Tags pushed with GITHUB_TOKEN do not + # retrigger this workflow, so images are retagged below instead. + - name: Bump version and push tag + if: github.ref == 'refs/heads/main' + id: tag_version + uses: mathieudutour/github-tag-action@v6.2 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + default_bump: false + + - name: Resolve release tag + id: release + run: | + if [ "${{ github.ref_type }}" = "tag" ]; then + tag="${GITHUB_REF_NAME}" + else + tag="${{ steps.tag_version.outputs.new_tag }}" + fi + if [ -z "$tag" ]; then + echo "No releasable commits since last tag - skipping release." + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + - name: Setup Go + if: steps.release.outputs.tag != '' + uses: actions/setup-go@v5 + with: + go-version-file: app/backend/go.mod + cache: true + + - name: Build CLI binaries + if: steps.release.outputs.tag != '' + working-directory: app/backend + env: + VERSION: ${{ steps.release.outputs.tag }} + run: bash ../../scripts/build-cli.sh + + - name: Checksums + if: steps.release.outputs.tag != '' + working-directory: app/backend/bin + run: sha256sum containr-* > checksums.txt + + - name: Login to GHCR + if: steps.release.outputs.tag != '' && github.ref == 'refs/heads/main' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Tag pushes from github-tag-action do not trigger workflows, so the + # release job retags the just-published :main images in-registry. + - name: Tag release images in GHCR + if: steps.release.outputs.tag != '' && github.ref == 'refs/heads/main' + env: + TAG: ${{ steps.release.outputs.tag }} + run: | + repo="${GITHUB_REPOSITORY,,}" + for img in backend frontend; do + docker buildx imagetools create \ + --tag "ghcr.io/${repo}-${img}:${TAG}" \ + --tag "ghcr.io/${repo}-${img}:latest" \ + "ghcr.io/${repo}-${img}:main" + done + + - name: Create GitHub Release + if: steps.release.outputs.tag != '' + working-directory: app/backend/bin + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.release.outputs.tag }} + run: | + gh release create "$TAG" --title "$TAG" --generate-notes \ + containr-* checksums.txt \ + || gh release upload "$TAG" containr-* checksums.txt --clobber diff --git a/app/backend/cmd/cli/main.go b/app/backend/cmd/cli/main.go index 2ba531b..854452b 100644 --- a/app/backend/cmd/cli/main.go +++ b/app/backend/cmd/cli/main.go @@ -6,7 +6,15 @@ import ( "os" ) +// Injected at build time via -ldflags (see scripts/build-cli.sh). +var ( + Version = "dev" + BuildTime = "unknown" + GitCommit = "unknown" +) + func main() { + cli.SetVersion(fmt.Sprintf("%s (%s, %s)", Version, GitCommit, BuildTime)) if err := cli.Run(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) diff --git a/app/backend/internal/cli/root.go b/app/backend/internal/cli/root.go index 731d5d9..ee9c8ab 100644 --- a/app/backend/internal/cli/root.go +++ b/app/backend/internal/cli/root.go @@ -27,6 +27,12 @@ func Execute() { cobra.CheckErr(rootCmd.Execute()) } +// SetVersion overrides the version reported by `containr --version`. +// Called by main with the value injected via -ldflags at build time. +func SetVersion(v string) { + rootCmd.Version = v +} + func init() { cobra.OnInitialize(initConfig) From 9a2aa7267bbf1c7ced2d76985619df8c148a703f Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Wed, 23 Sep 2026 11:57:37 +0200 Subject: [PATCH 4/4] fix(ci): recreate release instead of --clobber upload gh release upload --clobber races GitHub's asset tombstones and 422s intermittently on reruns. Delete-then-create is deterministic; the git tag is kept. Authored By: TDvorak Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4245aa9..b04f1cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -309,6 +309,9 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ steps.release.outputs.tag }} run: | + # Delete-then-create keeps reruns idempotent: --clobber uploads + # race GitHub's asset tombstones and intermittently 422. The git + # tag itself is kept (no --cleanup-tag). + gh release delete "$TAG" --yes 2>/dev/null || true gh release create "$TAG" --title "$TAG" --generate-notes \ - containr-* checksums.txt \ - || gh release upload "$TAG" containr-* checksums.txt --clobber + containr-* checksums.txt