Compare commits

..

6 Commits

Author SHA1 Message Date
af52b8581f feat(ci): stamp build metadata and validate footer version hash 2026-02-11 19:06:55 +01:00
3de4d5732e feat(versioning): show runtime version and git hash in app footers 2026-02-11 19:01:53 +01:00
14c3df623a fix(db): organize imports for biome check
Some checks failed
CMS CI / Governance Checks (push) Successful in 1m2s
CMS CI / Lint Typecheck Unit E2E (push) Failing after 3m38s
2026-02-11 18:45:30 +01:00
a57464d818 chore(repo): remove theoretical workflow and fix prisma ci generation
Some checks failed
CMS CI / Governance Checks (push) Successful in 1m6s
CMS CI / Lint Typecheck Unit E2E (push) Failing after 1m8s
2026-02-11 18:26:42 +01:00
c174f840bc fix(ci): gitea workflows
Some checks failed
CMS CI/CD (Theoretical) / Lint Typecheck Tests (push) Failing after 35s
CMS CI / Governance Checks (push) Successful in 1m1s
CMS CI/CD (Theoretical) / Build Staging Images (push) Has been skipped
CMS CI/CD (Theoretical) / Build Production Images (push) Has been skipped
CMS CI/CD (Theoretical) / Deploy Staging (Placeholder) (push) Has been skipped
CMS CI / Lint Typecheck Unit E2E (push) Failing after 1m25s
CMS CI/CD (Theoretical) / Deploy Production (Placeholder) (push) Has been skipped
2026-02-11 13:12:12 +01:00
334a5e3526 chore(ci): add gitea actions runner compose setup 2026-02-11 12:25:57 +01:00
21 changed files with 179 additions and 129 deletions

View File

@@ -10,5 +10,7 @@ CMS_SUPPORT_EMAIL="support@cms.local"
CMS_SUPPORT_PASSWORD="change-me-support-password" CMS_SUPPORT_PASSWORD="change-me-support-password"
CMS_SUPPORT_NAME="Technical Support" CMS_SUPPORT_NAME="Technical Support"
CMS_SUPPORT_LOGIN_KEY="support-access-change-me" CMS_SUPPORT_LOGIN_KEY="support-access-change-me"
NEXT_PUBLIC_APP_VERSION="0.1.0-dev"
NEXT_PUBLIC_GIT_SHA="local"
# Optional dev bypass role for admin middleware. Leave empty to require auth login. # Optional dev bypass role for admin middleware. Leave empty to require auth login.
# CMS_DEV_ROLE="admin" # CMS_DEV_ROLE="admin"

View File

@@ -0,0 +1,4 @@
GITEA_INSTANCE_URL="https://git.example.com"
GITEA_RUNNER_REGISTRATION_TOKEN="replace-with-runner-registration-token"
GITEA_RUNNER_NAME="cms-runner"
GITEA_RUNNER_LABELS="ubuntu-latest:docker://node:20-bookworm"

View File

@@ -1,113 +0,0 @@
name: CMS CI/CD (Theoretical)
on:
pull_request:
push:
branches:
- dev
- main
- staging
tags:
- "v*"
workflow_dispatch:
env:
BUN_VERSION: "1.3.5"
jobs:
quality:
name: Lint Typecheck Tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: ${{ env.BUN_VERSION }}
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Generate Prisma client
run: bun run db:generate
- name: Lint
run: bun run lint
- name: Typecheck
run: bun run typecheck
- name: Unit and component tests
run: bun run test
- name: E2E suite discovery check
run: bun run test:e2e --list
- name: Conventional commit check (latest commit)
run: bun run commitlint
build_staging_images:
name: Build Staging Images
runs-on: ubuntu-latest
needs: quality
if: github.ref == 'refs/heads/staging'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build web image (staging)
run: docker build -f apps/web/Dockerfile -t cms-web:staging .
- name: Build admin image (staging)
run: docker build -f apps/admin/Dockerfile -t cms-admin:staging .
build_production_images:
name: Build Production Images
runs-on: ubuntu-latest
needs: quality
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build web image (production)
run: docker build -f apps/web/Dockerfile -t cms-web:${{ github.ref_name }} .
- name: Build admin image (production)
run: docker build -f apps/admin/Dockerfile -t cms-admin:${{ github.ref_name }} .
- name: Generate changelog
run: |
bun install --frozen-lockfile
bun run changelog:release
- name: Push images (placeholder)
run: |
echo "TODO: docker login to registry"
echo "TODO: docker push cms-web:${{ github.ref_name }}"
echo "TODO: docker push cms-admin:${{ github.ref_name }}"
echo "TODO: publish CHANGELOG.md content as release notes"
deploy_staging:
name: Deploy Staging (Placeholder)
runs-on: ubuntu-latest
needs: build_staging_images
if: github.ref == 'refs/heads/staging'
steps:
- name: Deploy placeholder
run: |
echo "TODO: Pull and restart staging compose on target host"
echo "docker compose -f docker-compose.staging.yml up -d"
deploy_production:
name: Deploy Production (Placeholder)
runs-on: ubuntu-latest
needs: build_production_images
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Deploy placeholder
run: |
echo "TODO: Pull and restart production compose on target host"
echo "docker compose -f docker-compose.production.yml up -d"

View File

@@ -27,7 +27,7 @@ env:
jobs: jobs:
governance: governance:
name: Governance Checks name: Governance Checks
runs-on: ubuntu-latest runs-on: node22-bun
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -57,7 +57,7 @@ jobs:
quality: quality:
name: Lint Typecheck Unit E2E name: Lint Typecheck Unit E2E
needs: governance needs: governance
runs-on: ubuntu-latest runs-on: node22-bun
services: services:
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
@@ -84,12 +84,21 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
- name: Resolve build metadata
run: |
version=$(bun -e 'const pkg = JSON.parse(await Bun.file("package.json").text()); console.log(pkg.version)')
echo "NEXT_PUBLIC_APP_VERSION=$version" >> "$GITHUB_ENV"
echo "NEXT_PUBLIC_GIT_SHA=${GITHUB_SHA}" >> "$GITHUB_ENV"
- name: Install Playwright browser deps - name: Install Playwright browser deps
run: bunx playwright install --with-deps chromium run: bunx playwright install --with-deps chromium
- name: Lint and format checks - name: Lint and format checks
run: bun run check run: bun run check
- name: Generate Prisma client
run: bun run db:generate
- name: Typecheck - name: Typecheck
run: bun run typecheck run: bun run typecheck

View File

@@ -20,7 +20,7 @@ on:
jobs: jobs:
deploy: deploy:
name: Deploy Compose Stack name: Deploy Compose Stack
runs-on: ubuntu-latest runs-on: node22-bun
steps: steps:
- name: Resolve deployment target - name: Resolve deployment target
id: target id: target

View File

@@ -21,7 +21,7 @@ env:
jobs: jobs:
release: release:
name: Build Push Changelog name: Build Push Changelog
runs-on: ubuntu-latest runs-on: node22-bun
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4

1
.gitignore vendored
View File

@@ -24,6 +24,7 @@ test-results
!.env.example !.env.example
!.env.staging.example !.env.staging.example
!.env.production.example !.env.production.example
!.env.gitea-runner.example
# prisma # prisma
packages/db/prisma/dev.db* packages/db/prisma/dev.db*

View File

@@ -97,10 +97,11 @@ bunx playwright install
## Delivery Scaffolding ## Delivery Scaffolding
The repo includes a theoretical CI/CD and deployment baseline: The repo includes a CI/CD and deployment baseline:
- Gitea workflow: `.gitea/workflows/ci-cd-theoretical.yml` - Quality gate workflow: `.gitea/workflows/ci.yml`
- Real quality gate workflow: `.gitea/workflows/ci.yml` - Deployment workflow: `.gitea/workflows/deploy.yml`
- Release workflow: `.gitea/workflows/release.yml`
- App images: - App images:
- `apps/web/Dockerfile` - `apps/web/Dockerfile`
- `apps/admin/Dockerfile` - `apps/admin/Dockerfile`
@@ -119,12 +120,20 @@ Environment examples:
- `.env.staging.example` - `.env.staging.example`
- `.env.production.example` - `.env.production.example`
- `.env.gitea-runner.example`
Notes: Notes:
- `dev` remains your local non-docker Bun workflow. - `dev` remains your local non-docker Bun workflow.
- Staging and production compose files are templates and still require real secrets, registry strategy, and deployment host wiring. - Staging and production compose files are templates and still require real secrets, registry strategy, and deployment host wiring.
Gitea Actions runner compose (self-hosted):
```bash
cp .env.gitea-runner.example .env.gitea-runner
docker compose --env-file .env.gitea-runner -f docker-compose.gitea-runner.yml up -d
```
## Changelog ## Changelog
- Changelog file: `CHANGELOG.md` - Changelog file: `CHANGELOG.md`

17
TODO.md
View File

@@ -81,16 +81,16 @@ This file is the single source of truth for roadmap and delivery progress.
### Delivery Pipeline And Runtime ### Delivery Pipeline And Runtime
- [x] [P2] Theoretical Gitea Actions workflow scaffold (`.gitea/workflows/ci-cd-theoretical.yml`) - [x] [P2] Gitea workflow baseline (`.gitea/workflows/ci.yml`, `.gitea/workflows/deploy.yml`, `.gitea/workflows/release.yml`)
- [x] [P2] Bun-based Dockerfiles for public and admin apps - [x] [P2] Bun-based Dockerfiles for public and admin apps
- [x] [P2] Staging and production docker-compose templates - [x] [P2] Staging and production docker-compose templates
- [x] [P1] Registry credentials and image push strategy - [x] [P1] Registry credentials and image push strategy
- [x] [P1] Staging deployment automation against real host - [~] [P1] Staging deployment automation against real host
- [x] [P1] Production promotion and rollback procedure - [~] [P1] Production promotion and rollback procedure
### Git Flow And Branching ### Git Flow And Branching
- [x] [P1] Protect `main` and `staging` branches in Gitea - [~] [P1] Protect `main` and `staging` branches in Gitea
- [x] [P1] Define PR gates: lint + typecheck + unit + e2e list minimum - [x] [P1] Define PR gates: lint + typecheck + unit + e2e list minimum
- [x] [P1] Enforce one todo item per branch naming convention - [x] [P1] Enforce one todo item per branch naming convention
- [x] [P2] Add PR template requiring linked TODO step - [x] [P2] Add PR template requiring linked TODO step
@@ -105,6 +105,15 @@ This file is the single source of truth for roadmap and delivery progress.
- [x] [P2] Validation tests for displayed version/hash consistency per deployment - [x] [P2] Validation tests for displayed version/hash consistency per deployment
- [x] [P1] Release tagging and changelog publication policy in CI - [x] [P1] Release tagging and changelog publication policy in CI
### MVP0 Close-Out Checklist
- [ ] [P1] Verify and document protected branch rules in Gitea (`main`, `staging`)
- [ ] [P1] Run first staging deployment against a real host with deploy workflow and document result
- [ ] [P1] Replace release workflow placeholders with real release-notes and rollback execution steps
- [x] [P1] Expose runtime version + short git hash in admin and public app footer
- [x] [P2] Add CI build stamping for version/hash values consumed by app footers
- [x] [P2] Add automated tests validating displayed version/hash format and consistency
## MVP 1: Core CMS Business Features ## MVP 1: Core CMS Business Features
### Admin App (Primary Focus) ### Admin App (Primary Focus)

View File

@@ -4,6 +4,7 @@ import type { ReactNode } from "react"
import { LogoutButton } from "@/app/logout-button" import { LogoutButton } from "@/app/logout-button"
import { AdminLocaleSwitcher } from "@/components/admin-locale-switcher" import { AdminLocaleSwitcher } from "@/components/admin-locale-switcher"
import { getBuildInfo } from "@/lib/build-info"
type AdminShellProps = { type AdminShellProps = {
role: Role role: Role
@@ -57,6 +58,8 @@ export function AdminShell({
actions, actions,
children, children,
}: AdminShellProps) { }: AdminShellProps) {
const buildInfo = getBuildInfo()
return ( return (
<div className="mx-auto flex min-h-screen w-full max-w-7xl gap-8 px-6 py-10"> <div className="mx-auto flex min-h-screen w-full max-w-7xl gap-8 px-6 py-10">
<aside className="sticky top-0 hidden h-fit w-64 shrink-0 space-y-4 lg:block"> <aside className="sticky top-0 hidden h-fit w-64 shrink-0 space-y-4 lg:block">
@@ -111,6 +114,10 @@ export function AdminShell({
</header> </header>
{children} {children}
<footer className="border-t border-neutral-200 pt-4 text-xs text-neutral-500">
Build v{buildInfo.version} +sha.{buildInfo.sha}
</footer>
</div> </div>
</div> </div>
) )

View File

@@ -0,0 +1,29 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import { getBuildInfo } from "./build-info"
afterEach(() => {
vi.unstubAllEnvs()
})
describe("getBuildInfo (admin)", () => {
it("returns fallback values when env is missing", () => {
vi.stubEnv("NEXT_PUBLIC_APP_VERSION", "")
vi.stubEnv("NEXT_PUBLIC_GIT_SHA", "")
expect(getBuildInfo()).toEqual({
version: "0.0.1-dev",
sha: "local",
})
})
it("uses env values and truncates git sha", () => {
vi.stubEnv("NEXT_PUBLIC_APP_VERSION", "0.2.0")
vi.stubEnv("NEXT_PUBLIC_GIT_SHA", "abcdef123456")
expect(getBuildInfo()).toEqual({
version: "0.2.0",
sha: "abcdef1",
})
})
})

View File

@@ -0,0 +1,21 @@
const FALLBACK_VERSION = "0.0.1-dev"
const FALLBACK_SHA = "local"
function shortenSha(input: string): string {
const value = input.trim()
if (!value) {
return FALLBACK_SHA
}
return value.slice(0, 7)
}
export function getBuildInfo() {
const version = process.env.NEXT_PUBLIC_APP_VERSION?.trim() || FALLBACK_VERSION
const sha = shortenSha(process.env.NEXT_PUBLIC_GIT_SHA ?? "")
return {
version,
sha,
}
}

View File

@@ -2,9 +2,12 @@
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { getBuildInfo } from "@/lib/build-info"
export function PublicSiteFooter() { export function PublicSiteFooter() {
const t = useTranslations("Layout") const t = useTranslations("Layout")
const year = new Date().getFullYear() const year = new Date().getFullYear()
const buildInfo = getBuildInfo()
return ( return (
<footer className="border-t border-neutral-200 bg-neutral-50"> <footer className="border-t border-neutral-200 bg-neutral-50">
@@ -15,6 +18,9 @@ export function PublicSiteFooter() {
})} })}
</p> </p>
<p>{t("footer.tagline")}</p> <p>{t("footer.tagline")}</p>
<p className="font-mono text-xs text-neutral-500">
Build v{buildInfo.version} +sha.{buildInfo.sha}
</p>
</div> </div>
</footer> </footer>
) )

View File

@@ -0,0 +1,29 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import { getBuildInfo } from "./build-info"
afterEach(() => {
vi.unstubAllEnvs()
})
describe("getBuildInfo (web)", () => {
it("returns fallback values when env is missing", () => {
vi.stubEnv("NEXT_PUBLIC_APP_VERSION", "")
vi.stubEnv("NEXT_PUBLIC_GIT_SHA", "")
expect(getBuildInfo()).toEqual({
version: "0.0.1-dev",
sha: "local",
})
})
it("uses env values and truncates git sha", () => {
vi.stubEnv("NEXT_PUBLIC_APP_VERSION", "0.2.0")
vi.stubEnv("NEXT_PUBLIC_GIT_SHA", "123456789abc")
expect(getBuildInfo()).toEqual({
version: "0.2.0",
sha: "1234567",
})
})
})

View File

@@ -0,0 +1,21 @@
const FALLBACK_VERSION = "0.0.1-dev"
const FALLBACK_SHA = "local"
function shortenSha(input: string): string {
const value = input.trim()
if (!value) {
return FALLBACK_SHA
}
return value.slice(0, 7)
}
export function getBuildInfo() {
const version = process.env.NEXT_PUBLIC_APP_VERSION?.trim() || FALLBACK_VERSION
const sha = shortenSha(process.env.NEXT_PUBLIC_GIT_SHA ?? "")
return {
version,
sha,
}
}

View File

@@ -6,7 +6,7 @@ module.exports = {
"always", "always",
["feat", "fix", "refactor", "perf", "test", "docs", "build", "ci", "chore", "revert"], ["feat", "fix", "refactor", "perf", "test", "docs", "build", "ci", "chore", "revert"],
], ],
"scope-empty": [2, "never"], "scope-empty": [0],
"subject-empty": [2, "never"], "subject-empty": [2, "never"],
}, },
} }

View File

@@ -0,0 +1,13 @@
services:
gitea-runner:
image: gitea/act_runner:latest
container_name: cms-gitea-runner
restart: unless-stopped
environment:
GITEA_INSTANCE_URL: "${GITEA_INSTANCE_URL}"
GITEA_RUNNER_REGISTRATION_TOKEN: "${GITEA_RUNNER_REGISTRATION_TOKEN}"
GITEA_RUNNER_NAME: "${GITEA_RUNNER_NAME:-cms-runner}"
GITEA_RUNNER_LABELS: "${GITEA_RUNNER_LABELS:-ubuntu-latest:docker://node:20-bookworm}"
volumes:
- ./runner-data:/data
- /var/run/docker.sock:/var/run/docker.sock

View File

@@ -1,10 +1,13 @@
import { expect, test } from "@playwright/test" import { expect, test } from "@playwright/test"
const BUILD_INFO_PATTERN = /Build v\S+ \+sha\.[a-z0-9]{5,7}/i
test("smoke", async ({ page }, testInfo) => { test("smoke", async ({ page }, testInfo) => {
await page.goto("/") await page.goto("/")
if (testInfo.project.name === "web-chromium") { if (testInfo.project.name === "web-chromium") {
await expect(page.getByRole("heading", { name: /your next\.js cms frontend/i })).toBeVisible() await expect(page.getByRole("heading", { name: /your next\.js cms frontend/i })).toBeVisible()
await expect(page.getByText(BUILD_INFO_PATTERN)).toBeVisible()
return return
} }
@@ -12,6 +15,7 @@ test("smoke", async ({ page }, testInfo) => {
if (await dashboardHeading.isVisible({ timeout: 2000 })) { if (await dashboardHeading.isVisible({ timeout: 2000 })) {
await expect(dashboardHeading).toBeVisible() await expect(dashboardHeading).toBeVisible()
await expect(page.getByText(BUILD_INFO_PATTERN)).toBeVisible()
return return
} }

View File

@@ -1,6 +1,5 @@
generator client { generator client {
provider = "prisma-client" provider = "prisma-client-js"
output = "./generated/client"
} }
datasource db { datasource db {

View File

@@ -1,6 +1,6 @@
import { PrismaPg } from "@prisma/adapter-pg" import { PrismaPg } from "@prisma/adapter-pg"
import { PrismaClient } from "@prisma/client"
import { Pool } from "pg" import { Pool } from "pg"
import { PrismaClient } from "../prisma/generated/client/client"
const connectionString = process.env.DATABASE_URL const connectionString = process.env.DATABASE_URL

View File

@@ -5,7 +5,7 @@ import {
updatePostInputSchema, updatePostInputSchema,
} from "@cms/content" } from "@cms/content"
import { type CrudAuditHook, type CrudMutationContext, createCrudService } from "@cms/crud" import { type CrudAuditHook, type CrudMutationContext, createCrudService } from "@cms/crud"
import type { Post } from "../prisma/generated/client/client" import type { Post } from "@prisma/client"
import { db } from "./client" import { db } from "./client"