From ccac669454b46a3918b34df1d3c5f0e5f00aa1d9 Mon Sep 17 00:00:00 2001 From: Citali Date: Wed, 11 Feb 2026 19:09:22 +0100 Subject: [PATCH] feat(release): publish gitea release notes and enable production rollback --- .gitea/scripts/extract-release-notes.sh | 45 +++++++++++ .gitea/scripts/publish-gitea-release.mjs | 80 +++++++++++++++++++ .gitea/workflows/release.yml | 43 +++++++--- TODO.md | 3 +- docs/product-engineering/delivery-pipeline.md | 16 ++-- 5 files changed, 170 insertions(+), 17 deletions(-) create mode 100644 .gitea/scripts/extract-release-notes.sh create mode 100644 .gitea/scripts/publish-gitea-release.mjs diff --git a/.gitea/scripts/extract-release-notes.sh b/.gitea/scripts/extract-release-notes.sh new file mode 100644 index 0000000..9abc250 --- /dev/null +++ b/.gitea/scripts/extract-release-notes.sh @@ -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 diff --git a/.gitea/scripts/publish-gitea-release.mjs b/.gitea/scripts/publish-gitea-release.mjs new file mode 100644 index 0000000..79356de --- /dev/null +++ b/.gitea/scripts/publish-gitea-release.mjs @@ -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}`) +} diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index e9c98fb..c29f25b 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -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" diff --git a/TODO.md b/TODO.md index 10f3bd8..3944a4f 100644 --- a/TODO.md +++ b/TODO.md @@ -109,7 +109,7 @@ This file is the single source of truth for roadmap and delivery progress. - [ ] [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,7 @@ 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. ## How We Use This File diff --git a/docs/product-engineering/delivery-pipeline.md b/docs/product-engineering/delivery-pipeline.md index ed60a8d..c4df75d 100644 --- a/docs/product-engineering/delivery-pipeline.md +++ b/docs/product-engineering/delivery-pipeline.md @@ -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