Compare commits
2 Commits
todo/mvp0-
...
todo/mvp0-
| Author | SHA1 | Date | |
|---|---|---|---|
|
f9f2b4eb15
|
|||
|
ccac669454
|
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}`)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
6
TODO.md
6
TODO.md
@@ -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] 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] 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,8 @@ 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.
|
||||
|
||||
## How We Use This 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
|
||||
|
||||
|
||||
@@ -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`:
|
||||
|
||||
Reference in New Issue
Block a user