mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
ci: add semantic-release workflow and docs
This commit is contained in:
@@ -1,21 +1,19 @@
|
||||
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:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
# Break-glass: bypasses CI/E2E gate. Use only when CI is broken and a hotfix image is urgently needed.
|
||||
workflow_dispatch:
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
concurrency:
|
||||
# Use the commit SHA across all trigger types so a workflow_run completion and a
|
||||
# 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 }}
|
||||
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 }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
@@ -28,10 +26,11 @@ env:
|
||||
|
||||
jobs:
|
||||
image:
|
||||
name: Build and Push Image
|
||||
name: ${{ github.event_name == 'pull_request' && 'Build and Scan Image' || 'Build and Push Image' }}
|
||||
if: >
|
||||
github.event_name != 'workflow_run' ||
|
||||
(github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push')
|
||||
github.event_name == 'pull_request' ||
|
||||
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
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
@@ -48,7 +47,6 @@ jobs:
|
||||
GH_SHA: ${{ github.sha }}
|
||||
WORKFLOW_RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
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
|
||||
echo "workflow_dispatch is only supported from main."
|
||||
exit 1
|
||||
@@ -63,41 +61,28 @@ jobs:
|
||||
SHORT_SHA="${SHA:0:12}"
|
||||
IMAGE="$REGISTRY/$IMAGE_NAME"
|
||||
|
||||
PRIMARY_TAG="$GH_REF_NAME"
|
||||
|
||||
echo "checkout_ref=${SHA}" >> "$GITHUB_OUTPUT"
|
||||
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
|
||||
echo "scan_tag=${IMAGE}:sha-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||
echo "tags<<EOF" >> "$GITHUB_OUTPUT"
|
||||
echo "${IMAGE}:${PRIMARY_TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "${IMAGE}:sha-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [[ "$EVENT_NAME" == "pull_request" ]]; then
|
||||
echo "should_push=false" >> "$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
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
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
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
@@ -124,6 +109,7 @@ jobs:
|
||||
exit-code: "1"
|
||||
|
||||
- name: Log in to GHCR
|
||||
if: steps.vars.outputs.should_push == 'true'
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
@@ -131,6 +117,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Push image
|
||||
if: steps.vars.outputs.should_push == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
while IFS= read -r tag; do
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
### 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 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.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# BookOrbit
|
||||
|
||||
[](https://github.com/neonsolstice/bookorbit/actions/workflows/ci.yml)
|
||||
[](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.
|
||||
|
||||
@@ -92,14 +93,13 @@ All commands run from the **repo root** unless noted otherwise.
|
||||
|
||||
### Everyday workflow
|
||||
|
||||
| Command | Description |
|
||||
| -------------------- | -------------------------------------------------- |
|
||||
| `pnpm setup` | One-time bootstrap |
|
||||
| `pnpm dev` | Daily development |
|
||||
| `pnpm verify` | Default local checks before push |
|
||||
| `pnpm quick` | Faster local checks while coding |
|
||||
| `pnpm verify:strict` | Aspirational strict gate (format + full typecheck) |
|
||||
| `pnpm guide` | Print command reference |
|
||||
| Command | Description |
|
||||
| -------------------- | --------------------------------------------------- |
|
||||
| `pnpm setup` | One-time bootstrap |
|
||||
| `pnpm dev` | Daily development |
|
||||
| `pnpm verify` | Default local checks before push |
|
||||
| `pnpm verify:fast` | Faster local checks while coding (lint + typecheck) |
|
||||
| `pnpm verify:strict` | Aspirational strict gate (format + full typecheck) |
|
||||
|
||||
### Development
|
||||
|
||||
@@ -111,14 +111,14 @@ All commands run from the **repo root** unless noted otherwise.
|
||||
|
||||
### Testing
|
||||
|
||||
| Command | Description |
|
||||
| ------------------------------- | ---------------------------- |
|
||||
| `pnpm test` | Server + client unit tests |
|
||||
| `pnpm test:server` | Server unit tests |
|
||||
| `pnpm test:client` | Client unit tests |
|
||||
| `pnpm test:e2e:smoke` | Server smoke e2e |
|
||||
| `pnpm coverage` | Coverage for server + client |
|
||||
| `pnpm --filter server test:e2e` | Server e2e only |
|
||||
| Command | Description |
|
||||
| ------------------------------------- | ---------------------------- |
|
||||
| `pnpm test` | Server + client unit tests |
|
||||
| `pnpm test:server` | Server unit tests |
|
||||
| `pnpm test:client` | Client unit tests |
|
||||
| `pnpm run e2e:run -- guard-mechanics` | Server smoke e2e |
|
||||
| `pnpm coverage` | Coverage for server + client |
|
||||
| `pnpm --filter server test:e2e` | Server e2e only |
|
||||
|
||||
### Database
|
||||
|
||||
@@ -133,25 +133,21 @@ All commands run from the **repo root** unless noted otherwise.
|
||||
|
||||
### Production operations
|
||||
|
||||
| Command | Description |
|
||||
| ------------------------------------------ | -------------------------------------------- |
|
||||
| `pnpm prod:up` | Start/update 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 |
|
||||
| Command | Description |
|
||||
| ---------------- | ------------------------------------- |
|
||||
| `pnpm prod:up` | Start/update production compose stack |
|
||||
| `pnpm prod:down` | Stop production compose stack |
|
||||
|
||||
### Linting & formatting
|
||||
|
||||
| Command | Description |
|
||||
| --------------------------------------- | ------------------------------------------ |
|
||||
| `pnpm format:check` | Check formatting |
|
||||
| `pnpm format` | Auto-format source files |
|
||||
| `pnpm lint:check` | Non-mutating lint checks |
|
||||
| `pnpm lint:fix` | Auto-fix lint issues |
|
||||
| `pnpm typecheck` | Server typecheck + no-new client TS errors |
|
||||
| `pnpm typecheck:full` | Typecheck server + client (strict) |
|
||||
| `pnpm typecheck:client:baseline:update` | Refresh accepted client TS baseline |
|
||||
| Command | Description |
|
||||
| --------------------- | ---------------------------------- |
|
||||
| `pnpm format:check` | Check formatting |
|
||||
| `pnpm format` | Auto-format source files |
|
||||
| `pnpm lint:check` | Non-mutating lint checks |
|
||||
| `pnpm lint:fix` | Auto-fix lint issues |
|
||||
| `pnpm typecheck` | Typecheck server + client |
|
||||
| `pnpm typecheck:full` | Typecheck server + client (strict) |
|
||||
|
||||
---
|
||||
|
||||
@@ -375,9 +371,10 @@ Make sure you're accessing the client at `http://localhost:5173` (not port 3000)
|
||||
|
||||
## Further Reading
|
||||
|
||||
| Doc | What it covers |
|
||||
| ------------------------------------------ | ------------------------------------------------------------ |
|
||||
| [`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 |
|
||||
| [`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 |
|
||||
| Doc | What it covers |
|
||||
| ---------------------------------------------------- | ------------------------------------------------------------ |
|
||||
| [`docs/production.md`](docs/production.md) | Production compose deployment, backups, restore testing |
|
||||
| [`docs/release-process.md`](docs/release-process.md) | Release workflow, PR title format, Docker publish, rollback |
|
||||
| [`server/README.md`](server/README.md) | Backend module map, DB commands, NestJS conventions |
|
||||
| [`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 |
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
};
|
||||
@@ -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
@@ -4,10 +4,10 @@
|
||||
"packageManager": "pnpm@10.32.1",
|
||||
"scripts": {
|
||||
"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:client": "cd client && npm run dev -- --host",
|
||||
"dev:server": "cd server && npm run start:dev",
|
||||
"verify:fast": "pnpm run lint:check && pnpm run typecheck && pnpm run test",
|
||||
"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 && pnpm dev -- --host",
|
||||
"dev:server": "cd server && pnpm start:dev",
|
||||
"verify:fast": "pnpm run lint:check && pnpm run typecheck",
|
||||
"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",
|
||||
"build:client": "pnpm --filter client build",
|
||||
@@ -44,10 +44,17 @@
|
||||
"pnpm": ">=9"
|
||||
},
|
||||
"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",
|
||||
"conventional-changelog-conventionalcommits": "^8.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.4.0",
|
||||
"prettier": "^3.8.3"
|
||||
"prettier": "^3.8.3",
|
||||
"semantic-release": "^24.2.5"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{md,json,yml,yaml}": [
|
||||
|
||||
Generated
+2302
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user