Compare commits
10 Commits
todo/mvp0-
...
mvp0-compl
| Author | SHA1 | Date | |
|---|---|---|---|
|
37fabad1f8
|
|||
|
637dfd2651
|
|||
|
f9f2b4eb15
|
|||
|
ccac669454
|
|||
|
af52b8581f
|
|||
|
3de4d5732e
|
|||
|
14c3df623a
|
|||
|
a57464d818
|
|||
|
c174f840bc
|
|||
|
334a5e3526
|
@@ -10,5 +10,7 @@ CMS_SUPPORT_EMAIL="support@cms.local"
|
||||
CMS_SUPPORT_PASSWORD="change-me-support-password"
|
||||
CMS_SUPPORT_NAME="Technical Support"
|
||||
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.
|
||||
# CMS_DEV_ROLE="admin"
|
||||
|
||||
4
.env.gitea-runner.example
Normal file
4
.env.gitea-runner.example
Normal 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"
|
||||
45
.gitea/scripts/extract-release-notes.sh
Normal file
45
.gitea/scripts/extract-release-notes.sh
Normal file
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
tag="${1:-}"
|
||||
|
||||
if [ -z "$tag" ]; then
|
||||
echo "Missing release tag argument (expected vX.Y.Z)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f CHANGELOG.md ]; then
|
||||
echo "CHANGELOG.md not found."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version="${tag#v}"
|
||||
|
||||
awk -v version="$version" '
|
||||
BEGIN {
|
||||
in_section = 0
|
||||
started = 0
|
||||
}
|
||||
/^## / {
|
||||
if (in_section == 1) {
|
||||
exit
|
||||
}
|
||||
|
||||
if (index($0, version) > 0) {
|
||||
in_section = 1
|
||||
started = 1
|
||||
print $0
|
||||
next
|
||||
}
|
||||
}
|
||||
{
|
||||
if (in_section == 1) {
|
||||
print $0
|
||||
}
|
||||
}
|
||||
END {
|
||||
if (started == 0) {
|
||||
exit 2
|
||||
}
|
||||
}
|
||||
' CHANGELOG.md
|
||||
80
.gitea/scripts/publish-gitea-release.mjs
Normal file
80
.gitea/scripts/publish-gitea-release.mjs
Normal file
@@ -0,0 +1,80 @@
|
||||
import { readFileSync } from "node:fs"
|
||||
|
||||
const tag = process.env.RELEASE_TAG?.trim()
|
||||
const releaseName = process.env.RELEASE_NAME?.trim() || tag
|
||||
const bodyFile = process.env.RELEASE_BODY_FILE?.trim() || ".gitea-release-notes.md"
|
||||
const serverUrl = process.env.GITHUB_SERVER_URL?.trim()
|
||||
const repository = process.env.GITHUB_REPOSITORY?.trim()
|
||||
const token = process.env.GITEA_RELEASE_TOKEN?.trim()
|
||||
|
||||
if (!tag) {
|
||||
throw new Error("RELEASE_TAG is required")
|
||||
}
|
||||
|
||||
if (!serverUrl || !repository) {
|
||||
throw new Error("GITHUB_SERVER_URL and GITHUB_REPOSITORY are required")
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
throw new Error("GITEA_RELEASE_TOKEN is required")
|
||||
}
|
||||
|
||||
const body = readFileSync(bodyFile, "utf8")
|
||||
const baseApi = `${serverUrl.replace(/\/$/, "")}/api/v1/repos/${repository}`
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const response = await fetch(`${baseApi}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `token ${token}`,
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
})
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
const payload = {
|
||||
tag_name: tag,
|
||||
target_commitish: "main",
|
||||
name: releaseName,
|
||||
body,
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
}
|
||||
|
||||
const existingResponse = await request(`/releases/tags/${encodeURIComponent(tag)}`)
|
||||
|
||||
if (existingResponse.ok) {
|
||||
const existing = await existingResponse.json()
|
||||
const updateResponse = await request(`/releases/${existing.id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
target_commitish: existing.target_commitish ?? payload.target_commitish,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!updateResponse.ok) {
|
||||
const message = await updateResponse.text()
|
||||
throw new Error(`Failed to update release: ${updateResponse.status} ${message}`)
|
||||
}
|
||||
|
||||
console.log(`Updated release for tag ${tag}`)
|
||||
} else if (existingResponse.status === 404) {
|
||||
const createResponse = await request("/releases", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!createResponse.ok) {
|
||||
const message = await createResponse.text()
|
||||
throw new Error(`Failed to create release: ${createResponse.status} ${message}`)
|
||||
}
|
||||
|
||||
console.log(`Created release for tag ${tag}`)
|
||||
} else {
|
||||
const message = await existingResponse.text()
|
||||
throw new Error(`Failed to query existing release: ${existingResponse.status} ${message}`)
|
||||
}
|
||||
@@ -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"
|
||||
@@ -27,7 +27,7 @@ env:
|
||||
jobs:
|
||||
governance:
|
||||
name: Governance Checks
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: node22-bun
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
quality:
|
||||
name: Lint Typecheck Unit E2E
|
||||
needs: governance
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: node22-bun
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
@@ -84,12 +84,21 @@ jobs:
|
||||
- name: Install dependencies
|
||||
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
|
||||
run: bunx playwright install --with-deps chromium
|
||||
|
||||
- name: Lint and format checks
|
||||
run: bun run check
|
||||
|
||||
- name: Generate Prisma client
|
||||
run: bun run db:generate
|
||||
|
||||
- name: Typecheck
|
||||
run: bun run typecheck
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ on:
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy Compose Stack
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: node22-bun
|
||||
steps:
|
||||
- name: Resolve deployment target
|
||||
id: target
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: "Release tag in vX.Y.Z format"
|
||||
required: true
|
||||
required: false
|
||||
rollback_image_tag:
|
||||
description: "Optional rollback image tag"
|
||||
required: false
|
||||
@@ -21,7 +21,8 @@ env:
|
||||
jobs:
|
||||
release:
|
||||
name: Build Push Changelog
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' || github.event.inputs.rollback_image_tag == ''
|
||||
runs-on: node22-bun
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -38,6 +39,10 @@ jobs:
|
||||
id: tag
|
||||
run: |
|
||||
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
|
||||
if [ -z "${{ github.event.inputs.release_tag }}" ]; then
|
||||
echo "release_tag input is required when publishing a release manually."
|
||||
exit 1
|
||||
fi
|
||||
echo "value=${{ github.event.inputs.release_tag }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "value=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
|
||||
@@ -49,6 +54,13 @@ jobs:
|
||||
- name: Generate changelog
|
||||
run: bun run changelog:release
|
||||
|
||||
- name: Build release notes payload
|
||||
run: |
|
||||
if ! sh .gitea/scripts/extract-release-notes.sh "${{ steps.tag.outputs.value }}" > .gitea-release-notes.md; then
|
||||
echo "Could not isolate section for tag ${{ steps.tag.outputs.value }}. Falling back to full CHANGELOG.md."
|
||||
cp CHANGELOG.md .gitea-release-notes.md
|
||||
fi
|
||||
|
||||
- name: Login to image registry
|
||||
run: |
|
||||
echo "${{ secrets.CMS_IMAGE_REGISTRY_PASSWORD }}" | docker login "${{ env.REGISTRY }}" -u "${{ secrets.CMS_IMAGE_REGISTRY_USER }}" --password-stdin
|
||||
@@ -65,18 +77,27 @@ jobs:
|
||||
docker build -f apps/admin/Dockerfile -t "$image" .
|
||||
docker push "$image"
|
||||
|
||||
- name: Release notes placeholder
|
||||
run: |
|
||||
echo "Release tag: ${{ steps.tag.outputs.value }}"
|
||||
echo "TODO: publish CHANGELOG.md content to release notes in Gitea."
|
||||
- name: Publish release notes to Gitea
|
||||
env:
|
||||
RELEASE_TAG: ${{ steps.tag.outputs.value }}
|
||||
RELEASE_NAME: ${{ steps.tag.outputs.value }}
|
||||
RELEASE_BODY_FILE: ".gitea-release-notes.md"
|
||||
GITEA_RELEASE_TOKEN: ${{ secrets.GITEA_RELEASE_TOKEN }}
|
||||
run: bun .gitea/scripts/publish-gitea-release.mjs
|
||||
|
||||
rollback:
|
||||
name: Rollback (Manual)
|
||||
name: Rollback Production (Manual)
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.rollback_image_tag != ''
|
||||
runs-on: ubuntu-latest
|
||||
needs: release
|
||||
runs-on: node22-bun
|
||||
steps:
|
||||
- name: Rollback placeholder
|
||||
- name: Setup SSH
|
||||
run: |
|
||||
echo "Rollback to image tag: ${{ github.event.inputs.rollback_image_tag }}"
|
||||
echo "TODO: apply compose update with rollback image tags on production host."
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.CMS_DEPLOY_KEY }}" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh-keyscan -H "${{ secrets.CMS_PRODUCTION_HOST }}" >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Apply rollback image tag on production
|
||||
run: |
|
||||
ssh "${{ secrets.CMS_PRODUCTION_USER }}@${{ secrets.CMS_PRODUCTION_HOST }}" \
|
||||
"cd ${{ secrets.CMS_REMOTE_DEPLOY_PATH }} && CMS_IMAGE_TAG=${{ github.event.inputs.rollback_image_tag }} docker compose -f docker-compose.production.yml pull && CMS_IMAGE_TAG=${{ github.event.inputs.rollback_image_tag }} docker compose -f docker-compose.production.yml up -d"
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -24,6 +24,7 @@ test-results
|
||||
!.env.example
|
||||
!.env.staging.example
|
||||
!.env.production.example
|
||||
!.env.gitea-runner.example
|
||||
|
||||
# prisma
|
||||
packages/db/prisma/dev.db*
|
||||
|
||||
15
README.md
15
README.md
@@ -97,10 +97,11 @@ bunx playwright install
|
||||
|
||||
## 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`
|
||||
- Real quality gate workflow: `.gitea/workflows/ci.yml`
|
||||
- Quality gate workflow: `.gitea/workflows/ci.yml`
|
||||
- Deployment workflow: `.gitea/workflows/deploy.yml`
|
||||
- Release workflow: `.gitea/workflows/release.yml`
|
||||
- App images:
|
||||
- `apps/web/Dockerfile`
|
||||
- `apps/admin/Dockerfile`
|
||||
@@ -119,12 +120,20 @@ Environment examples:
|
||||
|
||||
- `.env.staging.example`
|
||||
- `.env.production.example`
|
||||
- `.env.gitea-runner.example`
|
||||
|
||||
Notes:
|
||||
|
||||
- `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.
|
||||
|
||||
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 file: `CHANGELOG.md`
|
||||
|
||||
20
TODO.md
20
TODO.md
@@ -81,16 +81,16 @@ This file is the single source of truth for roadmap and delivery progress.
|
||||
|
||||
### 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] Staging and production docker-compose templates
|
||||
- [x] [P1] Registry credentials and image push strategy
|
||||
- [x] [P1] Staging deployment automation against real host
|
||||
- [x] [P1] Production promotion and rollback procedure
|
||||
- [~] [P1] Staging deployment automation against real host
|
||||
- [~] [P1] Production promotion and rollback procedure
|
||||
|
||||
### 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] Enforce one todo item per branch naming convention
|
||||
- [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] [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
|
||||
- [x] [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
|
||||
|
||||
### Admin App (Primary Focus)
|
||||
@@ -209,6 +218,9 @@ This file is the single source of truth for roadmap and delivery progress.
|
||||
- [2026-02-10] i18n conventions are now documented as an engineering standard (`docs/product-engineering/i18n-conventions.md`).
|
||||
- [2026-02-10] Docs now include a domain glossary, public API glossary, and ADR baseline with initial accepted decision (`ADR 0001`).
|
||||
- [2026-02-10] Delivery and release governance now include branch/PR policy checks, deploy/release workflows, and explicit versioning policy (`VERSIONING.md`).
|
||||
- [2026-02-11] Release workflow now publishes changelog-derived notes to Gitea releases and supports executable production rollback via SSH + compose tag switch.
|
||||
- [2026-02-11] Branch protection verification checklist is now documented; final UI-level verification remains environment-specific.
|
||||
- [2026-02-11] Added a staging deployment execution checklist and deployment-record template to capture first real-host rollout evidence.
|
||||
|
||||
## How We Use This File
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ReactNode } from "react"
|
||||
|
||||
import { LogoutButton } from "@/app/logout-button"
|
||||
import { AdminLocaleSwitcher } from "@/components/admin-locale-switcher"
|
||||
import { getBuildInfo } from "@/lib/build-info"
|
||||
|
||||
type AdminShellProps = {
|
||||
role: Role
|
||||
@@ -57,6 +58,8 @@ export function AdminShell({
|
||||
actions,
|
||||
children,
|
||||
}: AdminShellProps) {
|
||||
const buildInfo = getBuildInfo()
|
||||
|
||||
return (
|
||||
<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">
|
||||
@@ -111,6 +114,10 @@ export function AdminShell({
|
||||
</header>
|
||||
|
||||
{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>
|
||||
)
|
||||
|
||||
29
apps/admin/src/lib/build-info.test.ts
Normal file
29
apps/admin/src/lib/build-info.test.ts
Normal 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",
|
||||
})
|
||||
})
|
||||
})
|
||||
21
apps/admin/src/lib/build-info.ts
Normal file
21
apps/admin/src/lib/build-info.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { getBuildInfo } from "@/lib/build-info"
|
||||
|
||||
export function PublicSiteFooter() {
|
||||
const t = useTranslations("Layout")
|
||||
const year = new Date().getFullYear()
|
||||
const buildInfo = getBuildInfo()
|
||||
|
||||
return (
|
||||
<footer className="border-t border-neutral-200 bg-neutral-50">
|
||||
@@ -15,6 +18,9 @@ export function PublicSiteFooter() {
|
||||
})}
|
||||
</p>
|
||||
<p>{t("footer.tagline")}</p>
|
||||
<p className="font-mono text-xs text-neutral-500">
|
||||
Build v{buildInfo.version} +sha.{buildInfo.sha}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
|
||||
29
apps/web/src/lib/build-info.test.ts
Normal file
29
apps/web/src/lib/build-info.test.ts
Normal 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",
|
||||
})
|
||||
})
|
||||
})
|
||||
21
apps/web/src/lib/build-info.ts
Normal file
21
apps/web/src/lib/build-info.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
16
bun.lock
16
bun.lock
@@ -17,7 +17,7 @@
|
||||
"conventional-changelog-cli": "5.0.0",
|
||||
"jsdom": "28.0.0",
|
||||
"msw": "2.12.9",
|
||||
"turbo": "2.8.3",
|
||||
"turbo": "^2.8.6",
|
||||
"typescript": "5.9.3",
|
||||
"vite-tsconfig-paths": "6.1.0",
|
||||
"vitepress": "1.6.4",
|
||||
@@ -1451,19 +1451,19 @@
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"turbo": ["turbo@2.8.3", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.3", "turbo-darwin-arm64": "2.8.3", "turbo-linux-64": "2.8.3", "turbo-linux-arm64": "2.8.3", "turbo-windows-64": "2.8.3", "turbo-windows-arm64": "2.8.3" }, "bin": { "turbo": "bin/turbo" } }, "sha512-8Osxz5Tu/Dw2kb31EAY+nhq/YZ3wzmQSmYa1nIArqxgCAldxv9TPlrAiaBUDVnKA4aiPn0OFBD1ACcpc5VFOAQ=="],
|
||||
"turbo": ["turbo@2.8.6", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.6", "turbo-darwin-arm64": "2.8.6", "turbo-linux-64": "2.8.6", "turbo-linux-arm64": "2.8.6", "turbo-windows-64": "2.8.6", "turbo-windows-arm64": "2.8.6" }, "bin": { "turbo": "bin/turbo" } }, "sha512-QMj1SQwUYehc+xJ9SxXn56UO43hfKN64/NFetVW1BwzysRqn+q0FSgrmk+IbJ+djfd8j8zXGKGeqsnUcXwQSUQ=="],
|
||||
|
||||
"turbo-darwin-64": ["turbo-darwin-64@2.8.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-4kXRLfcygLOeNcP6JquqRLmGB/ATjjfehiojL2dJkL7GFm3SPSXbq7oNj8UbD8XriYQ5hPaSuz59iF1ijPHkTw=="],
|
||||
"turbo-darwin-64": ["turbo-darwin-64@2.8.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-6QeZ/aLZizekiI6tKZN0IGP1a1WYZ9c/qDKPa0rSmj2X0O0Iw/ES4rKZV40S5n8SUJdiU01EFLygHJ2oWaYKXg=="],
|
||||
|
||||
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xF7uCeC0UY0Hrv/tqax0BMbFlVP1J/aRyeGQPZT4NjvIPj8gSPDgFhfkfz06DhUwDg5NgMo04uiSkAWE8WB/QQ=="],
|
||||
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RS4Z902vB93cQD3PJS/1IMmS0HefrB5ZXuw4ECOrxhOGz5jJVmYFJ6weDzedjoTDeYHHXGo1NoiCSHg69ngWKA=="],
|
||||
|
||||
"turbo-linux-64": ["turbo-linux-64@2.8.3", "", { "os": "linux", "cpu": "x64" }, "sha512-vxMDXwaOjweW/4etY7BxrXCSkvtwh0PbwVafyfT1Ww659SedUxd5rM3V2ZCmbwG8NiCfY7d6VtxyHx3Wh1GoZA=="],
|
||||
"turbo-linux-64": ["turbo-linux-64@2.8.6", "", { "os": "linux", "cpu": "x64" }, "sha512-hCWDnDepYbrSJdByuryKFoHAGFkvgBYXr6qdaGsYhX1Wgq8isqXCQBKOo99Y/9tXDwKGEeQ7xnkdFvSL7AQ4iQ=="],
|
||||
|
||||
"turbo-linux-arm64": ["turbo-linux-arm64@2.8.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-mQX7uYBZFkuPLLlKaNe9IjR1JIef4YvY8f21xFocvttXvdPebnq3PK1Zjzl9A1zun2BEuWNUwQIL8lgvN9Pm3Q=="],
|
||||
"turbo-linux-arm64": ["turbo-linux-arm64@2.8.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-oS15aCYEpynG/l69xs/ZnQ0dnz0pHhfHg70Zf5J+j5Cam0/RA0MpcryjneN/9G0PmP8a/6ZxnL5nZahX+wOBPA=="],
|
||||
|
||||
"turbo-windows-64": ["turbo-windows-64@2.8.3", "", { "os": "win32", "cpu": "x64" }, "sha512-YLGEfppGxZj3VWcNOVa08h6ISsVKiG85aCAWosOKNUjb6yErWEuydv6/qImRJUI+tDLvDvW7BxopAkujRnWCrw=="],
|
||||
"turbo-windows-64": ["turbo-windows-64@2.8.6", "", { "os": "win32", "cpu": "x64" }, "sha512-eqBxqJD7H/uk9V0QO10VgwY9J2BUXejsGuzChln72Yl+o8GZwsvhOekndRxccR90J8ZO+LKO24+3VzHFh4Cu/g=="],
|
||||
|
||||
"turbo-windows-arm64": ["turbo-windows-arm64@2.8.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-afTUGKBRmOJU1smQSBnFGcbq0iabAPwh1uXu2BVk7BREg30/1gMnJh9DFEQTah+UD3n3ru8V55J83RQNFfqoyw=="],
|
||||
"turbo-windows-arm64": ["turbo-windows-arm64@2.8.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-I3VEQyxIlNZ6XTg4fLKAkuhcwzIs/GD7Vs1yhelH2aUTjf08wprjBWknDqP7mjAHMpsosRrq4DtfSZEQm83Hxg=="],
|
||||
|
||||
"type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="],
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ module.exports = {
|
||||
"always",
|
||||
["feat", "fix", "refactor", "perf", "test", "docs", "build", "ci", "chore", "revert"],
|
||||
],
|
||||
"scope-empty": [2, "never"],
|
||||
"scope-empty": [0],
|
||||
"subject-empty": [2, "never"],
|
||||
},
|
||||
}
|
||||
|
||||
13
docker-compose.gitea-runner.yml
Normal file
13
docker-compose.gitea-runner.yml
Normal 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
|
||||
@@ -28,8 +28,14 @@ Policy:
|
||||
- Steps:
|
||||
1. validate tag vs root `package.json` version
|
||||
2. generate changelog
|
||||
3. docker login
|
||||
4. build and push `cms-web` and `cms-admin` images
|
||||
3. extract release notes from `CHANGELOG.md`
|
||||
4. docker login
|
||||
5. build and push `cms-web` and `cms-admin` images
|
||||
6. publish/update Gitea release notes through API
|
||||
|
||||
Additional required secret:
|
||||
|
||||
- `GITEA_RELEASE_TOKEN`
|
||||
|
||||
## Staging Deployment Automation
|
||||
|
||||
@@ -57,10 +63,10 @@ Promotion:
|
||||
|
||||
Rollback:
|
||||
|
||||
- release workflow supports rollback placeholder by image tag
|
||||
- deploy workflow supports `rollback_tag` input
|
||||
- release workflow supports manual production rollback by `rollback_image_tag`
|
||||
- deploy workflow supports `rollback_tag` input for environment-specific rollback
|
||||
- recovery action:
|
||||
- rerun deploy with previous known-good tag
|
||||
- rerun deploy/rollback with previous known-good tag
|
||||
|
||||
## Deployment Verification
|
||||
|
||||
|
||||
@@ -23,6 +23,33 @@ Minimum policy:
|
||||
- required status checks
|
||||
- at least one reviewer approval
|
||||
|
||||
## Branch Protection Verification Checklist
|
||||
|
||||
Use this checklist in Gitea repository settings after applying policy:
|
||||
|
||||
1. `main` protection exists and direct push is disabled.
|
||||
2. `staging` protection exists and direct push is disabled.
|
||||
3. Required checks include:
|
||||
- `Governance Checks`
|
||||
- `Lint Typecheck Unit E2E`
|
||||
4. Pull request approval is required.
|
||||
5. Branch must be up to date before merge (recommended in protected branches).
|
||||
|
||||
API automation example:
|
||||
|
||||
```bash
|
||||
sh .gitea/scripts/configure-branch-protection.sh \
|
||||
"$GITEA_URL" \
|
||||
"$GITEA_OWNER" \
|
||||
"$GITEA_REPO" \
|
||||
"$GITEA_ADMIN_TOKEN"
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- The script applies baseline protection for `main` and `staging`.
|
||||
- Final verification is still required in the Gitea UI to confirm repository-specific policies.
|
||||
|
||||
## PR Gates
|
||||
|
||||
Required checks are implemented in `.gitea/workflows/ci.yml`:
|
||||
|
||||
@@ -12,6 +12,7 @@ This section covers platform and implementation documentation for engineers and
|
||||
- [CRUD Examples](/product-engineering/crud-examples)
|
||||
- [Domain Glossary](/product-engineering/domain-glossary)
|
||||
- [Environment Runbook](/product-engineering/environment-runbook)
|
||||
- [Staging Deployment Checklist](/product-engineering/staging-deployment-checklist)
|
||||
- [Delivery Pipeline](/product-engineering/delivery-pipeline)
|
||||
- [Git Flow Governance](/product-engineering/git-flow-governance)
|
||||
- [Testing Strategy Baseline](/product-engineering/testing-strategy)
|
||||
|
||||
100
docs/product-engineering/staging-deployment-checklist.md
Normal file
100
docs/product-engineering/staging-deployment-checklist.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# Staging Deployment Checklist
|
||||
|
||||
## Purpose
|
||||
|
||||
Operational checklist for the first real staging deployment using `.gitea/workflows/deploy.yml`.
|
||||
|
||||
Use this once end-to-end, save the record, then mark MVP0 staging deployment as complete in `TODO.md`.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Docker host for staging is reachable via SSH.
|
||||
- Gitea repo secrets are configured:
|
||||
- `CMS_STAGING_HOST`
|
||||
- `CMS_STAGING_USER`
|
||||
- `CMS_DEPLOY_KEY`
|
||||
- `CMS_REMOTE_DEPLOY_PATH`
|
||||
- `CMS_IMAGE_REGISTRY`
|
||||
- `CMS_IMAGE_NAMESPACE`
|
||||
- `CMS_IMAGE_REGISTRY_USER`
|
||||
- `CMS_IMAGE_REGISTRY_PASSWORD`
|
||||
- Release image tag exists in registry (e.g. `v0.1.0`).
|
||||
- Remote deploy path contains:
|
||||
- `docker-compose.staging.yml`
|
||||
- staging env file(s) needed by compose
|
||||
|
||||
## Step-by-Step Execution
|
||||
|
||||
1. Verify release images exist:
|
||||
- `cms-web:<tag>`
|
||||
- `cms-admin:<tag>`
|
||||
2. In Gitea Actions, run `CMS Deploy` workflow.
|
||||
3. Inputs:
|
||||
- `environment=staging`
|
||||
- `image_tag=<tag>`
|
||||
- `rollback_tag=` (empty for normal deploy)
|
||||
4. Confirm workflow success.
|
||||
5. Validate staging endpoints:
|
||||
- web base route
|
||||
- admin login route
|
||||
6. Run smoke checks on staging:
|
||||
- auth login
|
||||
- i18n route/switch baseline
|
||||
- admin dashboard route access
|
||||
7. If failure:
|
||||
- rerun `CMS Deploy` with `rollback_tag=<previous-tag>`
|
||||
- capture root cause and remediation notes
|
||||
|
||||
## Evidence To Capture
|
||||
|
||||
- Workflow run URL
|
||||
- Deployed image tag
|
||||
- Timestamp (UTC)
|
||||
- Validation results (pass/fail)
|
||||
- Rollback performed or not
|
||||
|
||||
## Deployment Record Template
|
||||
|
||||
Copy the block below into a new file under `docs/product-engineering/staging-deployments/`.
|
||||
|
||||
```md
|
||||
# Staging Deployment Record - <YYYY-MM-DD>
|
||||
|
||||
- Date (UTC):
|
||||
- Operator:
|
||||
- Workflow run URL:
|
||||
- Target environment: staging
|
||||
- Image tag:
|
||||
- Previous tag:
|
||||
|
||||
## Preconditions
|
||||
|
||||
- [ ] Secrets configured in Gitea
|
||||
- [ ] Registry images available
|
||||
- [ ] Remote compose path verified
|
||||
|
||||
## Execution
|
||||
|
||||
1. Triggered `CMS Deploy` with `environment=staging`, `image_tag=<tag>`
|
||||
2. Workflow status: <!-- pass/fail -->
|
||||
|
||||
## Validation
|
||||
|
||||
- [ ] Web route check
|
||||
- [ ] Admin login route check
|
||||
- [ ] Auth smoke flow
|
||||
- [ ] i18n smoke flow
|
||||
- [ ] Admin dashboard access
|
||||
|
||||
## Rollback
|
||||
|
||||
- Performed: <!-- yes/no -->
|
||||
- Rollback tag:
|
||||
- Rollback workflow run URL:
|
||||
|
||||
## Outcome
|
||||
|
||||
- Result: <!-- success/failed -->
|
||||
- Notes:
|
||||
- Follow-up actions:
|
||||
```
|
||||
@@ -1,10 +1,13 @@
|
||||
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) => {
|
||||
await page.goto("/")
|
||||
|
||||
if (testInfo.project.name === "web-chromium") {
|
||||
await expect(page.getByRole("heading", { name: /your next\.js cms frontend/i })).toBeVisible()
|
||||
await expect(page.getByText(BUILD_INFO_PATTERN)).toBeVisible()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -12,6 +15,7 @@ test("smoke", async ({ page }, testInfo) => {
|
||||
|
||||
if (await dashboardHeading.isVisible({ timeout: 2000 })) {
|
||||
await expect(dashboardHeading).toBeVisible()
|
||||
await expect(page.getByText(BUILD_INFO_PATTERN)).toBeVisible()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
10
package.json
10
package.json
@@ -44,22 +44,22 @@
|
||||
"docker:production:down": "docker compose -f docker-compose.production.yml down"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.58.2",
|
||||
"@biomejs/biome": "2.3.14",
|
||||
"@commitlint/cli": "20.4.1",
|
||||
"@commitlint/config-conventional": "20.4.1",
|
||||
"@playwright/test": "1.58.2",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@vitejs/plugin-react": "5.1.3",
|
||||
"@vitest/coverage-istanbul": "4.0.18",
|
||||
"@biomejs/biome": "2.3.14",
|
||||
"conventional-changelog-cli": "5.0.0",
|
||||
"jsdom": "28.0.0",
|
||||
"msw": "2.12.9",
|
||||
"conventional-changelog-cli": "5.0.0",
|
||||
"turbo": "2.8.3",
|
||||
"turbo": "^2.8.6",
|
||||
"typescript": "5.9.3",
|
||||
"vitepress": "1.6.4",
|
||||
"vite-tsconfig-paths": "6.1.0",
|
||||
"vitepress": "1.6.4",
|
||||
"vitest": "4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "./generated/client"
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PrismaPg } from "@prisma/adapter-pg"
|
||||
import { PrismaClient } from "@prisma/client"
|
||||
import { Pool } from "pg"
|
||||
import { PrismaClient } from "../prisma/generated/client/client"
|
||||
|
||||
const connectionString = process.env.DATABASE_URL
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
updatePostInputSchema,
|
||||
} from "@cms/content"
|
||||
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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user