mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -7,6 +7,11 @@ on:
|
||||
branches: [main]
|
||||
# Break-glass: bypasses CI/E2E gate. Use only when CI is broken and a hotfix image is urgently needed.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force_rebuild:
|
||||
description: Rebuild even if image for this SHA already exists in GHCR
|
||||
type: boolean
|
||||
default: false
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
types: [completed]
|
||||
@@ -25,63 +30,17 @@ env:
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
image:
|
||||
name: ${{ github.event_name == 'pull_request' && 'Build and Scan Image' || 'Build and Push Image' }}
|
||||
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')
|
||||
# PR path: amd64-only build and scan for fast feedback. No push.
|
||||
scan:
|
||||
name: Build and Scan Image
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
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
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ steps.vars.outputs.checkout_ref }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
@@ -93,36 +52,239 @@ jobs:
|
||||
file: ./Dockerfile
|
||||
push: false
|
||||
load: true
|
||||
tags: ${{ steps.vars.outputs.tags }}
|
||||
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:pr-scan
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-from: type=gha,scope=container-image-yml-pr
|
||||
cache-to: type=gha,mode=max,scope=container-image-yml-pr
|
||||
|
||||
- name: Scan image with Trivy
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
with:
|
||||
image-ref: ${{ steps.vars.outputs.scan_tag }}
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:pr-scan
|
||||
format: table
|
||||
vuln-type: os,library
|
||||
severity: CRITICAL,HIGH
|
||||
ignore-unfixed: true
|
||||
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
|
||||
if: steps.vars.outputs.should_push == 'true'
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Push image
|
||||
if: steps.vars.outputs.should_push == 'true'
|
||||
- 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/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
|
||||
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: |
|
||||
while IFS= read -r tag; do
|
||||
[ -n "$tag" ] || continue
|
||||
docker push "$tag"
|
||||
done <<'EOF'
|
||||
${{ steps.vars.outputs.tags }}
|
||||
EOF
|
||||
FULL_IMAGE="${REGISTRY}/${IMAGE}"
|
||||
docker buildx imagetools create \
|
||||
--tag "${FULL_IMAGE}:sha-${SHA}" \
|
||||
"${FULL_IMAGE}@${AMD64_DIGEST}" \
|
||||
"${FULL_IMAGE}@${ARM64_DIGEST}"
|
||||
|
||||
- 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
@@ -81,9 +81,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
packages: read
|
||||
outputs:
|
||||
published: ${{ steps.semantic.outputs.new_release_published }}
|
||||
version: ${{ steps.semantic.outputs.new_release_version }}
|
||||
@@ -123,6 +125,30 @@ jobs:
|
||||
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
|
||||
|
||||
- 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
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
with:
|
||||
@@ -131,10 +157,23 @@ jobs:
|
||||
|
||||
- name: Run semantic-release
|
||||
id: semantic
|
||||
uses: cycjimmy/semantic-release-action@16ca923e6ccbb50770c415a0ccd43709a8c5f7a4 # v4.2.2
|
||||
shell: bash
|
||||
env:
|
||||
GH_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
|
||||
if: steps.semantic.outputs.new_release_published == 'true'
|
||||
@@ -148,64 +187,22 @@ jobs:
|
||||
echo "- **Version:** ${VERSION}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- **Tag:** ${TAG}" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
docker-publish:
|
||||
name: Publish Docker Image
|
||||
docker-promote:
|
||||
name: Promote Docker Image
|
||||
needs: [release]
|
||||
if: needs.release.outputs.published == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
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:
|
||||
@@ -213,21 +210,97 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Push image
|
||||
- name: Verify SHA image and resolve digest
|
||||
id: source
|
||||
shell: bash
|
||||
env:
|
||||
REGISTRY: ${{ env.REGISTRY }}
|
||||
IMAGE: ${{ env.IMAGE_NAME }}
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
while IFS= read -r tag; do
|
||||
[ -n "$tag" ] || continue
|
||||
docker push "$tag"
|
||||
done <<'EOF'
|
||||
${{ steps.tags.outputs.tags }}
|
||||
EOF
|
||||
FULL_IMAGE="${REGISTRY}/${IMAGE}"
|
||||
SOURCE_TAG="${FULL_IMAGE}:sha-${SHA}"
|
||||
if ! docker buildx imagetools inspect "${SOURCE_TAG}" > /dev/null 2>&1; then
|
||||
echo "::error::Multi-arch image sha-${SHA} not found in GHCR. Wait for the Container Image workflow to complete for this commit before releasing."
|
||||
exit 1
|
||||
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
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ needs.release.outputs.version }}
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
echo "## Docker Image Published" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "## Docker Image Promoted" >> "$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"
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
--> 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" (
|
||||
"user_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_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 "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_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
|
||||
@@ -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_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 "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_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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "2de4dd88-2590-4da4-b868-f9341ffe3e9f",
|
||||
"id": "e9ca331e-b35a-4d80-a54b-28759d1e1c61",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
@@ -5286,6 +5286,104 @@
|
||||
},
|
||||
"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": {
|
||||
"name": "user_book_status",
|
||||
"schema": "",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1777215796833,
|
||||
"when": 1777315276249,
|
||||
"tag": "0000_baseline",
|
||||
"breakpoints": true
|
||||
}
|
||||
|
||||
@@ -42,6 +42,32 @@ export const userBookStatus = pgTable(
|
||||
export type UserBookStatusRow = typeof userBookStatus.$inferSelect;
|
||||
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(
|
||||
'reading_progress',
|
||||
{
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
collectionBooks,
|
||||
collections,
|
||||
readingProgress,
|
||||
userBookRatings,
|
||||
genres,
|
||||
libraries,
|
||||
narrators,
|
||||
@@ -33,7 +34,6 @@ const SORT_FIELD_MAP: Partial<Record<SortField, AnyColumn>> = {
|
||||
updatedAt: books.updatedAt,
|
||||
publishedYear: bookMetadata.publishedYear,
|
||||
pageCount: bookMetadata.pageCount,
|
||||
rating: bookMetadata.rating,
|
||||
publisher: bookMetadata.publisher,
|
||||
};
|
||||
|
||||
@@ -119,6 +119,11 @@ export class BookQueryBuilder {
|
||||
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`,
|
||||
);
|
||||
} 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') {
|
||||
const daySeed = Math.floor(Date.now() / 86_400_000);
|
||||
const scopedSeed = daySeed + (userId ?? 0);
|
||||
@@ -172,7 +177,8 @@ export class BookQueryBuilder {
|
||||
case 'pageCount':
|
||||
return this.numericRuleToSql(bookMetadata.pageCount, operator, value as number, valueTo as number | undefined);
|
||||
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':
|
||||
return this.authorRuleToSql(operator, value as string[]);
|
||||
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 {
|
||||
const existsAuthor = (whereClause?: SQL) => {
|
||||
const predicates: SQL[] = [eq(bookAuthors.bookId, books.id)];
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
narrators,
|
||||
audiobookProgress,
|
||||
readingProgress,
|
||||
userBookRatings,
|
||||
tags,
|
||||
userBookStatus,
|
||||
} from '../../db/schema';
|
||||
@@ -93,12 +94,13 @@ export class BookRepository {
|
||||
seriesIndex: bookMetadata.seriesIndex,
|
||||
publishedYear: bookMetadata.publishedYear,
|
||||
language: bookMetadata.language,
|
||||
rating: bookMetadata.rating,
|
||||
rating: userBookRatings.rating,
|
||||
coverSource: bookMetadata.coverSource,
|
||||
lockedFields: bookMetadata.lockedFields,
|
||||
})
|
||||
.from(books)
|
||||
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
|
||||
.leftJoin(userBookRatings, and(eq(userBookRatings.bookId, books.id), eq(userBookRatings.userId, userId)))
|
||||
.where(where)
|
||||
.orderBy(...orderBy)
|
||||
.limit(limit)
|
||||
@@ -268,7 +270,7 @@ export class BookRepository {
|
||||
book_metadata.series_index,
|
||||
book_metadata.published_year,
|
||||
book_metadata.language,
|
||||
book_metadata.rating,
|
||||
ubr.rating,
|
||||
book_metadata.cover_source,
|
||||
book_metadata.locked_fields,
|
||||
book_metadata.publisher,
|
||||
@@ -280,6 +282,7 @@ export class BookRepository {
|
||||
sc.cover_book_ids
|
||||
FROM books
|
||||
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
|
||||
ON sa.norm_series = NULLIF(lower(btrim(book_metadata.series_name)), '')
|
||||
AND sa.library_id = books.library_id
|
||||
@@ -420,6 +423,15 @@ export class BookRepository {
|
||||
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 }[]> {
|
||||
return this.db
|
||||
.select({ id: collections.id, name: collections.name })
|
||||
@@ -704,9 +716,20 @@ export class BookRepository {
|
||||
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;
|
||||
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[]>> {
|
||||
|
||||
@@ -85,6 +85,7 @@ function makeService() {
|
||||
findTagsByBookIds: vi.fn(),
|
||||
findPrimaryFile: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
findRatingByBookAndUser: vi.fn().mockResolvedValue(null),
|
||||
findCollectionsByBookId: vi.fn(),
|
||||
findKoboReadingState: vi.fn(),
|
||||
findKoboSnapshotState: vi.fn(),
|
||||
@@ -847,11 +848,11 @@ describe('BookService', () => {
|
||||
5,
|
||||
expect.objectContaining({
|
||||
title: null,
|
||||
rating: 4,
|
||||
updatedAt: expect.any(Date),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(bookRepo.bulkSetRating).toHaveBeenCalledWith([5], 4, user.id);
|
||||
expect(metadataService.replaceAuthors).toHaveBeenCalledWith(
|
||||
5,
|
||||
[
|
||||
@@ -1670,7 +1671,7 @@ describe('BookService', () => {
|
||||
|
||||
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(2, 5, 'auto', 42);
|
||||
expect(scoreService.calculateAndSave).toHaveBeenNthCalledWith(1, 3);
|
||||
@@ -1791,6 +1792,7 @@ describe('BookService', () => {
|
||||
userBookStatusService.findOne.mockResolvedValue({ status: 'reading' });
|
||||
comicMetadataService.findByBookId.mockResolvedValue({ issueNumber: '1', teams: ['House Atreides'] });
|
||||
bookRepo.findCollectionsByBookId.mockResolvedValue([{ id: 3, name: 'Favorites' }]);
|
||||
bookRepo.findRatingByBookAndUser.mockResolvedValue(5);
|
||||
|
||||
const result = await service.getDetail(9, user);
|
||||
|
||||
|
||||
@@ -508,7 +508,6 @@ export class BookService {
|
||||
if (dto.seriesIndex !== undefined) scalarFields.seriesIndex = dto.seriesIndex ?? null;
|
||||
if (dto.isbn10 !== undefined) scalarFields.isbn10 = dto.isbn10 ?? 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.goodreadsId !== undefined) scalarFields.goodreadsId = dto.goodreadsId ?? null;
|
||||
if (dto.amazonId !== undefined) scalarFields.amazonId = dto.amazonId ?? null;
|
||||
@@ -565,6 +564,10 @@ export class BookService {
|
||||
|
||||
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.fileWriteService?.scheduleWrite(id, 'auto', user.id);
|
||||
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();
|
||||
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.bookRepo.bulkSetRating(bookIds, rating);
|
||||
await this.bookRepo.bulkSetRating(bookIds, rating, user.id);
|
||||
this.triggerPostMetadataUpdateEffects(bookIds, user.id);
|
||||
this.logger.log(
|
||||
`[${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> {
|
||||
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.findRatingByBookAndUser(id, user.id),
|
||||
this.userBookStatusService.findOne(user.id, id),
|
||||
this.comicMetadataService.findByBookId(id),
|
||||
this.bookRepo.findCollectionsByBookId(id, user.id),
|
||||
@@ -1257,7 +1261,7 @@ export class BookService {
|
||||
pageCount: meta?.pageCount ?? null,
|
||||
seriesName: meta?.seriesName ?? null,
|
||||
seriesIndex: meta?.seriesIndex ?? null,
|
||||
rating: meta?.rating ?? null,
|
||||
rating: personalRating,
|
||||
coverSource: (meta?.coverSource as 'extracted' | 'custom' | null) ?? null,
|
||||
lockedFields: this.bookMetadataLockService.normalizeLockedFields(meta?.lockedFields),
|
||||
providerIds: {
|
||||
|
||||
@@ -19,7 +19,6 @@ export const BOOK_WRITE_FIELD_KEYS: readonly BookWritePayloadKey[] = [
|
||||
'seriesIndex',
|
||||
'isbn10',
|
||||
'isbn13',
|
||||
'rating',
|
||||
'authors',
|
||||
'genres',
|
||||
'tags',
|
||||
|
||||
@@ -48,7 +48,6 @@ describe('FileWriteRepository', () => {
|
||||
seriesIndex: 1,
|
||||
isbn10: '123',
|
||||
isbn13: '978123',
|
||||
rating: 8,
|
||||
googleBooksId: 'g',
|
||||
goodreadsId: 'gr',
|
||||
amazonId: 'a',
|
||||
|
||||
@@ -99,7 +99,6 @@ export class FileWriteRepository {
|
||||
seriesIndex: meta.seriesIndex,
|
||||
isbn10: meta.isbn10,
|
||||
isbn13: meta.isbn13,
|
||||
rating: meta.rating,
|
||||
googleBooksId: meta.googleBooksId,
|
||||
goodreadsId: meta.goodreadsId,
|
||||
amazonId: meta.amazonId,
|
||||
|
||||
Reference in New Issue
Block a user