diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f89d31b..b04f1cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -222,3 +222,96 @@ 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: | + # 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 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) 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']); 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(