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
|
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
|
||||||
|
|||||||
@@ -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.
|
- **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.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# BookOrbit
|
# BookOrbit
|
||||||
|
|
||||||
[](https://github.com/neonsolstice/bookorbit/actions/workflows/ci.yml)
|
[](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.
|
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 |
|
||||||
|
|||||||
@@ -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",
|
"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}": [
|
||||||
|
|||||||
Generated
+2302
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user