ci: add semantic-release workflow and docs

This commit is contained in:
neon
2026-04-26 19:03:03 -06:00
parent c85379e4ff
commit 8ce315a7d0
9 changed files with 2862 additions and 80 deletions
+24 -37
View File
@@ -1,21 +1,19 @@
name: Container Image name: Container Image
run-name: ${{ github.event_name == 'workflow_run' && format('Image - {0} ({1})', github.event.workflow_run.head_commit.message, github.event.workflow_run.head_branch) || format('Image - {0}', github.ref_name) }} run-name: >-
${{ github.event_name == 'workflow_run' && format('Image - {0} ({1})', github.event.workflow_run.head_commit.message, github.event.workflow_run.head_branch) || github.event_name == 'pull_request' && format('Image check - PR #{0}', github.event.pull_request.number) || format('Image - {0}', github.ref_name) }}
on: on:
pull_request:
branches: [main]
# Break-glass: bypasses CI/E2E gate. Use only when CI is broken and a hotfix image is urgently needed. # Break-glass: bypasses CI/E2E gate. Use only when CI is broken and a hotfix image is urgently needed.
workflow_dispatch: workflow_dispatch:
workflow_run: workflow_run:
workflows: ["CI"] workflows: ["CI"]
types: [completed] types: [completed]
branches: [main] branches: [main]
push:
tags:
- "v*"
concurrency: concurrency:
# Use the commit SHA across all trigger types so a workflow_run completion and a group: ${{ github.workflow }}-${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.sha }}
# simultaneous tag push for the same commit share one concurrency slot and cancel correctly.
group: ${{ github.workflow }}-${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }}
cancel-in-progress: true cancel-in-progress: true
permissions: permissions:
@@ -28,10 +26,11 @@ env:
jobs: jobs:
image: image:
name: Build and Push Image name: ${{ github.event_name == 'pull_request' && 'Build and Scan Image' || 'Build and Push Image' }}
if: > if: >
github.event_name != 'workflow_run' || github.event_name == 'pull_request' ||
(github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push') github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push')
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 45 timeout-minutes: 45
permissions: permissions:
@@ -48,7 +47,6 @@ jobs:
GH_SHA: ${{ github.sha }} GH_SHA: ${{ github.sha }}
WORKFLOW_RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} WORKFLOW_RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: | run: |
# SHA resolution mirrors the concurrency group logic at the top of this workflow.
if [[ "$EVENT_NAME" == "workflow_dispatch" && "$GH_REF" != "refs/heads/main" ]]; then if [[ "$EVENT_NAME" == "workflow_dispatch" && "$GH_REF" != "refs/heads/main" ]]; then
echo "workflow_dispatch is only supported from main." echo "workflow_dispatch is only supported from main."
exit 1 exit 1
@@ -63,41 +61,28 @@ jobs:
SHORT_SHA="${SHA:0:12}" SHORT_SHA="${SHA:0:12}"
IMAGE="$REGISTRY/$IMAGE_NAME" IMAGE="$REGISTRY/$IMAGE_NAME"
PRIMARY_TAG="$GH_REF_NAME"
echo "checkout_ref=${SHA}" >> "$GITHUB_OUTPUT" echo "checkout_ref=${SHA}" >> "$GITHUB_OUTPUT"
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT" echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
echo "scan_tag=${IMAGE}:sha-${SHORT_SHA}" >> "$GITHUB_OUTPUT" echo "scan_tag=${IMAGE}:sha-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
echo "tags<<EOF" >> "$GITHUB_OUTPUT"
echo "${IMAGE}:${PRIMARY_TAG}" >> "$GITHUB_OUTPUT" if [[ "$EVENT_NAME" == "pull_request" ]]; then
echo "${IMAGE}:sha-${SHORT_SHA}" >> "$GITHUB_OUTPUT" echo "should_push=false" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT" echo "tags<<EOF" >> "$GITHUB_OUTPUT"
echo "${IMAGE}:sha-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
else
echo "should_push=true" >> "$GITHUB_OUTPUT"
echo "tags<<EOF" >> "$GITHUB_OUTPUT"
echo "${IMAGE}:${GH_REF_NAME}" >> "$GITHUB_OUTPUT"
echo "${IMAGE}:sha-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
fi
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with: with:
ref: ${{ steps.vars.outputs.checkout_ref }} ref: ${{ steps.vars.outputs.checkout_ref }}
- name: Verify CI succeeded for tag commit
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
shell: bash
env:
GH_TOKEN: ${{ github.token }}
COMMIT_SHA: ${{ github.sha }}
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
echo "Checking CI status for commit ${COMMIT_SHA}..."
success_count=$(gh api --paginate \
"repos/${GH_REPO}/actions/workflows/ci.yml/runs?head_sha=${COMMIT_SHA}&status=completed&event=push&branch=main" \
--jq '[.workflow_runs[] | select(.conclusion == "success")] | length' \
| awk '{sum += $1} END {print sum}')
if [[ "${success_count:-0}" -lt 1 ]]; then
echo "No successful CI run found for tag commit ${COMMIT_SHA}. Refusing to publish image."
exit 1
fi
echo "CI verification passed: ${success_count} successful run(s) found."
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
@@ -124,6 +109,7 @@ jobs:
exit-code: "1" exit-code: "1"
- name: Log in to GHCR - name: Log in to GHCR
if: steps.vars.outputs.should_push == 'true'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
@@ -131,6 +117,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Push image - name: Push image
if: steps.vars.outputs.should_push == 'true'
shell: bash shell: bash
run: | run: |
while IFS= read -r tag; do while IFS= read -r tag; do
+233
View File
@@ -0,0 +1,233 @@
name: Release
run-name: >-
${{ github.event_name == 'workflow_dispatch' && format('Release - {0}', github.ref_name) || format('Release checks - PR #{0}', github.event.pull_request.number) }}
on:
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && 'release' || format('pr-{0}', github.event.pull_request.number) }}
cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }}
permissions:
actions: read
contents: write
issues: write
pull-requests: write
packages: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24"
PNPM_VERSION: "10"
jobs:
commitlint:
name: Lint PR Title
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
pull-requests: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up Node.js and pnpm
uses: ./.github/actions/setup-node-pnpm
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
- name: Lint PR title
shell: bash
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: printf '%s\n' "$PR_TITLE" | pnpm exec commitlint --config commitlint.config.cjs
release-dry-run:
name: Release Dry Run
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
issues: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Set up Node.js and pnpm
uses: ./.github/actions/setup-node-pnpm
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
- name: Semantic release dry run
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: pnpm exec semantic-release --dry-run --no-ci
release:
name: Publish Release
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
issues: write
pull-requests: write
outputs:
published: ${{ steps.semantic.outputs.new_release_published }}
version: ${{ steps.semantic.outputs.new_release_version }}
tag: ${{ steps.semantic.outputs.new_release_git_tag }}
steps:
- name: Guard against non-main branch
shell: bash
env:
GH_REF: ${{ github.ref }}
run: |
if [[ "$GH_REF" != "refs/heads/main" ]]; then
echo "::error::Release workflow must be dispatched from main. Current ref: $GH_REF"
exit 1
fi
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
- name: Verify CI success for current SHA
shell: bash
env:
GH_TOKEN: ${{ github.token }}
COMMIT_SHA: ${{ github.sha }}
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
echo "Checking CI status for commit ${COMMIT_SHA}..."
success_count=$(gh api --paginate \
"repos/${GH_REPO}/actions/workflows/ci.yml/runs?head_sha=${COMMIT_SHA}&status=completed&event=push&branch=main" \
--jq '[.workflow_runs[] | select(.conclusion == "success")] | length' \
| awk '{sum += $1} END {print sum}')
if [[ "${success_count:-0}" -lt 1 ]]; then
echo "::error::No successful CI run found for commit ${COMMIT_SHA}. Refusing to release."
exit 1
fi
echo "CI verification passed: ${success_count} successful run(s) found."
- name: Set up Node.js and pnpm
uses: ./.github/actions/setup-node-pnpm
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
- name: Run semantic-release
id: semantic
uses: cycjimmy/semantic-release-action@16ca923e6ccbb50770c415a0ccd43709a8c5f7a4 # v4.2.2
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Release summary
if: steps.semantic.outputs.new_release_published == 'true'
shell: bash
env:
VERSION: ${{ steps.semantic.outputs.new_release_version }}
TAG: ${{ steps.semantic.outputs.new_release_git_tag }}
run: |
echo "## Release Published" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "- **Version:** ${VERSION}" >> "$GITHUB_STEP_SUMMARY"
echo "- **Tag:** ${TAG}" >> "$GITHUB_STEP_SUMMARY"
docker-publish:
name: Publish Docker Image
needs: [release]
if: needs.release.outputs.published == 'true'
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
packages: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ needs.release.outputs.tag }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Resolve image tags
id: tags
shell: bash
env:
VERSION: ${{ needs.release.outputs.version }}
REGISTRY: ${{ env.REGISTRY }}
IMAGE: ${{ env.IMAGE_NAME }}
run: |
FULL_IMAGE="${REGISTRY}/${IMAGE}"
echo "scan_tag=${FULL_IMAGE}:${VERSION}" >> "$GITHUB_OUTPUT"
echo "tags<<EOF" >> "$GITHUB_OUTPUT"
echo "${FULL_IMAGE}:${VERSION}" >> "$GITHUB_OUTPUT"
echo "${FULL_IMAGE}:latest" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
- name: Build image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: ./Dockerfile
push: false
load: true
tags: ${{ steps.tags.outputs.tags }}
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Scan image with Trivy
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: ${{ steps.tags.outputs.scan_tag }}
format: table
vuln-type: os,library
severity: CRITICAL,HIGH
ignore-unfixed: true
exit-code: "1"
- name: Log in to GHCR
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Push image
shell: bash
run: |
while IFS= read -r tag; do
[ -n "$tag" ] || continue
docker push "$tag"
done <<'EOF'
${{ steps.tags.outputs.tags }}
EOF
- name: Docker publish summary
shell: bash
env:
VERSION: ${{ needs.release.outputs.version }}
run: |
echo "## Docker Image Published" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "- **Tags:** \`${VERSION}\`, \`latest\`" >> "$GITHUB_STEP_SUMMARY"
+38
View File
@@ -0,0 +1,38 @@
{
"branches": ["main"],
"tagFormat": "v${version}",
"plugins": [
[
"@semantic-release/commit-analyzer",
{
"preset": "conventionalcommits",
"releaseRules": [
{ "type": "feat", "release": "minor" },
{ "type": "fix", "release": "patch" },
{ "type": "perf", "release": "patch" },
{ "type": "security", "release": "patch" },
{ "type": "db", "release": "patch" },
{ "type": "style", "release": "patch" },
{ "type": "revert", "release": false }
]
}
],
[
"@semantic-release/release-notes-generator",
{
"preset": "conventionalcommits",
"presetConfig": {
"types": [
{ "type": "feat", "section": "Features" },
{ "type": "fix", "section": "Bug Fixes" },
{ "type": "security", "section": "Security" },
{ "type": "perf", "section": "Performance" },
{ "type": "db", "section": "Database" },
{ "type": "style", "section": "Visual Changes" }
]
}
}
],
"@semantic-release/github"
]
}
+13
View File
@@ -368,6 +368,19 @@ The same pipeline plus:
- **Container image build** (`.github/workflows/container-image.yml`) - Builds a Docker image and pushes to GitHub Container Registry. - **Container image build** (`.github/workflows/container-image.yml`) - Builds a Docker image and pushes to GitHub Container Registry.
### Releases
Releases are created manually via `workflow_dispatch` on the `release.yml` workflow. The release workflow:
1. Verifies CI has passed for the current `main` commit.
2. Runs semantic-release to determine the next version from commit history.
3. Creates a Git tag and GitHub Release with auto-generated release notes.
4. Builds, scans, and publishes a Docker image with version and `latest` tags.
PR titles are validated by commitlint to ensure they follow the [commit guidelines](COMMIT_GUIDELINES.md). Only releasable types (`feat`, `fix`, `perf`, `security`, `db`, `style`) trigger version bumps.
For the full release runbook, see [`docs/release-process.md`](docs/release-process.md).
### E2E in CI ### E2E in CI
E2E suites are dispatched automatically based on which files changed. On nightly builds and manual dispatches, all suites run. Each suite runs in its own job with a dedicated PostgreSQL service container. E2E suites are dispatched automatically based on which files changed. On nightly builds and manual dispatches, all suites run. Each suite runs in its own job with a dedicated PostgreSQL service container.
+35 -38
View File
@@ -1,6 +1,7 @@
# BookOrbit # BookOrbit
[![CI](https://github.com/neonsolstice/bookorbit/actions/workflows/ci.yml/badge.svg)](https://github.com/neonsolstice/bookorbit/actions/workflows/ci.yml) [![CI](https://github.com/neonsolstice/bookorbit/actions/workflows/ci.yml/badge.svg)](https://github.com/neonsolstice/bookorbit/actions/workflows/ci.yml)
[![Release](https://github.com/neonsolstice/bookorbit/actions/workflows/release.yml/badge.svg)](https://github.com/neonsolstice/bookorbit/actions/workflows/release.yml)
A self-hostable book and library management app with Kobo device support. Organize your epub, pdf, and cbz collections, track reading progress, and sync with your Kobo. A self-hostable book and library management app with Kobo device support. Organize your epub, pdf, and cbz collections, track reading progress, and sync with your Kobo.
@@ -92,14 +93,13 @@ All commands run from the **repo root** unless noted otherwise.
### Everyday workflow ### Everyday workflow
| Command | Description | | Command | Description |
| -------------------- | -------------------------------------------------- | | -------------------- | --------------------------------------------------- |
| `pnpm setup` | One-time bootstrap | | `pnpm setup` | One-time bootstrap |
| `pnpm dev` | Daily development | | `pnpm dev` | Daily development |
| `pnpm verify` | Default local checks before push | | `pnpm verify` | Default local checks before push |
| `pnpm quick` | Faster local checks while coding | | `pnpm verify:fast` | Faster local checks while coding (lint + typecheck) |
| `pnpm verify:strict` | Aspirational strict gate (format + full typecheck) | | `pnpm verify:strict` | Aspirational strict gate (format + full typecheck) |
| `pnpm guide` | Print command reference |
### Development ### Development
@@ -111,14 +111,14 @@ All commands run from the **repo root** unless noted otherwise.
### Testing ### Testing
| Command | Description | | Command | Description |
| ------------------------------- | ---------------------------- | | ------------------------------------- | ---------------------------- |
| `pnpm test` | Server + client unit tests | | `pnpm test` | Server + client unit tests |
| `pnpm test:server` | Server unit tests | | `pnpm test:server` | Server unit tests |
| `pnpm test:client` | Client unit tests | | `pnpm test:client` | Client unit tests |
| `pnpm test:e2e:smoke` | Server smoke e2e | | `pnpm run e2e:run -- guard-mechanics` | Server smoke e2e |
| `pnpm coverage` | Coverage for server + client | | `pnpm coverage` | Coverage for server + client |
| `pnpm --filter server test:e2e` | Server e2e only | | `pnpm --filter server test:e2e` | Server e2e only |
### Database ### Database
@@ -133,25 +133,21 @@ All commands run from the **repo root** unless noted otherwise.
### Production operations ### Production operations
| Command | Description | | Command | Description |
| ------------------------------------------ | -------------------------------------------- | | ---------------- | ------------------------------------- |
| `pnpm prod:up` | Start/update production compose stack | | `pnpm prod:up` | Start/update production compose stack |
| `pnpm prod:down` | Stop production compose stack | | `pnpm prod:down` | Stop production compose stack |
| `pnpm db:backup:prod` | Create Postgres backup (`local/backups/`) |
| `pnpm db:restore:prod -- <file.dump>` | Restore backup into production database |
| `pnpm db:restore:test:prod -- <file.dump>` | Restore into temp DB to validate backup file |
### Linting & formatting ### Linting & formatting
| Command | Description | | Command | Description |
| --------------------------------------- | ------------------------------------------ | | --------------------- | ---------------------------------- |
| `pnpm format:check` | Check formatting | | `pnpm format:check` | Check formatting |
| `pnpm format` | Auto-format source files | | `pnpm format` | Auto-format source files |
| `pnpm lint:check` | Non-mutating lint checks | | `pnpm lint:check` | Non-mutating lint checks |
| `pnpm lint:fix` | Auto-fix lint issues | | `pnpm lint:fix` | Auto-fix lint issues |
| `pnpm typecheck` | Server typecheck + no-new client TS errors | | `pnpm typecheck` | Typecheck server + client |
| `pnpm typecheck:full` | Typecheck server + client (strict) | | `pnpm typecheck:full` | Typecheck server + client (strict) |
| `pnpm typecheck:client:baseline:update` | Refresh accepted client TS baseline |
--- ---
@@ -375,9 +371,10 @@ Make sure you're accessing the client at `http://localhost:5173` (not port 3000)
## Further Reading ## Further Reading
| Doc | What it covers | | Doc | What it covers |
| ------------------------------------------ | ------------------------------------------------------------ | | ---------------------------------------------------- | ------------------------------------------------------------ |
| [`docs/production.md`](docs/production.md) | Production compose deployment, backups, restore testing | | [`docs/production.md`](docs/production.md) | Production compose deployment, backups, restore testing |
| [`server/README.md`](server/README.md) | Backend module map, DB commands, NestJS conventions | | [`docs/release-process.md`](docs/release-process.md) | Release workflow, PR title format, Docker publish, rollback |
| [`client/README.md`](client/README.md) | Frontend project layout, IDE setup, Vue/Tailwind conventions | | [`server/README.md`](server/README.md) | Backend module map, DB commands, NestJS conventions |
| [`packages/types/`](packages/types/) | Shared type definitions between server and client | | [`client/README.md`](client/README.md) | Frontend project layout, IDE setup, Vue/Tailwind conventions |
| [`packages/types/`](packages/types/) | Shared type definitions between server and client |
+38
View File
@@ -0,0 +1,38 @@
module.exports = {
extends: ["@commitlint/config-conventional"],
rules: {
"type-enum": [2, "always", ["feat", "fix", "db", "perf", "refactor", "style", "docs", "test", "build", "ci", "chore", "security", "revert"]],
"scope-enum": [
2,
"always",
[
"auth",
"library",
"books",
"authors",
"series",
"cover",
"metadata",
"kobo",
"opds",
"reader",
"collections",
"annotations",
"users",
"stats",
"notifications",
"settings",
"scanner",
"email",
"audit",
"smart-scope",
"types",
"docker",
"deps",
"server",
"client",
],
],
"scope-empty": [0],
},
};
+167
View File
@@ -0,0 +1,167 @@
# Release Process
This document covers how releases are created, what triggers Docker image publishing, and how to roll back if needed.
---
## Overview
BookOrbit uses [semantic-release](https://github.com/semantic-release/semantic-release) for automated versioning and GitHub Release creation. Releases are triggered manually via `workflow_dispatch` and produce:
- A Git tag (`vX.Y.Z`)
- A GitHub Release with auto-generated release notes
- A Docker image pushed to GHCR (`X.Y.Z` + `latest`)
---
## PR Requirements
### Squash merge required
All PRs to `main` must use **squash merge**. The squash commit message becomes the commit that semantic-release analyzes for version bumps.
### PR title format
PR titles must follow the [commit guidelines](../COMMIT_GUIDELINES.md):
```
<type>(<scope>): <summary>
```
The PR title is validated by commitlint in the release workflow. Invalid titles will fail the `Lint PR Title` check.
### Types that trigger releases
| Type | Release | Notes |
| ---------- | ------- | ------------------------ |
| `feat` | minor | New features |
| `fix` | patch | Bug fixes |
| `perf` | patch | Performance improvements |
| `security` | patch | Security patches |
| `db` | patch | Database/schema changes |
| `style` | patch | Visual/CSS changes |
| `revert` | none | No release |
| `refactor` | none | No release |
| `docs` | none | No release |
| `test` | none | No release |
| `build` | none | No release |
| `ci` | none | No release |
| `chore` | none | No release |
Breaking changes (`BREAKING CHANGE:` in footer) always trigger a **major** release regardless of type.
---
## Manual Release Runbook
Releases are triggered manually from the GitHub Actions UI.
### Prerequisites
1. All changes are merged to `main`.
2. CI has passed for the latest commit on `main`.
### Steps
1. Go to **Actions** > **Release** workflow.
2. Click **Run workflow**.
3. Select `main` branch.
4. Click **Run workflow**.
The workflow will:
1. Verify the dispatch is from `main`.
2. Verify CI has passed for the current commit.
3. Run semantic-release to determine the next version.
4. Create a Git tag and GitHub Release with release notes.
5. Build, scan, and push the Docker image (if a new version was published).
### No release produced
If all commits since the last release are non-releasable types (`chore`, `docs`, `test`, `build`, `ci`, `refactor`), semantic-release will skip the release. The Docker publish job will also be skipped.
---
## Docker Image Publishing
### Release images
The `release.yml` workflow publishes Docker images after a successful release:
- `ghcr.io/neonsolstice/bookorbit:<X.Y.Z>` - version-specific tag
- `ghcr.io/neonsolstice/bookorbit:latest` - latest stable release
Images are scanned with Trivy before push. If critical or high vulnerabilities are found, the push is blocked.
### Continuous validation images
The `container-image.yml` workflow still runs on CI success (push to `main`) and publishes validation images with branch and SHA tags. These are not release images.
---
## Rollback
### Rolling back to a previous release
To deploy a previous version:
```bash
# In your production docker-compose.yml or .env, set:
APP_IMAGE=ghcr.io/neonsolstice/bookorbit:X.Y.Z
```
Replace `X.Y.Z` with the version you want to roll back to, then redeploy:
```bash
pnpm prod:down
pnpm prod:up
```
### Deleting a bad release tag
If a release was created in error and needs to be removed:
```bash
# Delete tag locally and remotely
git tag -d vX.Y.Z
git push origin :refs/tags/vX.Y.Z
```
Then delete the corresponding GitHub Release from the Releases page.
---
## Release Workflow Architecture
The `release.yml` workflow has four jobs:
```
┌──────────────┐ ┌──────────────────┐
│ commitlint │ │ release-dry-run │ (PR only)
└──────────────┘ └──────────────────┘
┌──────────────┐ ┌──────────────────┐
│ release │────>│ docker-publish │ (workflow_dispatch only)
└──────────────┘ └──────────────────┘
```
- **commitlint**: Validates PR title format (PR only).
- **release-dry-run**: Runs semantic-release in dry-run mode to validate config (PR only).
- **release**: Creates the actual release (manual dispatch only). Outputs `published`, `version`, and `tag`.
- **docker-publish**: Builds and pushes Docker image (only when a release was published).
### Concurrency
- PR checks cancel older runs for the same PR.
- Manual release runs are serialized and never canceled mid-flight.
- PR checks and manual releases do not block each other.
---
## Configuration Files
| File | Purpose |
| ------------------------------- | ------------------------------------------------ |
| `.releaserc.json` | semantic-release config (plugins, release rules) |
| `commitlint.config.cjs` | commitlint config (type/scope validation) |
| `.github/workflows/release.yml` | Release and Docker publish workflow |
+12 -5
View File
@@ -4,10 +4,10 @@
"packageManager": "pnpm@10.32.1", "packageManager": "pnpm@10.32.1",
"scripts": { "scripts": {
"setup": "bash ./scripts/bootstrap/local.sh", "setup": "bash ./scripts/bootstrap/local.sh",
"dev": "concurrently --names \"types,server,client\" --prefix-colors \"cyan,blue,green\" \"pnpm --filter @bookorbit/types dev\" \"npm run dev:server\" \"npm run dev:client\"", "dev": "concurrently --names \"types,server,client\" --prefix-colors \"cyan,blue,green\" \"pnpm --filter @bookorbit/types dev\" \"pnpm run dev:server\" \"pnpm run dev:client\"",
"dev:client": "cd client && npm run dev -- --host", "dev:client": "cd client && pnpm dev -- --host",
"dev:server": "cd server && npm run start:dev", "dev:server": "cd server && pnpm start:dev",
"verify:fast": "pnpm run lint:check && pnpm run typecheck && pnpm run test", "verify:fast": "pnpm run lint:check && pnpm run typecheck",
"verify": "pnpm run lint:check && pnpm run typecheck && pnpm run test", "verify": "pnpm run lint:check && pnpm run typecheck && pnpm run test",
"verify:strict": "pnpm run format:check && pnpm run lint:check && pnpm run typecheck:full && pnpm run test", "verify:strict": "pnpm run format:check && pnpm run lint:check && pnpm run typecheck:full && pnpm run test",
"build:client": "pnpm --filter client build", "build:client": "pnpm --filter client build",
@@ -44,10 +44,17 @@
"pnpm": ">=9" "pnpm": ">=9"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1",
"@semantic-release/commit-analyzer": "^13.0.1",
"@semantic-release/github": "^11.0.1",
"@semantic-release/release-notes-generator": "^14.0.3",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"conventional-changelog-conventionalcommits": "^8.0.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^16.4.0", "lint-staged": "^16.4.0",
"prettier": "^3.8.3" "prettier": "^3.8.3",
"semantic-release": "^24.2.5"
}, },
"lint-staged": { "lint-staged": {
"*.{md,json,yml,yaml}": [ "*.{md,json,yml,yaml}": [
+2302
View File
File diff suppressed because it is too large Load Diff