Compare commits

...

4 Commits

Author SHA1 Message Date
37fabad1f8 chore(repo): update turbo dependency
Some checks failed
CMS CI / Governance Checks (push) Successful in 1m5s
CMS CI / Lint Typecheck Unit E2E (push) Failing after 3m40s
2026-02-11 22:08:01 +01:00
637dfd2651 docs(ops): add staging deployment checklist and evidence template 2026-02-11 19:11:45 +01:00
f9f2b4eb15 docs(gitflow): add branch protection verification checklist 2026-02-11 19:09:57 +01:00
ccac669454 feat(release): publish gitea release notes and enable production rollback 2026-02-11 19:09:22 +01:00
10 changed files with 315 additions and 32 deletions

View 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

View 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}`)
}

View File

@@ -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,6 +21,7 @@ env:
jobs:
release:
name: Build Push Changelog
if: github.event_name == 'push' || github.event.inputs.rollback_image_tag == ''
runs-on: node22-bun
steps:
- name: Checkout
@@ -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"

View File

@@ -107,9 +107,9 @@ This file is the single source of truth for roadmap and delivery progress.
### 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
- [~] [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
@@ -218,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

View File

@@ -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=="],

View File

@@ -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

View File

@@ -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`:

View File

@@ -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)

View 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:
```

View File

@@ -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"
}
}