ci: consolidate release workflow and automate release notes

- Hardened multi-arch container release flow with build-once promotion and safer image gating.
- Reworked semantic-release execution, permissions, and action pinning for more reliable releases.
- Expanded release notes transformation with contributor and artifact metadata, plus cleaner formatting.
- Fixed edge cases in notes rendering by preserving commit metadata and guarding author formatting.
- Removed redundant release notes sections to keep generated output concise and consistent.
This commit is contained in:
neon
2026-04-27 08:21:15 -06:00
parent 812efcd1be
commit de7a61888b
15 changed files with 734 additions and 175 deletions
+225 -63
View File
@@ -7,6 +7,11 @@ on:
branches: [main] 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:
inputs:
force_rebuild:
description: Rebuild even if image for this SHA already exists in GHCR
type: boolean
default: false
workflow_run: workflow_run:
workflows: ["CI"] workflows: ["CI"]
types: [completed] types: [completed]
@@ -25,63 +30,17 @@ env:
IMAGE_NAME: ${{ github.repository }} IMAGE_NAME: ${{ github.repository }}
jobs: jobs:
image: # PR path: amd64-only build and scan for fast feedback. No push.
name: ${{ github.event_name == 'pull_request' && 'Build and Scan Image' || 'Build and Push Image' }} scan:
if: > name: Build and Scan Image
github.event_name == 'pull_request' || if: 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 runs-on: ubuntu-latest
timeout-minutes: 45 timeout-minutes: 45
permissions: permissions:
contents: read contents: read
packages: write
steps: steps:
- name: Resolve source and image tags
id: vars
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
GH_REF: ${{ github.ref }}
GH_REF_NAME: ${{ github.ref_name }}
GH_SHA: ${{ github.sha }}
WORKFLOW_RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
if [[ "$EVENT_NAME" == "workflow_dispatch" && "$GH_REF" != "refs/heads/main" ]]; then
echo "workflow_dispatch is only supported from main."
exit 1
fi
if [[ "$EVENT_NAME" == "workflow_run" ]]; then
SHA="$WORKFLOW_RUN_HEAD_SHA"
else
SHA="$GH_SHA"
fi
SHORT_SHA="${SHA:0:12}"
IMAGE="$REGISTRY/$IMAGE_NAME"
echo "checkout_ref=${SHA}" >> "$GITHUB_OUTPUT"
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
echo "scan_tag=${IMAGE}:sha-${SHORT_SHA}" >> "$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 - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ steps.vars.outputs.checkout_ref }}
- 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
@@ -93,36 +52,239 @@ jobs:
file: ./Dockerfile file: ./Dockerfile
push: false push: false
load: true load: true
tags: ${{ steps.vars.outputs.tags }} tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:pr-scan
platforms: linux/amd64 platforms: linux/amd64
cache-from: type=gha cache-from: type=gha,scope=container-image-yml-pr
cache-to: type=gha,mode=max cache-to: type=gha,mode=max,scope=container-image-yml-pr
- name: Scan image with Trivy - name: Scan image with Trivy
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with: with:
image-ref: ${{ steps.vars.outputs.scan_tag }} image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:pr-scan
format: table format: table
vuln-type: os,library vuln-type: os,library
severity: CRITICAL,HIGH severity: CRITICAL,HIGH
ignore-unfixed: true ignore-unfixed: true
exit-code: "1" exit-code: "1"
# Main push path: resolve context once, then build multi-arch natively in parallel.
prepare:
name: Resolve Build Context
if: >
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: 5
permissions:
contents: read
packages: read
outputs:
source_sha: ${{ steps.ctx.outputs.source_sha }}
update_main: ${{ steps.ctx.outputs.update_main }}
skip_build: ${{ steps.ctx.outputs.skip_build }}
steps:
- name: Resolve build context
id: ctx
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
GH_REF: ${{ github.ref }}
WORKFLOW_RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
GH_SHA: ${{ github.sha }}
GH_TOKEN: ${{ github.token }}
IMAGE_NAME: ${{ github.repository }}
FORCE_REBUILD: ${{ inputs.force_rebuild }}
run: |
if [[ "$EVENT_NAME" == "workflow_dispatch" && "$GH_REF" != "refs/heads/main" ]]; then
echo "::error::workflow_dispatch is only supported from main."
exit 1
fi
if [[ "$EVENT_NAME" == "workflow_run" ]]; then
SOURCE_SHA="$WORKFLOW_RUN_HEAD_SHA"
echo "update_main=true" >> "$GITHUB_OUTPUT"
else
SOURCE_SHA="$GH_SHA"
echo "update_main=false" >> "$GITHUB_OUTPUT"
fi
echo "source_sha=${SOURCE_SHA}" >> "$GITHUB_OUTPUT"
IMAGE_LOWER=$(echo "${IMAGE_NAME}" | tr '[:upper:]' '[:lower:]')
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.oci.image.index.v1+json" \
"https://ghcr.io/v2/${IMAGE_LOWER}/manifests/sha-${SOURCE_SHA}")
if [[ "$HTTP_STATUS" == "200" && "${FORCE_REBUILD}" != "true" ]]; then
echo "Image sha-${SOURCE_SHA} already exists in GHCR, skipping rebuild."
echo "skip_build=true" >> "$GITHUB_OUTPUT"
else
echo "skip_build=false" >> "$GITHUB_OUTPUT"
fi
build-amd64:
name: Build and Push amd64
needs: [prepare]
if: needs.prepare.outputs.skip_build != 'true'
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
packages: write
outputs:
digest: ${{ steps.build.outputs.digest }}
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ needs.prepare.outputs.source_sha }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- 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 }}
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Push image - name: Build image
if: steps.vars.outputs.should_push == 'true' id: build
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: ./Dockerfile
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
platforms: linux/amd64
cache-from: type=gha,scope=container-image-yml-amd64
cache-to: type=gha,mode=max,scope=container-image-yml-amd64
- name: Scan image with Trivy
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
env:
TRIVY_PLATFORM: linux/amd64
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
format: table
vuln-type: os,library
severity: CRITICAL,HIGH
ignore-unfixed: true
exit-code: "1"
build-arm64:
name: Build and Push arm64
needs: [prepare]
if: needs.prepare.outputs.skip_build != 'true'
runs-on: ubuntu-24.04-arm
timeout-minutes: 45
permissions:
contents: read
packages: write
outputs:
digest: ${{ steps.build.outputs.digest }}
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ needs.prepare.outputs.source_sha }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- 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: Build image
id: build
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: ./Dockerfile
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
platforms: linux/arm64
cache-from: type=gha,scope=container-image-yml-arm64
cache-to: type=gha,mode=max,scope=container-image-yml-arm64
- name: Scan image with Trivy
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
env:
TRIVY_PLATFORM: linux/arm64
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
format: table
vuln-type: os,library
severity: CRITICAL,HIGH
ignore-unfixed: true
exit-code: "1"
merge-manifest:
name: Merge and Push Multi-Arch Manifest
needs: [prepare, build-amd64, build-arm64]
if: needs.prepare.outputs.skip_build != 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
packages: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- 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: Create SHA manifest
shell: bash shell: bash
env:
REGISTRY: ${{ env.REGISTRY }}
IMAGE: ${{ env.IMAGE_NAME }}
SHA: ${{ needs.prepare.outputs.source_sha }}
AMD64_DIGEST: ${{ needs.build-amd64.outputs.digest }}
ARM64_DIGEST: ${{ needs.build-arm64.outputs.digest }}
run: | run: |
while IFS= read -r tag; do FULL_IMAGE="${REGISTRY}/${IMAGE}"
[ -n "$tag" ] || continue docker buildx imagetools create \
docker push "$tag" --tag "${FULL_IMAGE}:sha-${SHA}" \
done <<'EOF' "${FULL_IMAGE}@${AMD64_DIGEST}" \
${{ steps.vars.outputs.tags }} "${FULL_IMAGE}@${ARM64_DIGEST}"
EOF
- name: Update main tag
if: needs.prepare.outputs.update_main == 'true'
shell: bash
env:
REGISTRY: ${{ env.REGISTRY }}
IMAGE: ${{ env.IMAGE_NAME }}
SHA: ${{ needs.prepare.outputs.source_sha }}
AMD64_DIGEST: ${{ needs.build-amd64.outputs.digest }}
ARM64_DIGEST: ${{ needs.build-arm64.outputs.digest }}
run: |
FULL_IMAGE="${REGISTRY}/${IMAGE}"
docker buildx imagetools create \
--tag "${FULL_IMAGE}:main" \
"${FULL_IMAGE}@${AMD64_DIGEST}" \
"${FULL_IMAGE}@${ARM64_DIGEST}"
- name: Inspect SHA manifest
shell: bash
env:
REGISTRY: ${{ env.REGISTRY }}
IMAGE: ${{ env.IMAGE_NAME }}
SHA: ${{ needs.prepare.outputs.source_sha }}
run: docker buildx imagetools inspect "${REGISTRY}/${IMAGE}:sha-${SHA}"
+130 -57
View File
@@ -81,9 +81,11 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15
permissions: permissions:
actions: read
contents: write contents: write
issues: write issues: write
pull-requests: write pull-requests: write
packages: read
outputs: outputs:
published: ${{ steps.semantic.outputs.new_release_published }} published: ${{ steps.semantic.outputs.new_release_published }}
version: ${{ steps.semantic.outputs.new_release_version }} version: ${{ steps.semantic.outputs.new_release_version }}
@@ -123,6 +125,30 @@ jobs:
fi fi
echo "CI verification passed: ${success_count} successful run(s) found." echo "CI verification passed: ${success_count} successful run(s) found."
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Log in to GHCR
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Verify SHA image exists in GHCR
shell: bash
env:
COMMIT_SHA: ${{ github.sha }}
IMAGE_NAME: ${{ github.repository }}
run: |
set -euo pipefail
SOURCE_TAG="ghcr.io/${IMAGE_NAME}:sha-${COMMIT_SHA}"
if ! docker buildx imagetools inspect "${SOURCE_TAG}" > /dev/null 2>&1; then
echo "::error::Multi-arch image sha-${COMMIT_SHA} not found in GHCR. Wait for the Container Image workflow to complete for this commit before releasing."
exit 1
fi
echo "Image sha-${COMMIT_SHA} verified in GHCR."
- name: Set up Node.js and pnpm - name: Set up Node.js and pnpm
uses: ./.github/actions/setup-node-pnpm uses: ./.github/actions/setup-node-pnpm
with: with:
@@ -131,10 +157,23 @@ jobs:
- name: Run semantic-release - name: Run semantic-release
id: semantic id: semantic
uses: cycjimmy/semantic-release-action@16ca923e6ccbb50770c415a0ccd43709a8c5f7a4 # v4.2.2 shell: bash
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
BEFORE_TAG=$(git tag --sort=-v:refname | head -1 || true)
pnpm exec semantic-release
git fetch --tags --quiet
AFTER_TAG=$(git tag --sort=-v:refname | head -1 || true)
if [[ -n "$AFTER_TAG" && "$AFTER_TAG" != "$BEFORE_TAG" ]]; then
VERSION="${AFTER_TAG#v}"
echo "new_release_published=true" >> "$GITHUB_OUTPUT"
echo "new_release_version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "new_release_git_tag=${AFTER_TAG}" >> "$GITHUB_OUTPUT"
else
echo "new_release_published=false" >> "$GITHUB_OUTPUT"
fi
- name: Release summary - name: Release summary
if: steps.semantic.outputs.new_release_published == 'true' if: steps.semantic.outputs.new_release_published == 'true'
@@ -148,64 +187,22 @@ jobs:
echo "- **Version:** ${VERSION}" >> "$GITHUB_STEP_SUMMARY" echo "- **Version:** ${VERSION}" >> "$GITHUB_STEP_SUMMARY"
echo "- **Tag:** ${TAG}" >> "$GITHUB_STEP_SUMMARY" echo "- **Tag:** ${TAG}" >> "$GITHUB_STEP_SUMMARY"
docker-publish: docker-promote:
name: Publish Docker Image name: Promote Docker Image
needs: [release] needs: [release]
if: needs.release.outputs.published == 'true' if: needs.release.outputs.published == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 45 timeout-minutes: 10
permissions: permissions:
contents: read contents: write
packages: write packages: write
env: env:
REGISTRY: ghcr.io REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }} IMAGE_NAME: ${{ github.repository }}
steps: steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ needs.release.outputs.tag }}
- 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
- 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 - name: Log in to GHCR
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with: with:
@@ -213,21 +210,97 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Push image - name: Verify SHA image and resolve digest
id: source
shell: bash shell: bash
env:
REGISTRY: ${{ env.REGISTRY }}
IMAGE: ${{ env.IMAGE_NAME }}
SHA: ${{ github.sha }}
run: | run: |
while IFS= read -r tag; do FULL_IMAGE="${REGISTRY}/${IMAGE}"
[ -n "$tag" ] || continue SOURCE_TAG="${FULL_IMAGE}:sha-${SHA}"
docker push "$tag" if ! docker buildx imagetools inspect "${SOURCE_TAG}" > /dev/null 2>&1; then
done <<'EOF' echo "::error::Multi-arch image sha-${SHA} not found in GHCR. Wait for the Container Image workflow to complete for this commit before releasing."
${{ steps.tags.outputs.tags }} exit 1
EOF fi
DIGEST=$(docker buildx imagetools inspect --format '{{json .Manifest}}' "${SOURCE_TAG}" | jq -r '.digest')
echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT"
echo "image=${FULL_IMAGE}" >> "$GITHUB_OUTPUT"
- name: Promote to version and latest
shell: bash
env:
VERSION: ${{ needs.release.outputs.version }}
DIGEST: ${{ steps.source.outputs.digest }}
IMAGE: ${{ steps.source.outputs.image }}
run: |
docker buildx imagetools create \
--tag "${IMAGE}:${VERSION}" \
"${IMAGE}@${DIGEST}"
docker buildx imagetools create \
--tag "${IMAGE}:latest" \
"${IMAGE}@${DIGEST}"
- name: Inspect promoted manifests
shell: bash
env:
VERSION: ${{ needs.release.outputs.version }}
IMAGE: ${{ steps.source.outputs.image }}
run: |
docker buildx imagetools inspect "${IMAGE}:${VERSION}"
docker buildx imagetools inspect "${IMAGE}:latest"
- name: Enrich release notes
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
TAG: ${{ needs.release.outputs.tag }}
VERSION: ${{ needs.release.outputs.version }}
SHA: ${{ github.sha }}
IMAGE: ${{ steps.source.outputs.image }}
DIGEST: ${{ steps.source.outputs.digest }}
run: |
set -euo pipefail
BODY_FILE="$(mktemp)"
gh release view "${TAG}" --repo "${GH_REPO}" --json body --jq '.body' > "${BODY_FILE}"
if grep -q "<!-- release-metadata -->" "${BODY_FILE}"; then
echo "Release metadata already present; skipping."
exit 0
fi
PREV_TAG=$(gh release list \
--repo "${GH_REPO}" \
--exclude-drafts \
--exclude-pre-releases \
--limit 100 \
--json tagName \
--jq '.[].tagName' \
| awk -v current="${TAG}" '$0 != current {print; exit}')
{
echo ""
echo "<!-- release-metadata -->"
echo ""
if [[ -n "${PREV_TAG}" ]]; then
echo "[Full diff](https://github.com/${GH_REPO}/compare/${PREV_TAG}...${TAG})"
echo ""
fi
echo "### Release Artifact"
echo "- Image tags: \`${IMAGE}:${VERSION}\`, \`${IMAGE}:latest\`"
} >> "${BODY_FILE}"
gh release edit "${TAG}" --repo "${GH_REPO}" --notes-file "${BODY_FILE}"
- name: Docker publish summary - name: Docker publish summary
shell: bash shell: bash
env: env:
VERSION: ${{ needs.release.outputs.version }} VERSION: ${{ needs.release.outputs.version }}
SHA: ${{ github.sha }}
run: | run: |
echo "## Docker Image Published" >> "$GITHUB_STEP_SUMMARY" echo "## Docker Image Promoted" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY"
echo "- **Tags:** \`${VERSION}\`, \`latest\`" >> "$GITHUB_STEP_SUMMARY" echo "- **Source:** \`sha-${SHA}\`" >> "$GITHUB_STEP_SUMMARY"
echo "- **Tags:** \`${VERSION}\`, \`latest\` (linux/amd64, linux/arm64)" >> "$GITHUB_STEP_SUMMARY"
-38
View File
@@ -1,38 +0,0 @@
{
"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"
]
}
+159
View File
@@ -0,0 +1,159 @@
"use strict";
const { spawnSync } = require("child_process");
const DOCKER_IMAGE = "ghcr.io/neonsolstice/bookorbit";
// Maps local git author names to GitHub usernames for @-mentions in release notes.
const AUTHOR_MAP = {
neon: "neonsolstice",
};
const 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" },
];
// Commit partial: standard conventional-changelog layout with an optional
// author @-mention inserted before the hash link.
// Based on conventional-changelog-conventionalcommits@8 commit.hbs.
const commitPartial = `\
*{{#if scope}} **{{scope}}:**
{{~/if}} {{#if subject}}
{{~subject}}
{{~else}}
{{~header}}
{{~/if}}
{{~#if githubUser}} by @{{githubUser}}{{/if}}
{{~!-- commit link --}}{{~#if hash}} {{#if @root.linkReferences~}}
([{{shortHash}}]({{commitUrlFormat}}))
{{~else}}
{{~shortHash}}
{{~/if}}{{~/if}}
{{~!-- commit references --}}
{{~#if references~}}
, closes
{{~#each references}} {{#if @root.linkReferences~}}
[
{{~#if this.owner}}
{{~this.owner}}/
{{~/if}}
{{~this.repository}}{{this.prefix}}{{this.issue}}]({{issueUrlFormat}})
{{~else}}
{{~#if this.owner}}
{{~this.owner}}/
{{~/if}}
{{~this.repository}}{{this.prefix}}{{this.issue}}
{{~/if}}{{/each}}
{{~/if}}`;
// Main template: removes the version header (GitHub Release already shows it),
// strips the warning emoji from breaking-changes headings, and appends a
// Docker pull block after the commit groups.
const mainTemplate = [
"{{#if noteGroups}}",
"{{#each noteGroups}}",
"",
"### {{title}}",
"",
"{{#each notes}}",
"* {{#if commit.scope}}**{{commit.scope}}:** {{/if}}{{text}}",
"{{/each}}",
"{{/each}}",
"{{/if}}",
"{{#each commitGroups}}",
"",
"{{#if title}}",
"### {{title}}",
"",
"{{/if}}",
"{{#each commits}}",
"{{> commit root=@root}}",
"{{/each}}",
"",
"{{/each}}",
"---",
"",
"**Docker**",
"",
"```bash",
`docker pull ${DOCKER_IMAGE}:{{version}}`,
"```",
"",
"Multi-arch: `linux/amd64` and `linux/arm64`.",
].join("\n");
// Runs after the preset transform. Looks up each commit's author via git log,
// maps git name -> GitHub username, and injects githubUser into each commit
// for the commitPartial.
function finalizeContext(ctx) {
try {
const result = spawnSync("git", ["log", "--format=%h %aN", "--max-count=500"], {
encoding: "utf8",
cwd: process.cwd(),
});
const shaToGithubUser = new Map();
if (result.status === 0) {
for (const line of result.stdout.split("\n").filter(Boolean)) {
const spaceIdx = line.indexOf(" ");
const shortSha = line.substring(0, spaceIdx);
const authorName = line.substring(spaceIdx + 1).trim();
if (!authorName || /\[bot\]/i.test(authorName)) continue;
shaToGithubUser.set(shortSha, AUTHOR_MAP[authorName] ?? authorName);
}
}
for (const group of ctx.commitGroups ?? []) {
for (const commit of group.commits ?? []) {
if (commit.shortHash) {
const githubUser = shaToGithubUser.get(commit.shortHash);
if (githubUser) commit.githubUser = githubUser;
}
}
}
} catch {
// non-fatal: commits render without author attribution
}
return ctx;
}
module.exports = {
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: TYPES },
writerOpts: {
commitPartial,
mainTemplate,
finalizeContext,
},
},
],
"@semantic-release/github",
],
};
@@ -555,6 +555,15 @@ CREATE TABLE "reading_sessions" (
CONSTRAINT "reading_sessions_ended_after_started_chk" CHECK ("reading_sessions"."ended_at" >= "reading_sessions"."started_at") CONSTRAINT "reading_sessions_ended_after_started_chk" CHECK ("reading_sessions"."ended_at" >= "reading_sessions"."started_at")
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE "user_book_ratings" (
"user_id" integer NOT NULL,
"book_id" integer NOT NULL,
"rating" integer NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "user_book_ratings_user_id_book_id_pk" PRIMARY KEY("user_id","book_id"),
CONSTRAINT "user_book_ratings_rating_range_chk" CHECK ("user_book_ratings"."rating" >= 1 and "user_book_ratings"."rating" <= 5)
);
--> statement-breakpoint
CREATE TABLE "user_book_status" ( CREATE TABLE "user_book_status" (
"user_id" integer NOT NULL, "user_id" integer NOT NULL,
"book_id" integer NOT NULL, "book_id" integer NOT NULL,
@@ -968,6 +977,8 @@ ALTER TABLE "reading_progress" ADD CONSTRAINT "reading_progress_book_file_id_boo
ALTER TABLE "reading_progress" ADD CONSTRAINT "reading_progress_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "reading_progress" ADD CONSTRAINT "reading_progress_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reading_sessions" ADD CONSTRAINT "reading_sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "reading_sessions" ADD CONSTRAINT "reading_sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reading_sessions" ADD CONSTRAINT "reading_sessions_book_file_id_book_files_id_fk" FOREIGN KEY ("book_file_id") REFERENCES "public"."book_files"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "reading_sessions" ADD CONSTRAINT "reading_sessions_book_file_id_book_files_id_fk" FOREIGN KEY ("book_file_id") REFERENCES "public"."book_files"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_book_ratings" ADD CONSTRAINT "user_book_ratings_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_book_ratings" ADD CONSTRAINT "user_book_ratings_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_book_status" ADD CONSTRAINT "user_book_status_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "user_book_status" ADD CONSTRAINT "user_book_status_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_book_status" ADD CONSTRAINT "user_book_status_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "user_book_status" ADD CONSTRAINT "user_book_status_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_reading_daily_stats" ADD CONSTRAINT "user_reading_daily_stats_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "user_reading_daily_stats" ADD CONSTRAINT "user_reading_daily_stats_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
@@ -1103,6 +1114,8 @@ CREATE UNIQUE INDEX "rs_user_session_id_uidx" ON "reading_sessions" USING btree
CREATE INDEX "rs_user_started_at_idx" ON "reading_sessions" USING btree ("user_id","started_at");--> statement-breakpoint CREATE INDEX "rs_user_started_at_idx" ON "reading_sessions" USING btree ("user_id","started_at");--> statement-breakpoint
CREATE INDEX "rs_book_file_started_at_idx" ON "reading_sessions" USING btree ("book_file_id","started_at");--> statement-breakpoint CREATE INDEX "rs_book_file_started_at_idx" ON "reading_sessions" USING btree ("book_file_id","started_at");--> statement-breakpoint
CREATE INDEX "rs_user_book_file_idx" ON "reading_sessions" USING btree ("user_id","book_file_id");--> statement-breakpoint CREATE INDEX "rs_user_book_file_idx" ON "reading_sessions" USING btree ("user_id","book_file_id");--> statement-breakpoint
CREATE INDEX "ubr_user_id_idx" ON "user_book_ratings" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "ubr_book_id_idx" ON "user_book_ratings" USING btree ("book_id");--> statement-breakpoint
CREATE INDEX "ubs_user_id_idx" ON "user_book_status" USING btree ("user_id");--> statement-breakpoint CREATE INDEX "ubs_user_id_idx" ON "user_book_status" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "ubs_user_status_idx" ON "user_book_status" USING btree ("user_id","status");--> statement-breakpoint CREATE INDEX "ubs_user_status_idx" ON "user_book_status" USING btree ("user_id","status");--> statement-breakpoint
CREATE INDEX "urds_user_day_idx" ON "user_reading_daily_stats" USING btree ("user_id","day");--> statement-breakpoint CREATE INDEX "urds_user_day_idx" ON "user_reading_daily_stats" USING btree ("user_id","day");--> statement-breakpoint
@@ -1,5 +1,5 @@
{ {
"id": "2de4dd88-2590-4da4-b868-f9341ffe3e9f", "id": "e9ca331e-b35a-4d80-a54b-28759d1e1c61",
"prevId": "00000000-0000-0000-0000-000000000000", "prevId": "00000000-0000-0000-0000-000000000000",
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
@@ -5286,6 +5286,104 @@
}, },
"isRLSEnabled": false "isRLSEnabled": false
}, },
"public.user_book_ratings": {
"name": "user_book_ratings",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"book_id": {
"name": "book_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"rating": {
"name": "rating",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ubr_user_id_idx": {
"name": "ubr_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"ubr_book_id_idx": {
"name": "ubr_book_id_idx",
"columns": [
{
"expression": "book_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_book_ratings_user_id_users_id_fk": {
"name": "user_book_ratings_user_id_users_id_fk",
"tableFrom": "user_book_ratings",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"user_book_ratings_book_id_books_id_fk": {
"name": "user_book_ratings_book_id_books_id_fk",
"tableFrom": "user_book_ratings",
"tableTo": "books",
"columnsFrom": ["book_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"user_book_ratings_user_id_book_id_pk": {
"name": "user_book_ratings_user_id_book_id_pk",
"columns": ["user_id", "book_id"]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"user_book_ratings_rating_range_chk": {
"name": "user_book_ratings_rating_range_chk",
"value": "\"user_book_ratings\".\"rating\" >= 1 and \"user_book_ratings\".\"rating\" <= 5"
}
},
"isRLSEnabled": false
},
"public.user_book_status": { "public.user_book_status": {
"name": "user_book_status", "name": "user_book_status",
"schema": "", "schema": "",
+1 -1
View File
@@ -5,7 +5,7 @@
{ {
"idx": 0, "idx": 0,
"version": "7", "version": "7",
"when": 1777215796833, "when": 1777315276249,
"tag": "0000_baseline", "tag": "0000_baseline",
"breakpoints": true "breakpoints": true
} }
+26
View File
@@ -42,6 +42,32 @@ export const userBookStatus = pgTable(
export type UserBookStatusRow = typeof userBookStatus.$inferSelect; export type UserBookStatusRow = typeof userBookStatus.$inferSelect;
export type NewUserBookStatus = typeof userBookStatus.$inferInsert; export type NewUserBookStatus = typeof userBookStatus.$inferInsert;
export const userBookRatings = pgTable(
'user_book_ratings',
{
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
bookId: integer('book_id')
.notNull()
.references(() => books.id, { onDelete: 'cascade' }),
rating: integer('rating').notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date()),
},
(t) => [
primaryKey({ columns: [t.userId, t.bookId] }),
index('ubr_user_id_idx').on(t.userId),
index('ubr_book_id_idx').on(t.bookId),
check('user_book_ratings_rating_range_chk', sql`${t.rating} >= 1 and ${t.rating} <= 5`),
],
);
export type UserBookRatingRow = typeof userBookRatings.$inferSelect;
export type NewUserBookRating = typeof userBookRatings.$inferInsert;
export const readingProgress = pgTable( export const readingProgress = pgTable(
'reading_progress', 'reading_progress',
{ {
@@ -17,6 +17,7 @@ import {
collectionBooks, collectionBooks,
collections, collections,
readingProgress, readingProgress,
userBookRatings,
genres, genres,
libraries, libraries,
narrators, narrators,
@@ -33,7 +34,6 @@ const SORT_FIELD_MAP: Partial<Record<SortField, AnyColumn>> = {
updatedAt: books.updatedAt, updatedAt: books.updatedAt,
publishedYear: bookMetadata.publishedYear, publishedYear: bookMetadata.publishedYear,
pageCount: bookMetadata.pageCount, pageCount: bookMetadata.pageCount,
rating: bookMetadata.rating,
publisher: bookMetadata.publisher, publisher: bookMetadata.publisher,
}; };
@@ -119,6 +119,11 @@ export class BookQueryBuilder {
result.push( result.push(
sql`(SELECT ubs.finished_at FROM user_book_status ubs WHERE ubs.book_id = books.id AND ubs.user_id = ${userId}) ${sql.raw(D)} NULLS LAST`, sql`(SELECT ubs.finished_at FROM user_book_status ubs WHERE ubs.book_id = books.id AND ubs.user_id = ${userId}) ${sql.raw(D)} NULLS LAST`,
); );
} else if (field === 'rating') {
if (userId === undefined) throw new BadRequestException('rating sort requires an authenticated user');
result.push(
sql`(SELECT ubr.rating FROM user_book_ratings ubr WHERE ubr.book_id = books.id AND ubr.user_id = ${userId}) ${sql.raw(D)} NULLS LAST`,
);
} else if (field === 'random') { } else if (field === 'random') {
const daySeed = Math.floor(Date.now() / 86_400_000); const daySeed = Math.floor(Date.now() / 86_400_000);
const scopedSeed = daySeed + (userId ?? 0); const scopedSeed = daySeed + (userId ?? 0);
@@ -172,7 +177,8 @@ export class BookQueryBuilder {
case 'pageCount': case 'pageCount':
return this.numericRuleToSql(bookMetadata.pageCount, operator, value as number, valueTo as number | undefined); return this.numericRuleToSql(bookMetadata.pageCount, operator, value as number, valueTo as number | undefined);
case 'rating': case 'rating':
return this.numericRuleToSql(bookMetadata.rating, operator, value as number, valueTo as number | undefined); if (userId === undefined) throw new BadRequestException('rating filter requires an authenticated user');
return this.ratingRuleToSql(operator, value as number | undefined, valueTo as number | undefined, userId);
case 'author': case 'author':
return this.authorRuleToSql(operator, value as string[]); return this.authorRuleToSql(operator, value as string[]);
case 'genre': case 'genre':
@@ -277,6 +283,40 @@ export class BookQueryBuilder {
} }
} }
private ratingRuleToSql(operator: string, value: number | undefined, valueTo: number | undefined, userId: number): SQL {
const ratingExpr = sql<number>`(SELECT ${userBookRatings.rating} FROM ${userBookRatings} WHERE ${userBookRatings.bookId} = ${books.id} AND ${userBookRatings.userId} = ${userId})`;
switch (operator) {
case 'eq':
this.assertNumber(value, operator, 'value');
return sql`${ratingExpr} = ${value!}`;
case 'notEq':
this.assertNumber(value, operator, 'value');
return sql`${ratingExpr} <> ${value!}`;
case 'gt':
this.assertNumber(value, operator, 'value');
return sql`${ratingExpr} > ${value!}`;
case 'gte':
this.assertNumber(value, operator, 'value');
return sql`${ratingExpr} >= ${value!}`;
case 'lt':
this.assertNumber(value, operator, 'value');
return sql`${ratingExpr} < ${value!}`;
case 'lte':
this.assertNumber(value, operator, 'value');
return sql`${ratingExpr} <= ${value!}`;
case 'between':
this.assertNumber(value, operator, 'value');
this.assertNumber(valueTo, operator, 'valueTo');
return sql`${ratingExpr} >= ${value!} and ${ratingExpr} <= ${valueTo!}`;
case 'isEmpty':
return sql`${ratingExpr} is null`;
case 'isNotEmpty':
return sql`${ratingExpr} is not null`;
default:
throw new BadRequestException(`Invalid operator '${operator}' for numeric field`);
}
}
private authorRuleToSql(operator: string, values?: string[]): SQL { private authorRuleToSql(operator: string, values?: string[]): SQL {
const existsAuthor = (whereClause?: SQL) => { const existsAuthor = (whereClause?: SQL) => {
const predicates: SQL[] = [eq(bookAuthors.bookId, books.id)]; const predicates: SQL[] = [eq(bookAuthors.bookId, books.id)];
+27 -4
View File
@@ -26,6 +26,7 @@ import {
narrators, narrators,
audiobookProgress, audiobookProgress,
readingProgress, readingProgress,
userBookRatings,
tags, tags,
userBookStatus, userBookStatus,
} from '../../db/schema'; } from '../../db/schema';
@@ -93,12 +94,13 @@ export class BookRepository {
seriesIndex: bookMetadata.seriesIndex, seriesIndex: bookMetadata.seriesIndex,
publishedYear: bookMetadata.publishedYear, publishedYear: bookMetadata.publishedYear,
language: bookMetadata.language, language: bookMetadata.language,
rating: bookMetadata.rating, rating: userBookRatings.rating,
coverSource: bookMetadata.coverSource, coverSource: bookMetadata.coverSource,
lockedFields: bookMetadata.lockedFields, lockedFields: bookMetadata.lockedFields,
}) })
.from(books) .from(books)
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id)) .leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.leftJoin(userBookRatings, and(eq(userBookRatings.bookId, books.id), eq(userBookRatings.userId, userId)))
.where(where) .where(where)
.orderBy(...orderBy) .orderBy(...orderBy)
.limit(limit) .limit(limit)
@@ -268,7 +270,7 @@ export class BookRepository {
book_metadata.series_index, book_metadata.series_index,
book_metadata.published_year, book_metadata.published_year,
book_metadata.language, book_metadata.language,
book_metadata.rating, ubr.rating,
book_metadata.cover_source, book_metadata.cover_source,
book_metadata.locked_fields, book_metadata.locked_fields,
book_metadata.publisher, book_metadata.publisher,
@@ -280,6 +282,7 @@ export class BookRepository {
sc.cover_book_ids sc.cover_book_ids
FROM books FROM books
LEFT JOIN book_metadata ON book_metadata.book_id = books.id LEFT JOIN book_metadata ON book_metadata.book_id = books.id
LEFT JOIN user_book_ratings ubr ON ubr.book_id = books.id AND ubr.user_id = ${userId}
LEFT JOIN series_agg sa LEFT JOIN series_agg sa
ON sa.norm_series = NULLIF(lower(btrim(book_metadata.series_name)), '') ON sa.norm_series = NULLIF(lower(btrim(book_metadata.series_name)), '')
AND sa.library_id = books.library_id AND sa.library_id = books.library_id
@@ -420,6 +423,15 @@ export class BookRepository {
return { book, authorRows, genreRows, tagRows, fileRows, narratorRows }; return { book, authorRows, genreRows, tagRows, fileRows, narratorRows };
} }
async findRatingByBookAndUser(bookId: number, userId: number): Promise<number | null> {
const [row] = await this.db
.select({ rating: userBookRatings.rating })
.from(userBookRatings)
.where(and(eq(userBookRatings.bookId, bookId), eq(userBookRatings.userId, userId)))
.limit(1);
return row?.rating ?? null;
}
async findCollectionsByBookId(bookId: number, userId: number): Promise<{ id: number; name: string }[]> { async findCollectionsByBookId(bookId: number, userId: number): Promise<{ id: number; name: string }[]> {
return this.db return this.db
.select({ id: collections.id, name: collections.name }) .select({ id: collections.id, name: collections.name })
@@ -704,9 +716,20 @@ export class BookRepository {
await this.db.delete(books).where(inArray(books.id, bookIds)); await this.db.delete(books).where(inArray(books.id, bookIds));
} }
async bulkSetRating(bookIds: number[], rating: number | null): Promise<void> { async bulkSetRating(bookIds: number[], rating: number | null, userId: number): Promise<void> {
if (bookIds.length === 0) return; if (bookIds.length === 0) return;
await this.db.update(bookMetadata).set({ rating, updatedAt: new Date() }).where(inArray(bookMetadata.bookId, bookIds)); if (rating === null) {
await this.db.delete(userBookRatings).where(and(eq(userBookRatings.userId, userId), inArray(userBookRatings.bookId, bookIds)));
return;
}
await this.db
.insert(userBookRatings)
.values(bookIds.map((bookId) => ({ userId, bookId, rating })))
.onConflictDoUpdate({
target: [userBookRatings.userId, userBookRatings.bookId],
set: { rating, updatedAt: new Date() },
});
} }
async findTagsByBookIds(bookIds: number[], executor: MetadataReadExecutor = this.db): Promise<Map<number, string[]>> { async findTagsByBookIds(bookIds: number[], executor: MetadataReadExecutor = this.db): Promise<Map<number, string[]>> {
+4 -2
View File
@@ -85,6 +85,7 @@ function makeService() {
findTagsByBookIds: vi.fn(), findTagsByBookIds: vi.fn(),
findPrimaryFile: vi.fn(), findPrimaryFile: vi.fn(),
findById: vi.fn(), findById: vi.fn(),
findRatingByBookAndUser: vi.fn().mockResolvedValue(null),
findCollectionsByBookId: vi.fn(), findCollectionsByBookId: vi.fn(),
findKoboReadingState: vi.fn(), findKoboReadingState: vi.fn(),
findKoboSnapshotState: vi.fn(), findKoboSnapshotState: vi.fn(),
@@ -847,11 +848,11 @@ describe('BookService', () => {
5, 5,
expect.objectContaining({ expect.objectContaining({
title: null, title: null,
rating: 4,
updatedAt: expect.any(Date), updatedAt: expect.any(Date),
}), }),
expect.anything(), expect.anything(),
); );
expect(bookRepo.bulkSetRating).toHaveBeenCalledWith([5], 4, user.id);
expect(metadataService.replaceAuthors).toHaveBeenCalledWith( expect(metadataService.replaceAuthors).toHaveBeenCalledWith(
5, 5,
[ [
@@ -1670,7 +1671,7 @@ describe('BookService', () => {
await service.bulkSetRating([3, 5], 4, user); await service.bulkSetRating([3, 5], 4, user);
expect(bookRepo.bulkSetRating).toHaveBeenCalledWith([3, 5], 4); expect(bookRepo.bulkSetRating).toHaveBeenCalledWith([3, 5], 4, 42);
expect(fileWriteService.scheduleWrite).toHaveBeenNthCalledWith(1, 3, 'auto', 42); expect(fileWriteService.scheduleWrite).toHaveBeenNthCalledWith(1, 3, 'auto', 42);
expect(fileWriteService.scheduleWrite).toHaveBeenNthCalledWith(2, 5, 'auto', 42); expect(fileWriteService.scheduleWrite).toHaveBeenNthCalledWith(2, 5, 'auto', 42);
expect(scoreService.calculateAndSave).toHaveBeenNthCalledWith(1, 3); expect(scoreService.calculateAndSave).toHaveBeenNthCalledWith(1, 3);
@@ -1791,6 +1792,7 @@ describe('BookService', () => {
userBookStatusService.findOne.mockResolvedValue({ status: 'reading' }); userBookStatusService.findOne.mockResolvedValue({ status: 'reading' });
comicMetadataService.findByBookId.mockResolvedValue({ issueNumber: '1', teams: ['House Atreides'] }); comicMetadataService.findByBookId.mockResolvedValue({ issueNumber: '1', teams: ['House Atreides'] });
bookRepo.findCollectionsByBookId.mockResolvedValue([{ id: 3, name: 'Favorites' }]); bookRepo.findCollectionsByBookId.mockResolvedValue([{ id: 3, name: 'Favorites' }]);
bookRepo.findRatingByBookAndUser.mockResolvedValue(5);
const result = await service.getDetail(9, user); const result = await service.getDetail(9, user);
+8 -4
View File
@@ -508,7 +508,6 @@ export class BookService {
if (dto.seriesIndex !== undefined) scalarFields.seriesIndex = dto.seriesIndex ?? null; if (dto.seriesIndex !== undefined) scalarFields.seriesIndex = dto.seriesIndex ?? null;
if (dto.isbn10 !== undefined) scalarFields.isbn10 = dto.isbn10 ?? null; if (dto.isbn10 !== undefined) scalarFields.isbn10 = dto.isbn10 ?? null;
if (dto.isbn13 !== undefined) scalarFields.isbn13 = dto.isbn13 ?? null; if (dto.isbn13 !== undefined) scalarFields.isbn13 = dto.isbn13 ?? null;
if (dto.rating !== undefined) scalarFields.rating = dto.rating ?? null;
if (dto.googleBooksId !== undefined) scalarFields.googleBooksId = dto.googleBooksId ?? null; if (dto.googleBooksId !== undefined) scalarFields.googleBooksId = dto.googleBooksId ?? null;
if (dto.goodreadsId !== undefined) scalarFields.goodreadsId = dto.goodreadsId ?? null; if (dto.goodreadsId !== undefined) scalarFields.goodreadsId = dto.goodreadsId ?? null;
if (dto.amazonId !== undefined) scalarFields.amazonId = dto.amazonId ?? null; if (dto.amazonId !== undefined) scalarFields.amazonId = dto.amazonId ?? null;
@@ -565,6 +564,10 @@ export class BookService {
this.metadataService.emitAuthorsReplaced(id, replacedAuthorIds); this.metadataService.emitAuthorsReplaced(id, replacedAuthorIds);
if (dto.rating !== undefined) {
await this.bookRepo.bulkSetRating([id], dto.rating ?? null, user.id);
}
this.embedder?.embedBook(id).catch((err: Error) => this.logger.warn(`Embedding failed for book ${id}: ${err.message}`)); this.embedder?.embedBook(id).catch((err: Error) => this.logger.warn(`Embedding failed for book ${id}: ${err.message}`));
this.fileWriteService?.scheduleWrite(id, 'auto', user.id); this.fileWriteService?.scheduleWrite(id, 'auto', user.id);
this.scoreService.calculateAndSave(id).catch((err: Error) => this.logger.warn(`Score calculation failed for book ${id}: ${err.message}`)); this.scoreService.calculateAndSave(id).catch((err: Error) => this.logger.warn(`Score calculation failed for book ${id}: ${err.message}`));
@@ -755,7 +758,7 @@ export class BookService {
const startedAt = Date.now(); const startedAt = Date.now();
this.logger.log(`[${event}] [start] userId=${user.id} count=${bookIds.length} rating=${rating ?? 'null'} - bulk set rating started`); this.logger.log(`[${event}] [start] userId=${user.id} count=${bookIds.length} rating=${rating ?? 'null'} - bulk set rating started`);
await this.verifyLibraryAccessForBookIds(bookIds, user); await this.verifyLibraryAccessForBookIds(bookIds, user);
await this.bookRepo.bulkSetRating(bookIds, rating); await this.bookRepo.bulkSetRating(bookIds, rating, user.id);
this.triggerPostMetadataUpdateEffects(bookIds, user.id); this.triggerPostMetadataUpdateEffects(bookIds, user.id);
this.logger.log( this.logger.log(
`[${event}] [end] userId=${user.id} count=${bookIds.length} rating=${rating ?? 'null'} durationMs=${Date.now() - startedAt} - bulk set rating completed`, `[${event}] [end] userId=${user.id} count=${bookIds.length} rating=${rating ?? 'null'} durationMs=${Date.now() - startedAt} - bulk set rating completed`,
@@ -1226,8 +1229,9 @@ export class BookService {
async getDetail(id: number, user: RequestUser): Promise<BookDetailDto> { async getDetail(id: number, user: RequestUser): Promise<BookDetailDto> {
await this.verifyBookAccess(id, user); await this.verifyBookAccess(id, user);
const [result, readStatus, comicMeta, collectionRows] = await Promise.all([ const [result, personalRating, readStatus, comicMeta, collectionRows] = await Promise.all([
this.bookRepo.findById(id), this.bookRepo.findById(id),
this.bookRepo.findRatingByBookAndUser(id, user.id),
this.userBookStatusService.findOne(user.id, id), this.userBookStatusService.findOne(user.id, id),
this.comicMetadataService.findByBookId(id), this.comicMetadataService.findByBookId(id),
this.bookRepo.findCollectionsByBookId(id, user.id), this.bookRepo.findCollectionsByBookId(id, user.id),
@@ -1257,7 +1261,7 @@ export class BookService {
pageCount: meta?.pageCount ?? null, pageCount: meta?.pageCount ?? null,
seriesName: meta?.seriesName ?? null, seriesName: meta?.seriesName ?? null,
seriesIndex: meta?.seriesIndex ?? null, seriesIndex: meta?.seriesIndex ?? null,
rating: meta?.rating ?? null, rating: personalRating,
coverSource: (meta?.coverSource as 'extracted' | 'custom' | null) ?? null, coverSource: (meta?.coverSource as 'extracted' | 'custom' | null) ?? null,
lockedFields: this.bookMetadataLockService.normalizeLockedFields(meta?.lockedFields), lockedFields: this.bookMetadataLockService.normalizeLockedFields(meta?.lockedFields),
providerIds: { providerIds: {
@@ -19,7 +19,6 @@ export const BOOK_WRITE_FIELD_KEYS: readonly BookWritePayloadKey[] = [
'seriesIndex', 'seriesIndex',
'isbn10', 'isbn10',
'isbn13', 'isbn13',
'rating',
'authors', 'authors',
'genres', 'genres',
'tags', 'tags',
@@ -48,7 +48,6 @@ describe('FileWriteRepository', () => {
seriesIndex: 1, seriesIndex: 1,
isbn10: '123', isbn10: '123',
isbn13: '978123', isbn13: '978123',
rating: 8,
googleBooksId: 'g', googleBooksId: 'g',
goodreadsId: 'gr', goodreadsId: 'gr',
amazonId: 'a', amazonId: 'a',
@@ -99,7 +99,6 @@ export class FileWriteRepository {
seriesIndex: meta.seriesIndex, seriesIndex: meta.seriesIndex,
isbn10: meta.isbn10, isbn10: meta.isbn10,
isbn13: meta.isbn13, isbn13: meta.isbn13,
rating: meta.rating,
googleBooksId: meta.googleBooksId, googleBooksId: meta.googleBooksId,
goodreadsId: meta.goodreadsId, goodreadsId: meta.goodreadsId,
amazonId: meta.amazonId, amazonId: meta.amazonId,