Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0befa02b8b | ||
|
|
6ee59f1288 | ||
|
|
bd38117279 | ||
|
|
cb02e52a89 | ||
|
|
8c94103041 | ||
|
|
7401305e85 | ||
|
|
d2c408c8ea | ||
|
|
a99d143cdd | ||
|
|
93686a04bb | ||
|
|
1e8168ff40 | ||
|
|
fab1cfea1e | ||
|
|
6215a665d7 | ||
|
|
d1a55d3a40 | ||
|
|
e51885b379 | ||
|
|
ddee690c58 | ||
|
|
158187ac46 | ||
|
|
e50c3de0a9 | ||
|
|
8e7501aec1 | ||
|
|
dbd60d4765 | ||
|
|
629f501e9c | ||
|
|
1df372764f | ||
|
|
5f0fe06bbc | ||
|
|
d34b9f0256 | ||
|
|
e9a9dbf644 | ||
|
|
207410b50e | ||
|
|
63ff3afb41 | ||
|
|
4457260c60 | ||
|
|
41e4b67f1d | ||
|
|
1cb914023c | ||
|
|
f5cf5f1077 | ||
|
|
7fb29d002d |
@@ -96,6 +96,14 @@ configs/
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
|
||||
# Python virtualenvs. Large, platform-specific, and their symlinks break the build.
|
||||
.venv/
|
||||
**/.venv/
|
||||
venv/
|
||||
**/venv/
|
||||
*.egg-info/
|
||||
**/*.egg-info/
|
||||
|
||||
# Local env
|
||||
.env
|
||||
.env.*
|
||||
|
||||
@@ -22,26 +22,15 @@ indent_size = 4
|
||||
|
||||
[*.html]
|
||||
indent_size = 2
|
||||
insert_final_newline = false
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[{*.js,*.jsx,*.mjs,*.ts,*.tsx}]
|
||||
[{*.js,*.jsx,*.mjs,*.ts,*.tsx,*.mts}]
|
||||
indent_size = 2
|
||||
|
||||
[*.css]
|
||||
# CSS files typically use an indent size of 2 spaces for better readability and alignment with community standards.
|
||||
indent_size = 2
|
||||
|
||||
[*.{yml,yaml}]
|
||||
# YAML files use an indent size of 2 spaces to maintain consistency with common YAML formatting practices.
|
||||
indent_size = 2
|
||||
insert_final_newline = false
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.json]
|
||||
# JSON files use an indent size of 2 spaces, which is the standard for JSON formatting.
|
||||
indent_size = 2
|
||||
|
||||
[*.jsonc]
|
||||
# JSONC (JSON with comments) files also follow the standard JSON formatting with an indent size of 2 spaces.
|
||||
[*.{json,jsonc}]
|
||||
indent_size = 2
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
name: Local Java
|
||||
description: Configure the JDK already installed on the GitHub Actions runner
|
||||
inputs:
|
||||
java-version:
|
||||
description: JDK major version
|
||||
required: false
|
||||
default: "25"
|
||||
distribution:
|
||||
description: Java distribution to use
|
||||
required: false
|
||||
default: temurin
|
||||
architecture:
|
||||
description: Target architecture (x64 or arm64)
|
||||
required: false
|
||||
default: ""
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Configure preinstalled JDK
|
||||
shell: bash
|
||||
env:
|
||||
JAVA_VERSION: ${{ inputs.java-version }}
|
||||
JAVA_DISTRIBUTION: ${{ inputs.distribution }}
|
||||
JAVA_ARCHITECTURE: ${{ inputs.architecture }}
|
||||
RUNNER_ARCHITECTURE: ${{ runner.arch }}
|
||||
RUNNER_OS_NAME: ${{ runner.os }}
|
||||
TEMURIN_VERSION: 25.0.4+7
|
||||
TEMURIN_RELEASE_TAG: jdk-25.0.4%2B7
|
||||
TEMURIN_X64_ARCHIVE: OpenJDK25U-jdk_x64_mac_hotspot_25.0.4_7.tar.gz
|
||||
TEMURIN_X64_SHA256: a5ac9c46dad47ac06df35e36d096913195d8da1f3f71918828bcc2cfe33869b7
|
||||
TEMURIN_ARM64_ARCHIVE: OpenJDK25U-jdk_aarch64_mac_hotspot_25.0.4_7.tar.gz
|
||||
TEMURIN_ARM64_SHA256: 5a101c54abf5a9f16c0f70d8c38ba99e6567c1ba213378f0bb04497284f051bd
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "${JAVA_VERSION}" != "25" ]; then
|
||||
echo "This repository requires JDK 25; received ${JAVA_VERSION}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
distribution="$(printf '%s' "${JAVA_DISTRIBUTION}" | tr '[:upper:]' '[:lower:]')"
|
||||
case "${distribution}" in
|
||||
temurin|microsoft) ;;
|
||||
*)
|
||||
echo "Unsupported Java distribution: ${JAVA_DISTRIBUTION}. Supported: temurin, microsoft" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
java_home_is_valid() {
|
||||
candidate="$1"
|
||||
[ -x "${candidate}/bin/java" ] || return 1
|
||||
version_output="$(${candidate}/bin/java -version 2>&1)"
|
||||
printf '%s\n' "${version_output}" | grep -Eq "version \"${JAVA_VERSION}(\\.|\")" || return 1
|
||||
case "${distribution}" in
|
||||
temurin) printf '%s\n' "${version_output}" | grep -Eiq "Temurin|Eclipse Adoptium" ;;
|
||||
microsoft) printf '%s\n' "${version_output}" | grep -Eiq "Microsoft" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
architecture="${JAVA_ARCHITECTURE}"
|
||||
if [ -z "${architecture}" ]; then
|
||||
architecture="${RUNNER_ARCHITECTURE}"
|
||||
fi
|
||||
normalized_architecture="$(printf '%s' "${architecture}" | tr '[:lower:]' '[:upper:]')"
|
||||
case "${normalized_architecture}" in
|
||||
X64|AMD64|X86_64) architecture=X64 ;;
|
||||
ARM64|AARCH64) architecture=ARM64 ;;
|
||||
*) echo "Unsupported runner architecture: ${architecture}" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
runner_architecture="$(printf '%s' "${RUNNER_ARCHITECTURE}" | tr '[:lower:]' '[:upper:]')"
|
||||
if [ "${RUNNER_OS_NAME}" = "macOS" ] && [ -n "${JAVA_HOME:-}" ]; then
|
||||
runner_java_binary="${JAVA_HOME}/bin/java"
|
||||
if [ "${runner_architecture}" = "ARM64" ] && file "${runner_java_binary}" | grep -q "arm64"; then
|
||||
echo "JAVA_HOME_${JAVA_VERSION}_ARM64=${JAVA_HOME}" >> "${GITHUB_ENV}"
|
||||
elif [ "${runner_architecture}" = "X64" ] && file "${runner_java_binary}" | grep -q "x86_64"; then
|
||||
echo "JAVA_HOME_${JAVA_VERSION}_X64=${JAVA_HOME}" >> "${GITHUB_ENV}"
|
||||
fi
|
||||
fi
|
||||
|
||||
java_home_variable="JAVA_HOME_${JAVA_VERSION}_${architecture}"
|
||||
java_home="${!java_home_variable:-}"
|
||||
if [ -z "${java_home}" ] && [ "${RUNNER_OS_NAME}" = "macOS" ]; then
|
||||
if [ "${architecture}" = "${runner_architecture}" ] && [ -n "${JAVA_HOME:-}" ]; then
|
||||
java_home="${JAVA_HOME}"
|
||||
fi
|
||||
fi
|
||||
if [ -z "${java_home}" ] && [ "${RUNNER_OS_NAME}" = "macOS" ] && [ "${architecture}" = "X64" ] && [ "${distribution}" = "temurin" ]; then
|
||||
java_home="$(arch -x86_64 /usr/libexec/java_home -v "${JAVA_VERSION}" 2>/dev/null || true)"
|
||||
fi
|
||||
if [ -n "${java_home}" ] && [ "${RUNNER_OS_NAME}" = "macOS" ]; then
|
||||
java_binary="${java_home}/bin/java"
|
||||
if [ "${architecture}" = "X64" ] && ! file "${java_binary}" | grep -q "x86_64"; then
|
||||
java_home=""
|
||||
elif [ "${architecture}" = "ARM64" ] && ! file "${java_binary}" | grep -q "arm64"; then
|
||||
java_home=""
|
||||
fi
|
||||
fi
|
||||
if [ -n "${java_home}" ] && ! java_home_is_valid "${java_home}"; then
|
||||
echo "Ignoring ${java_home_variable}: it does not provide ${distribution} JDK ${JAVA_VERSION}" >&2
|
||||
java_home=""
|
||||
fi
|
||||
if [ -z "${java_home}" ] && [ "${RUNNER_OS_NAME}" = "macOS" ] && [ "${distribution}" = "temurin" ] && { [ "${architecture}" = "X64" ] || [ "${architecture}" = "ARM64" ]; }; then
|
||||
jdk_version="${TEMURIN_VERSION}"
|
||||
if [ "${architecture}" = "X64" ]; then
|
||||
jdk_archive="${TEMURIN_X64_ARCHIVE}"
|
||||
jdk_sha256="${TEMURIN_X64_SHA256}"
|
||||
else
|
||||
jdk_archive="${TEMURIN_ARM64_ARCHIVE}"
|
||||
jdk_sha256="${TEMURIN_ARM64_SHA256}"
|
||||
fi
|
||||
jdk_root="${RUNNER_TEMP}/stirling-temurin-${jdk_version}-${architecture}"
|
||||
archive_path="${jdk_root}/${jdk_archive}"
|
||||
mkdir -p "${jdk_root}"
|
||||
if [ ! -f "${archive_path}" ]; then
|
||||
curl --fail --location --retry 3 --retry-delay 2 \
|
||||
--output "${archive_path}" \
|
||||
"https://github.com/adoptium/temurin25-binaries/releases/download/${TEMURIN_RELEASE_TAG}/${jdk_archive}"
|
||||
fi
|
||||
printf '%s %s\n' "${jdk_sha256}" "${archive_path}" | shasum -a 256 -c -
|
||||
if [ ! -x "${jdk_root}/Contents/Home/bin/jlink" ]; then
|
||||
rm -rf "${jdk_root}/Contents"
|
||||
tar -xzf "${archive_path}" -C "${jdk_root}"
|
||||
fi
|
||||
java_home="$(find "${jdk_root}" -type d -path '*/Contents/Home' -print -quit)"
|
||||
fi
|
||||
if [ -z "${java_home}" ]; then
|
||||
echo "JDK ${JAVA_VERSION} (${distribution}, ${architecture}) is not installed on this runner (${java_home_variable} is missing)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
shell_java_home="${java_home}"
|
||||
if command -v cygpath >/dev/null 2>&1; then
|
||||
shell_java_home="$(cygpath --unix "${java_home}")"
|
||||
fi
|
||||
|
||||
export JAVA_HOME="${shell_java_home}"
|
||||
export PATH="${shell_java_home}/bin:${PATH}"
|
||||
echo "JAVA_HOME_${JAVA_VERSION}_${architecture}=${java_home}" >> "${GITHUB_ENV}"
|
||||
echo "JAVA_HOME=${java_home}" >> "${GITHUB_ENV}"
|
||||
echo "${java_home}/bin" >> "${GITHUB_PATH}"
|
||||
java -version 2>&1 | tee "${RUNNER_TEMP}/java-version.txt"
|
||||
case "${distribution}" in
|
||||
temurin) grep -Eiq "Temurin|Eclipse Adoptium" "${RUNNER_TEMP}/java-version.txt" ;;
|
||||
microsoft) grep -Eiq "Microsoft" "${RUNNER_TEMP}/java-version.txt" ;;
|
||||
esac
|
||||
grep -Eq "version \"${JAVA_VERSION}(\.|\")" "${RUNNER_TEMP}/java-version.txt"
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
pr_ref: ${{ steps.resolve.outputs.ref }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -121,7 +121,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -475,7 +475,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
enable_prototypes: ${{ steps.check-prototypes-flag.outputs.enable_prototypes }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -177,7 +177,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -200,10 +200,9 @@ jobs:
|
||||
key: gradle-deploy-pr-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: ./.github/actions/java
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
@@ -482,7 +481,7 @@ jobs:
|
||||
issues: write # add/remove labels, delete the command comment
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
is_fork: ${{ steps.decide.outputs.is_fork }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
issues: write # labels are applied through the issues API
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
flavor: [core, proprietary, saas]
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
@@ -46,10 +46,9 @@ jobs:
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK ${{ matrix.jdk-version }}
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: ./.github/actions/java
|
||||
with:
|
||||
java-version: ${{ matrix.jdk-version }}
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
@@ -197,7 +196,7 @@ jobs:
|
||||
|
||||
- name: Install uv
|
||||
if: always() && matrix.flavor == 'saas'
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
SYSTEM_ENABLEANALYTICS: "false"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
@@ -83,10 +83,9 @@ jobs:
|
||||
key: gradle-playwright-e2e-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: ./.github/actions/java
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
@@ -351,13 +350,13 @@ jobs:
|
||||
MN_COMPOSE: docker-compose-multinode.yml
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
proprietary: ${{ steps.changes.outputs.proprietary }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
@@ -62,6 +62,7 @@ jobs:
|
||||
filters: .github/config/.files.yaml
|
||||
|
||||
gradle-cache-prime:
|
||||
if: needs.files-changed.outputs.backend == 'true'
|
||||
needs: [files-changed]
|
||||
uses: ./.github/workflows/gradle-cache-prime.yml
|
||||
secrets: inherit
|
||||
@@ -166,7 +167,7 @@ jobs:
|
||||
|
||||
test-build-docker-images:
|
||||
if: |
|
||||
always() &&
|
||||
!cancelled() &&
|
||||
github.event_name == 'pull_request' &&
|
||||
needs.files-changed.outputs.project == 'true' &&
|
||||
contains(fromJSON('["success", "skipped"]'), needs.gradle-cache-prime.result) &&
|
||||
@@ -254,7 +255,7 @@ jobs:
|
||||
# for whatever did record. Advisory only - intentionally NOT in
|
||||
# all-checks-passed, so a flaky aggregate run never blocks merging.
|
||||
coverage-aggregate:
|
||||
if: always()
|
||||
if: ${{!cancelled()}}
|
||||
needs:
|
||||
- build
|
||||
- playwright-e2e-live
|
||||
@@ -298,7 +299,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
@@ -63,10 +63,9 @@ jobs:
|
||||
key: gradle-generated-models-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: ./.github/actions/java
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -32,10 +32,9 @@ jobs:
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: ./.github/actions/java
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -33,10 +33,9 @@ jobs:
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: ./.github/actions/java
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
pull-requests: write # Allow writing to pull requests
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -195,7 +195,7 @@ jobs:
|
||||
core.exportVariable("REFERENCE_FILE", referenceFilePath);
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
@@ -49,13 +49,12 @@ jobs:
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: ./.github/actions/java
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -36,10 +36,9 @@ jobs:
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: ./.github/actions/java
|
||||
with:
|
||||
java-version: 25
|
||||
distribution: temurin
|
||||
|
||||
# Keep the normal formatting path here so this smoke test exercises the
|
||||
# same Gradle configuration as the backend build.
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -44,10 +44,9 @@ jobs:
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: ./.github/actions/java
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
# When the PR changes the base image, test.sh builds it locally
|
||||
# (stirling-pdf-base:local) into the daemon image store. A buildx
|
||||
@@ -70,7 +69,7 @@ jobs:
|
||||
sudo chmod +x /usr/local/bin/docker-compose
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
@@ -33,10 +33,9 @@ jobs:
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: ./.github/actions/java
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
@@ -106,7 +105,7 @@ jobs:
|
||||
fi
|
||||
- name: Install uv
|
||||
if: always()
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
project: stubbed-webkit
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
licenses-backend: ${{ steps.changes.outputs.licenses-backend }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -166,7 +166,7 @@ jobs:
|
||||
});
|
||||
|
||||
// Filter for license check comments
|
||||
const licenseComments = comments.filter(comment =>
|
||||
const licenseComments = comments.filter(comment =>
|
||||
comment.body.includes('## ✅ Frontend License Check Passed') ||
|
||||
comment.body.includes('## ❌ Frontend License Check Failed')
|
||||
);
|
||||
@@ -334,7 +334,7 @@ jobs:
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -361,10 +361,9 @@ jobs:
|
||||
key: gradle-license-report-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: ./.github/actions/java
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
@@ -418,7 +417,7 @@ jobs:
|
||||
per_page: 100
|
||||
});
|
||||
|
||||
const backendLicenseComments = comments.filter(comment =>
|
||||
const backendLicenseComments = comments.filter(comment =>
|
||||
comment.body.includes('## ✅ Backend License Check Passed') ||
|
||||
comment.body.includes('## ❌ Backend License Check Failed')
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
}
|
||||
- name: Install uv
|
||||
if: always()
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
@@ -42,10 +42,9 @@ jobs:
|
||||
|
||||
- name: Set up JDK 25
|
||||
if: steps.cache-gradle-restore.outputs.cache-hit != 'true'
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: ./.github/actions/java
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Resolve backend dependencies
|
||||
if: steps.cache-gradle-restore.outputs.cache-hit != 'true'
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
version: ${{ steps.versionNumber.outputs.versionNumber }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -63,10 +63,9 @@ jobs:
|
||||
key: gradle-tauri-releases-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: ./.github/actions/java
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
@@ -140,7 +139,7 @@ jobs:
|
||||
file_suffix: "-server"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -155,10 +154,9 @@ jobs:
|
||||
key: gradle-tauri-releases-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: ./.github/actions/java
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Node.js
|
||||
if: matrix.variant.build_frontend == true
|
||||
@@ -207,7 +205,7 @@ jobs:
|
||||
RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
allowed-endpoints: >
|
||||
@@ -247,13 +245,12 @@ jobs:
|
||||
# x86_64 JDK is set up first so the aarch64 step below can leave its
|
||||
# JAVA_HOME as the active one. The macOS universal JRE build needs
|
||||
# jmods from both arches; the x64 path is captured into the env
|
||||
# before the second setup-java overwrites JAVA_HOME.
|
||||
# before the second local JDK setup overwrites JAVA_HOME.
|
||||
- name: Set up x86_64 JDK 25 (macOS universal JRE)
|
||||
if: matrix.platform == 'macos-15'
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: ./.github/actions/java
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
architecture: "x64"
|
||||
|
||||
- name: Capture x86_64 JAVA_HOME
|
||||
@@ -262,7 +259,7 @@ jobs:
|
||||
|
||||
# Temurin has no windows-aarch64 JDK 25 yet; Microsoft OpenJDK does.
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: ./.github/actions/java
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
|
||||
@@ -715,7 +712,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -872,7 +869,7 @@ jobs:
|
||||
# Gate publish on valid updater sigs. Runs after the review upload (so
|
||||
# artifacts survive for debugging) and before action-gh-release.
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -140,7 +140,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -148,13 +148,12 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: ./.github/actions/java
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
pull-requests: write # pulls.get/list plus add/remove the label on PRs
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -18,6 +18,16 @@ on:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
build_engine:
|
||||
description: "Build & push the standalone stirling-engine image."
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
force_engine_rebuild:
|
||||
description: "Rebuild stirling-engine even if its source hash is unchanged."
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
push:
|
||||
branches:
|
||||
- release
|
||||
@@ -50,9 +60,10 @@ jobs:
|
||||
env:
|
||||
RUN_MAIN_APP: ${{ github.event_name != 'workflow_dispatch' || inputs.build_main_app }}
|
||||
RUN_UNOSERVER: ${{ github.event_name != 'workflow_dispatch' || inputs.build_unoserver }}
|
||||
RUN_ENGINE: ${{ github.event_name != 'workflow_dispatch' || inputs.build_engine }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -67,10 +78,9 @@ jobs:
|
||||
key: gradle-push-docker-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: ./.github/actions/java
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
@@ -387,3 +397,119 @@ jobs:
|
||||
else
|
||||
echo "Warning: COSIGN_PRIVATE_KEY not set, skipping unoserver image signing"
|
||||
fi
|
||||
|
||||
# Standalone AI engine image, same shape as the unoserver image above.
|
||||
- name: Compute engine image source hash
|
||||
id: engineHash
|
||||
if: env.RUN_ENGINE == 'true'
|
||||
run: |
|
||||
set -eu
|
||||
hash=$( { cat engine/Dockerfile engine/pyproject.toml engine/uv.lock engine/.env; \
|
||||
find engine/src -type f -print0 | sort -z | xargs -0 cat; } \
|
||||
| sha256sum | cut -d' ' -f1)
|
||||
echo "hash=${hash}" >> "$GITHUB_OUTPUT"
|
||||
echo "Engine source hash: ${hash}"
|
||||
|
||||
- name: Decide whether to publish engine image
|
||||
id: engineDecision
|
||||
if: env.RUN_ENGINE == 'true'
|
||||
env:
|
||||
ENGINE_VERSION: ${{ steps.versionNumber.outputs.versionNumber }}
|
||||
ENGINE_HASH: ${{ steps.engineHash.outputs.hash }}
|
||||
ENGINE_IMAGE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-engine
|
||||
ENGINE_HASH_ANNOTATION: org.stirlingpdf.engine-source-hash
|
||||
FORCE_REBUILD: ${{ inputs.force_engine_rebuild }}
|
||||
GH_REF: ${{ github.ref }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
set -eu
|
||||
mode="skip"
|
||||
tags=""
|
||||
|
||||
read_published_hash() {
|
||||
local ref="$1"
|
||||
docker buildx imagetools inspect "$ref" --raw 2>/dev/null \
|
||||
| jq -r --arg key "$ENGINE_HASH_ANNOTATION" \
|
||||
'.annotations[$key] // empty' \
|
||||
2>/dev/null || true
|
||||
}
|
||||
|
||||
# Manual dispatch from any branch routes to the :alpha publish path.
|
||||
EFFECTIVE_REF="$GH_REF"
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
|
||||
EFFECTIVE_REF="refs/heads/testMain"
|
||||
fi
|
||||
|
||||
case "$EFFECTIVE_REF" in
|
||||
refs/heads/release)
|
||||
if [ "${FORCE_REBUILD}" = "true" ]; then
|
||||
echo "force_engine_rebuild=true — building stable regardless"
|
||||
mode="stable"
|
||||
tags="${ENGINE_IMAGE}:${ENGINE_VERSION},${ENGINE_IMAGE}:latest"
|
||||
elif docker manifest inspect "${ENGINE_IMAGE}:${ENGINE_VERSION}" >/dev/null 2>&1; then
|
||||
echo "stirling-engine:${ENGINE_VERSION} already on GHCR — skipping"
|
||||
else
|
||||
echo "stirling-engine:${ENGINE_VERSION} is new — will publish"
|
||||
mode="stable"
|
||||
tags="${ENGINE_IMAGE}:${ENGINE_VERSION},${ENGINE_IMAGE}:latest"
|
||||
fi
|
||||
;;
|
||||
refs/heads/main|refs/heads/testMain)
|
||||
published_hash=$(read_published_hash "${ENGINE_IMAGE}:alpha")
|
||||
if [ "${FORCE_REBUILD}" = "true" ]; then
|
||||
echo "force_engine_rebuild=true — rebuilding :alpha regardless"
|
||||
mode="alpha"
|
||||
tags="${ENGINE_IMAGE}:alpha"
|
||||
elif [ -n "$published_hash" ] && [ "$published_hash" = "$ENGINE_HASH" ]; then
|
||||
echo "Published :alpha source hash matches (${published_hash}) — skipping"
|
||||
else
|
||||
if [ -z "$published_hash" ]; then
|
||||
echo ":alpha has no source-hash annotation (first publish) — will publish"
|
||||
else
|
||||
echo "Source hash changed (was ${published_hash}, now ${ENGINE_HASH}) — will publish"
|
||||
fi
|
||||
mode="alpha"
|
||||
tags="${ENGINE_IMAGE}:alpha"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "Branch ${GH_REF} does not publish engine image"
|
||||
;;
|
||||
esac
|
||||
echo "mode=${mode}" >> "$GITHUB_OUTPUT"
|
||||
echo "tags=${tags}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and push engine image
|
||||
id: build-push-engine
|
||||
if: env.RUN_ENGINE == 'true' && steps.engineDecision.outputs.mode != 'skip'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./engine/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha,scope=stirling-engine
|
||||
cache-to: type=gha,mode=max,scope=stirling-engine
|
||||
tags: ${{ steps.engineDecision.outputs.tags }}
|
||||
# Manifest annotation read by the decision step above to detect drift.
|
||||
annotations: |
|
||||
index:org.stirlingpdf.engine-source-hash=${{ steps.engineHash.outputs.hash }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Sign engine image
|
||||
if: env.RUN_ENGINE == 'true' && steps.engineDecision.outputs.mode == 'stable'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-engine.outputs.digest }}
|
||||
TAGS: ${{ steps.engineDecision.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
if [ -n "$COSIGN_PRIVATE_KEY" ]; then
|
||||
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
|
||||
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
|
||||
done
|
||||
else
|
||||
echo "Warning: COSIGN_PRIVATE_KEY not set, skipping engine image signing"
|
||||
fi
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -45,10 +45,9 @@ jobs:
|
||||
key: gradle-swagger-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: ./.github/actions/java
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Generate Swagger documentation
|
||||
run: ./gradlew :stirling-pdf:generateOpenApiDocs
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
SIGN_BUNDLE: ${{ inputs.sign && (matrix.platform == 'macos-15' && secrets.APPLE_CERTIFICATE != '' || github.ref == 'refs/heads/main') }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -194,10 +194,9 @@ jobs:
|
||||
|
||||
- name: Set up x86_64 JDK 25 (macOS universal JRE)
|
||||
if: matrix.platform == 'macos-15'
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: ./.github/actions/java
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
architecture: "x64"
|
||||
|
||||
- name: Capture x86_64 JAVA_HOME
|
||||
@@ -206,7 +205,7 @@ jobs:
|
||||
|
||||
# Temurin has no windows-aarch64 JDK 25 yet; Microsoft OpenJDK does.
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: ./.github/actions/java
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
|
||||
@@ -703,7 +702,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -795,7 +794,7 @@ jobs:
|
||||
if: always()
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
cache-scope: stirling-pdf-fat
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -121,10 +121,9 @@ jobs:
|
||||
key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'buildSrc/**', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }}
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: ./.github/actions/java
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
@@ -217,7 +216,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -27,9 +27,8 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: ./.github/actions/java
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "25"
|
||||
|
||||
- name: Find latest Gradle release
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"ignoredFiles": [
|
||||
"frontend/editor/src-tauri/icons/icon.png"
|
||||
"frontend/editor/src-tauri/icons/macos/*",
|
||||
"frontend/editor/src-tauri/icons/linux/*",
|
||||
"frontend/editor/src-tauri/icons/windows/*"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -375,13 +375,13 @@ tasks:
|
||||
desc: "Auto-fix code formatting"
|
||||
deps: [install]
|
||||
cmds:
|
||||
- npx prettier --write .
|
||||
- npx oxfmt --write .
|
||||
|
||||
format:check:
|
||||
desc: "Check code formatting"
|
||||
deps: [install]
|
||||
cmds:
|
||||
- npx prettier --check .
|
||||
- npx oxfmt --check .
|
||||
|
||||
fix:
|
||||
desc: "Auto-fix lint and format"
|
||||
@@ -554,6 +554,7 @@ tasks:
|
||||
deps: [install, ":backend:swagger"]
|
||||
cmds:
|
||||
- npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --io-output editor/src/core/types/toolIO.ts
|
||||
- task: format
|
||||
sources:
|
||||
- editor/scripts/generate-tool-api-types.mts
|
||||
- ../SwaggerDoc.json
|
||||
@@ -563,9 +564,9 @@ tasks:
|
||||
|
||||
tool-models:check:
|
||||
desc: "Fail if committed tool API types are out of date"
|
||||
deps: [install, ":backend:swagger"]
|
||||
cmds:
|
||||
- npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --io-output editor/src/core/types/toolIO.ts --check
|
||||
- task: tool-models
|
||||
- git diff --exit-code -- editor/src/core/types/toolApiTypes.ts editor/src/core/types/toolIO.ts
|
||||
|
||||
licenses:generate:
|
||||
desc: "Generate frontend license report"
|
||||
|
||||
@@ -18,11 +18,11 @@ dependencies {
|
||||
api "org.apache.pdfbox:preflight:$pdfboxVersion"
|
||||
api 'com.github.junrar:junrar:8.0.0' // RAR archive support for CBR files
|
||||
api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
|
||||
api 'org.snakeyaml:snakeyaml-engine:3.0.1'
|
||||
api 'org.snakeyaml:snakeyaml-engine:3.1.1'
|
||||
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3"
|
||||
// Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage)
|
||||
api 'org.simplejavamail:simple-java-mail:9.3.1'
|
||||
api 'org.simplejavamail:outlook-module:9.3.1' // MSG file support
|
||||
api 'org.simplejavamail:simple-java-mail:9.3.2'
|
||||
api 'org.simplejavamail:outlook-module:9.3.2' // MSG file support
|
||||
api 'jakarta.mail:jakarta.mail-api:2.1.5'
|
||||
runtimeOnly 'org.eclipse.angus:angus-mail:2.0.5'
|
||||
|
||||
|
||||
@@ -4,7 +4,14 @@ import java.util.regex.Pattern;
|
||||
|
||||
public class RequestUriUtils {
|
||||
|
||||
private static final Pattern SHARE_LINK_PATTERN = Pattern.compile("^/share/[^/]+/?$");
|
||||
// Share tokens are 36-char lowercase UUIDs (UUID.randomUUID().toString()); match exactly
|
||||
private static final Pattern SHARE_LINK_PATTERN =
|
||||
Pattern.compile(
|
||||
"^/share/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/?$");
|
||||
// Invite tokens are 36-char lowercase UUIDs (UUID.randomUUID().toString()); match exactly
|
||||
private static final Pattern INVITE_LINK_PATTERN =
|
||||
Pattern.compile(
|
||||
"^/invite/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/?$");
|
||||
|
||||
public static boolean isStaticResource(String requestURI) {
|
||||
return isStaticResource("", requestURI);
|
||||
@@ -69,7 +76,7 @@ public class RequestUriUtils {
|
||||
// cookie, so the server can't authenticate the navigation itself). The
|
||||
// portal gates access via its own auth gate + RequirePortalAccess, and its
|
||||
// data APIs stay protected, so serving the shell pre-auth is safe.
|
||||
if (normalizedUri.equals("/processor") || normalizedUri.startsWith("/processor/")) {
|
||||
if ("/processor".equals(normalizedUri) || normalizedUri.startsWith("/processor/")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -209,7 +216,9 @@ public class RequestUriUtils {
|
||||
// Workflow participant endpoints - access controlled by share tokens, not login
|
||||
|| trimmedUri.startsWith("/api/v1/workflow/participant/")
|
||||
// Share-link SPA bootstrap; data APIs remain protected
|
||||
|| SHARE_LINK_PATTERN.matcher(trimmedUri).matches();
|
||||
|| SHARE_LINK_PATTERN.matcher(trimmedUri).matches()
|
||||
// Invite-accept SPA bootstrap; data APIs remain protected
|
||||
|| INVITE_LINK_PATTERN.matcher(trimmedUri).matches();
|
||||
}
|
||||
|
||||
private static String stripContextPath(String contextPath, String requestURI) {
|
||||
|
||||
@@ -206,12 +206,24 @@ class RequestUriUtilsTest {
|
||||
|
||||
@Test
|
||||
void testIsPublicAuthEndpoint_shareLinkTokenTrailingSlash() {
|
||||
assertTrue(RequestUriUtils.isPublicAuthEndpoint("/share/abc123/", ""));
|
||||
assertTrue(
|
||||
RequestUriUtils.isPublicAuthEndpoint(
|
||||
"/share/00dcac3a-fc7a-4989-9c4f-97745484d62f/", ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsPublicAuthEndpoint_shareLinkWithContextPath() {
|
||||
assertTrue(RequestUriUtils.isPublicAuthEndpoint("/app/share/abc123", "/app"));
|
||||
assertTrue(
|
||||
RequestUriUtils.isPublicAuthEndpoint(
|
||||
"/app/share/00dcac3a-fc7a-4989-9c4f-97745484d62f", "/app"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsPublicAuthEndpoint_shareLinkWithInvalidTokenLength() {
|
||||
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/share/abc123", ""));
|
||||
assertFalse(
|
||||
RequestUriUtils.isPublicAuthEndpoint(
|
||||
"/share/00dcac3a-fc7a-4989-9c4f-97745484d62fa", ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -236,4 +248,86 @@ class RequestUriUtilsTest {
|
||||
RequestUriUtils.isPublicAuthEndpoint(
|
||||
"/api/v1/storage/share-links/abc123/metadata", ""));
|
||||
}
|
||||
|
||||
// --- invite-accept SPA bootstrap ---
|
||||
|
||||
private static final String INVITE_TOKEN = "06a20e7e-2e35-4e26-be7d-2dce14f28f12";
|
||||
|
||||
@Test
|
||||
void testIsPublicAuthEndpoint_inviteLinkToken() {
|
||||
assertTrue(RequestUriUtils.isPublicAuthEndpoint("/invite/" + INVITE_TOKEN, ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsPublicAuthEndpoint_inviteLinkTokenTrailingSlash() {
|
||||
assertTrue(RequestUriUtils.isPublicAuthEndpoint("/invite/" + INVITE_TOKEN + "/", ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsPublicAuthEndpoint_inviteLinkWithContextPath() {
|
||||
assertTrue(RequestUriUtils.isPublicAuthEndpoint("/app/invite/" + INVITE_TOKEN, "/app"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsPublicAuthEndpoint_inviteRootNotPublic() {
|
||||
// Avoid matching bare "/invite" or "/invite/" - must have a token segment
|
||||
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite", ""));
|
||||
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite/", ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsPublicAuthEndpoint_inviteNestedPathNotPublic() {
|
||||
// Guard against future additions like /invite/<token>/foo becoming accidentally public
|
||||
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite/" + INVITE_TOKEN + "/foo", ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsPublicAuthEndpoint_invitePrefixDoesNotOvermatch() {
|
||||
// "/inviteX" must not match the invite pattern
|
||||
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/inviteX", ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsPublicAuthEndpoint_inviteNonUuidTokenNotPublic() {
|
||||
// Only exactly-shaped 36-char lowercase UUID tokens are treated as invite links
|
||||
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite/abc123", ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsPublicAuthEndpoint_inviteUppercaseUuidNotPublic() {
|
||||
// Tokens are generated lowercase by UUID.randomUUID().toString()
|
||||
assertFalse(
|
||||
RequestUriUtils.isPublicAuthEndpoint(
|
||||
"/invite/06A20E7E-2E35-4E26-BE7D-2DCE14F28F12", ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsPublicAuthEndpoint_inviteWrongLengthNotPublic() {
|
||||
// 35-char and 37-char UUID-like tokens are not valid UUIDs
|
||||
assertFalse(
|
||||
RequestUriUtils.isPublicAuthEndpoint(
|
||||
"/invite/06a20e7e-2e35-4e26-be7d-2dce14f28f1", ""));
|
||||
assertFalse(
|
||||
RequestUriUtils.isPublicAuthEndpoint(
|
||||
"/invite/06a20e7e-2e35-4e26-be7d-2dce14f28f122", ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsPublicAuthEndpoint_inviteWrongGroupingNotPublic() {
|
||||
// Groups of 8-4-4-4-4 must not be shifted around (e.g. 4-4-4-4-8)
|
||||
assertFalse(
|
||||
RequestUriUtils.isPublicAuthEndpoint(
|
||||
"/invite/06a2-0e7e-2e35-4e26-be7d2dce14f28f12", ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsPublicAuthEndpoint_inviteTokenInvalidCharsNotPublic() {
|
||||
// Hex-only; anything outside [0-9a-f] or the UUID hyphens is rejected
|
||||
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite/abc$123", ""));
|
||||
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite/abc..123", ""));
|
||||
assertFalse(RequestUriUtils.isPublicAuthEndpoint("/invite/abc%2F123", ""));
|
||||
assertFalse(
|
||||
RequestUriUtils.isPublicAuthEndpoint(
|
||||
"/invite/06a20e7e-2e35-4e26-be7d-2dce14f28f1g", ""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,24 +91,32 @@ public class ControllerAuditAspect {
|
||||
MethodSignature sig = (MethodSignature) joinPoint.getSignature();
|
||||
Method method = sig.getMethod();
|
||||
|
||||
// Fast path: check if auditing is enabled before doing any work
|
||||
// This avoids all data collection if auditing is disabled
|
||||
if (!auditService.shouldAudit(method, auditConfig)) {
|
||||
// Resolve the event type up front so the enterprise gate can be type-aware: document
|
||||
// processing events (the Documents tab's data source) are audited without an Enterprise
|
||||
// license, while the rest of the audit log stays Enterprise-only. resolveEventType is cheap
|
||||
// (annotation / class / path checks), so it's safe on the pre-record fast path.
|
||||
Audited auditedAnnotation = method.getAnnotation(Audited.class);
|
||||
String path = getRequestPath(method, httpMethod);
|
||||
AuditEventType eventType =
|
||||
auditService.resolveEventType(
|
||||
method,
|
||||
joinPoint.getTarget().getClass(),
|
||||
path,
|
||||
httpMethod,
|
||||
auditedAnnotation);
|
||||
|
||||
// Fast path: skip all data collection when this event won't be recorded.
|
||||
if (!auditService.shouldAudit(eventType, method, auditConfig)) {
|
||||
return joinPoint.proceed();
|
||||
}
|
||||
|
||||
// Check if method is explicitly annotated with @Audited
|
||||
Audited auditedAnnotation = method.getAnnotation(Audited.class);
|
||||
AuditLevel level = auditConfig.getAuditLevel();
|
||||
|
||||
// If @Audited annotation is present, respect its level setting
|
||||
if (auditedAnnotation != null) {
|
||||
// Use the level from annotation if it's stricter than global level
|
||||
level = auditedAnnotation.level();
|
||||
}
|
||||
|
||||
String path = getRequestPath(method, httpMethod);
|
||||
|
||||
// Skip static GET resources
|
||||
if ("GET".equals(httpMethod)) {
|
||||
HttpServletRequest maybe = auditService.getCurrentRequest();
|
||||
@@ -209,15 +217,6 @@ public class ControllerAuditAspect {
|
||||
// the body ran, so it must happen here rather than with the pre-proceed HTTP data).
|
||||
auditService.addAutomationContext(data, req);
|
||||
|
||||
// Resolve the event type using the unified method
|
||||
AuditEventType eventType =
|
||||
auditService.resolveEventType(
|
||||
method,
|
||||
joinPoint.getTarget().getClass(),
|
||||
path,
|
||||
httpMethod,
|
||||
auditedAnnotation);
|
||||
|
||||
// Add result only if operation result capture is explicitly enabled
|
||||
// Skip result for UI_DATA events to avoid storing large response bodies
|
||||
if (auditService.shouldCaptureOperationResults()
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package stirling.software.proprietary.audit;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Self-hosted default: any portal user sees the whole-server documents queue. */
|
||||
@Component
|
||||
public class DefaultPortalDocumentsScopeResolver implements PortalDocumentsScopeResolver {
|
||||
|
||||
@Override
|
||||
public PortalAuditScope resolve() {
|
||||
return PortalAuditScope.server();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package stirling.software.proprietary.audit;
|
||||
|
||||
/** Resolves which slice of the documents queue a portal user may see. */
|
||||
public interface PortalDocumentsScopeResolver {
|
||||
|
||||
PortalAuditScope resolve();
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
@@ -20,12 +21,13 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.tool.ToolFormat;
|
||||
import stirling.software.common.model.tool.ToolIO;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.PdfMetadataService;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
@@ -48,11 +50,13 @@ import tools.jackson.databind.node.ObjectNode;
|
||||
* <p>Runs as a Classification-policy pipeline step: it reads a bounded page window, asks the AI
|
||||
* engine to classify the document against the built-in label set, and stores the engine's JSON
|
||||
* answer — minus the transport-only {@code outcome} field — in the custom Info-dictionary key
|
||||
* {@link PdfMetadataService#CLASSIFICATION_KEY}. Returns the labelled PDF. Not intended for direct
|
||||
* client use.
|
||||
* {@link PdfMetadataService#CLASSIFICATION_KEY}. Returns the labelled PDF.
|
||||
*
|
||||
* <p>Published in the API spec rather than hidden, so the tool-model generator emits it and a
|
||||
* pipeline can name it as a step like any other tool. Classification is a thing a pipeline does,
|
||||
* not a thing only the Classification policy may do.
|
||||
*/
|
||||
@Slf4j
|
||||
@Hidden
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/ai/tools")
|
||||
@Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.")
|
||||
@@ -99,19 +103,31 @@ public class ClassifyLabelController {
|
||||
}
|
||||
|
||||
@PostMapping(value = "/classify-and-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
// PDF in, the same PDF out with a verdict on it, so a chain can be checked across this step.
|
||||
@ToolIO(accepts = ToolFormat.PDF, produces = ToolFormat.PDF)
|
||||
@Operation(
|
||||
summary = "Classify a PDF and label its metadata",
|
||||
description =
|
||||
"Reads the first two and last two pages, classifies the document via the AI"
|
||||
+ " engine, and stores the result in the StirlingPDFClassification"
|
||||
+ " metadata field. Dispatched by the Classification policy; not"
|
||||
+ " intended for direct client use.")
|
||||
+ " metadata field. A document that already carries a verdict is"
|
||||
+ " passed through untouched unless reclassify=true.")
|
||||
public ResponseEntity<Resource> classifyAndLabel(
|
||||
@RequestParam("fileInput") MultipartFile fileInput) throws IOException {
|
||||
@RequestParam("fileInput") MultipartFile fileInput,
|
||||
@RequestParam(value = "reclassify", defaultValue = "false") boolean reclassify)
|
||||
throws IOException {
|
||||
aiFeatureGate.requireClassify();
|
||||
try (PDDocument document = pdfDocumentFactory.load(fileInput, true)) {
|
||||
String fileName = safeFileName(fileInput.getOriginalFilename());
|
||||
|
||||
if (!reclassify && isClassified(document)) {
|
||||
// Classifying twice costs a second engine call and charges for it, and a document
|
||||
// that already carries a verdict has nothing new to learn. A pipeline can run this
|
||||
// step over a mixed batch without paying for the ones already done.
|
||||
log.debug("[classify-and-label] {} already classified; passing through", fileName);
|
||||
return WebResponseUtils.pdfDocToWebResponse(document, fileName, tempFileManager);
|
||||
}
|
||||
|
||||
List<EngineLabel> allowed = resolveAllowedLabels();
|
||||
if (allowed.isEmpty()) {
|
||||
// No vocabulary to classify against: pass the file through unlabelled rather than
|
||||
@@ -135,6 +151,25 @@ public class ClassifyLabelController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a verdict is already on the document.
|
||||
*
|
||||
* <p>This only reads back what a previous run of this step wrote. It is not a statement that
|
||||
* the verdict is trustworthy: the key is ordinary PDF metadata that whoever supplied the file
|
||||
* can set. Skipping the engine on the strength of it is safe because the cost of being wrong is
|
||||
* a missing re-classification, not a wrong decision. Anything that makes a SECURITY decision
|
||||
* from this field - routing a document somewhere on the strength of its label, say - must
|
||||
* classify with {@code reclassify=true} rather than trust what arrived.
|
||||
*/
|
||||
private static boolean isClassified(PDDocument document) {
|
||||
PDDocumentInformation info = document.getDocumentInformation();
|
||||
if (info == null) {
|
||||
return false;
|
||||
}
|
||||
String existing = info.getCustomMetadataValue(PdfMetadataService.CLASSIFICATION_KEY);
|
||||
return existing != null && !existing.isBlank();
|
||||
}
|
||||
|
||||
private List<AiPageText> extractWindow(PDDocument document) throws IOException {
|
||||
List<AiPageText> pages = new ArrayList<>();
|
||||
for (int pageNumber : windowPageNumbers(document.getNumberOfPages(), WINDOW_PAGES)) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package stirling.software.proprietary.controller.api;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
@@ -11,19 +12,25 @@ import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.annotations.api.ProprietaryUiDataApi;
|
||||
import stirling.software.proprietary.audit.PortalAuditScope;
|
||||
import stirling.software.proprietary.audit.PortalAuditScopeResolver;
|
||||
import stirling.software.proprietary.audit.PortalDocumentsScopeResolver;
|
||||
import stirling.software.proprietary.model.api.documents.PortalDocumentsResponseDto;
|
||||
import stirling.software.proprietary.security.config.EnterpriseEndpoint;
|
||||
import stirling.software.proprietary.service.PortalDocumentsService;
|
||||
|
||||
/** Serves the portal Documents review queue, derived from real audit data and scoped per caller. */
|
||||
/**
|
||||
* Serves the portal Documents review queue, derived from real audit data and scoped per caller.
|
||||
*
|
||||
* <p>Open to every portal user (not Enterprise-gated): the Documents tab is a core Processor
|
||||
* feature. Access is enforced by {@code @resourceAccess.canUsePortal()}; visibility is then
|
||||
* resolved per deployment - self-hosted portal users see the whole server, SaaS users see their
|
||||
* team (see {@link PortalDocumentsScopeResolver}).
|
||||
*/
|
||||
@ProprietaryUiDataApi
|
||||
@RequiredArgsConstructor
|
||||
@EnterpriseEndpoint
|
||||
@PreAuthorize("@resourceAccess.canUsePortal()")
|
||||
public class PortalDocumentsController {
|
||||
|
||||
private final PortalDocumentsService portalDocumentsService;
|
||||
private final PortalAuditScopeResolver auditScopeResolver;
|
||||
private final PortalDocumentsScopeResolver documentsScopeResolver;
|
||||
|
||||
// tier accepted for mock-seam symmetry; ignored (queue isn't tier-scoped).
|
||||
@GetMapping("/documents")
|
||||
@@ -32,8 +39,9 @@ public class PortalDocumentsController {
|
||||
description = "Files processed through the org, derived from the audit trail.")
|
||||
public ResponseEntity<PortalDocumentsResponseDto> getDocuments(
|
||||
@RequestParam(value = "tier", required = false) String tier) {
|
||||
PortalAuditScope scope = auditScopeResolver.resolve();
|
||||
PortalAuditScope scope = documentsScopeResolver.resolve();
|
||||
if (!scope.allowed()) {
|
||||
// SaaS caller with no team has nothing to show; surface an empty tab, not a 500.
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
}
|
||||
PortalDocumentsResponseDto body =
|
||||
|
||||
@@ -85,6 +85,11 @@ public record Policy(
|
||||
return new Policy(id, name, owner, enabled, inputs, steps, resolved, outputIds, teamId);
|
||||
}
|
||||
|
||||
/** A copy under a different owner (e.g. moving a seed off a placeholder name). */
|
||||
public Policy withOwner(String newOwner) {
|
||||
return new Policy(id, name, newOwner, enabled, inputs, steps, output, outputIds, teamId);
|
||||
}
|
||||
|
||||
/** A copy referencing the given saved output destinations. */
|
||||
public Policy withOutputIds(List<String> newOutputIds) {
|
||||
return new Policy(id, name, owner, enabled, inputs, steps, output, newOutputIds, teamId);
|
||||
|
||||
@@ -22,8 +22,8 @@ import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
|
||||
/**
|
||||
* Seeds an enabled Classification policy per team so classification is on by default. Idempotent;
|
||||
* skips the internal team.
|
||||
* Seeds an enabled Classification policy per team; idempotent, skips the internal team. Left
|
||||
* unowned: nobody created it, and an owner here would have to name a real user.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@@ -34,6 +34,11 @@ public class DefaultClassificationPolicySeeder {
|
||||
private static final String CLASSIFY_ENDPOINT = "/api/v1/ai/tools/classify-and-label";
|
||||
private static final String POLICY_NAME = "Classification Policy";
|
||||
|
||||
/**
|
||||
* Pre-existing seeds used this placeholder, which was never a user; see {@link #repairOwner}.
|
||||
*/
|
||||
private static final String LEGACY_OWNER = "system";
|
||||
|
||||
private final PolicyStore policyStore;
|
||||
private final TeamRepository teamRepository;
|
||||
|
||||
@@ -46,9 +51,8 @@ public class DefaultClassificationPolicySeeder {
|
||||
.ifPresent(team -> seedIfMissing(team.getId(), team.getName()));
|
||||
}
|
||||
|
||||
// Any team created at runtime (admin-created, SaaS sign-ups). Seeds inside the team's own
|
||||
// transaction: rollback still leaves no policy behind, and the store's pessimistic lock needs a
|
||||
// live transaction, which AFTER_COMMIT cannot offer.
|
||||
// Seeds inside the new team's own transaction: rollback leaves no policy behind, and the
|
||||
// store's pessimistic lock needs a live transaction, which AFTER_COMMIT cannot offer.
|
||||
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
|
||||
public void onTeamCreated(TeamCreatedEvent event) {
|
||||
seedIfMissing(event.teamId(), event.teamName());
|
||||
@@ -58,16 +62,33 @@ public class DefaultClassificationPolicySeeder {
|
||||
if (teamId == null || TeamService.INTERNAL_TEAM_NAME.equals(teamName)) {
|
||||
return;
|
||||
}
|
||||
boolean alreadySeeded =
|
||||
Policy existing =
|
||||
policyStore.findByTeam(teamId).stream()
|
||||
.anyMatch(DefaultClassificationPolicySeeder::isClassification);
|
||||
if (alreadySeeded) {
|
||||
.filter(DefaultClassificationPolicySeeder::isClassification)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (existing != null) {
|
||||
repairOwner(existing);
|
||||
return;
|
||||
}
|
||||
policyStore.save(defaultPolicy(teamId));
|
||||
log.info("Seeded default Classification policy for team {}", teamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear an owner seeded as a placeholder name. An owner someone deliberately set is left alone.
|
||||
*/
|
||||
private void repairOwner(Policy policy) {
|
||||
if (!LEGACY_OWNER.equals(policy.owner())) {
|
||||
return;
|
||||
}
|
||||
policyStore.save(policy.withOwner(null));
|
||||
log.info(
|
||||
"Cleared placeholder owner '{}' on Classification policy {}",
|
||||
LEGACY_OWNER,
|
||||
policy.id());
|
||||
}
|
||||
|
||||
private static boolean isClassification(Policy policy) {
|
||||
return policy.output() != null
|
||||
&& CATEGORY.equals(policy.output().options().get("categoryId"));
|
||||
@@ -85,7 +106,9 @@ public class DefaultClassificationPolicySeeder {
|
||||
return new Policy(
|
||||
null,
|
||||
POLICY_NAME,
|
||||
"system",
|
||||
// Nobody created this - it is seeded. A name here would have to be a real user, and
|
||||
// every consumer of owner already handles its absence.
|
||||
null,
|
||||
true,
|
||||
List.of(),
|
||||
List.of(new PipelineStep(CLASSIFY_ENDPOINT, Map.of())),
|
||||
|
||||
@@ -5,13 +5,13 @@ import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.config.AuditConfigurationProperties;
|
||||
@@ -20,15 +20,31 @@ import stirling.software.proprietary.repository.PersistentAuditEventRepository;
|
||||
/** Service to periodically clean up old audit events based on retention policy. */
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AuditCleanupService {
|
||||
|
||||
private final PersistentAuditEventRepository auditRepository;
|
||||
private final AuditConfigurationProperties auditConfig;
|
||||
private final boolean runningEE;
|
||||
|
||||
// Default batch size for deletions
|
||||
private static final int BATCH_SIZE = 10000;
|
||||
|
||||
/**
|
||||
* Maximum audit retention on non-Enterprise instances. Audit events feed the Documents tab on
|
||||
* every instance, but longer history is an Enterprise feature - so non-EE deployments keep a
|
||||
* shorter window ("infinite" included), bounding the always-on trail off-license.
|
||||
*/
|
||||
private static final int NON_EE_MAX_RETENTION_DAYS = 30;
|
||||
|
||||
public AuditCleanupService(
|
||||
PersistentAuditEventRepository auditRepository,
|
||||
AuditConfigurationProperties auditConfig,
|
||||
@Qualifier("runningEE") boolean runningEE) {
|
||||
this.auditRepository = auditRepository;
|
||||
this.auditConfig = auditConfig;
|
||||
this.runningEE = runningEE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheduled task that runs daily to clean up old audit events. The retention period is
|
||||
* configurable in settings.yml.
|
||||
@@ -39,7 +55,7 @@ public class AuditCleanupService {
|
||||
return;
|
||||
}
|
||||
|
||||
int retentionDays = auditConfig.getRetentionDays();
|
||||
int retentionDays = effectiveRetentionDays();
|
||||
if (retentionDays <= 0) {
|
||||
return;
|
||||
}
|
||||
@@ -58,6 +74,20 @@ public class AuditCleanupService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The retention window actually applied. Enterprise uses the configured value (0 = infinite);
|
||||
* non-Enterprise is clamped to {@link #NON_EE_MAX_RETENTION_DAYS}.
|
||||
*/
|
||||
int effectiveRetentionDays() {
|
||||
int configured = auditConfig.getRetentionDays();
|
||||
if (runningEE) {
|
||||
return configured;
|
||||
}
|
||||
return configured <= 0
|
||||
? NON_EE_MAX_RETENTION_DAYS
|
||||
: Math.min(configured, NON_EE_MAX_RETENTION_DAYS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs batch deletion of events to prevent long-running transactions and potential database
|
||||
* locks.
|
||||
|
||||
@@ -87,10 +87,7 @@ public class AuditService {
|
||||
* @param level The minimum audit level required for this event to be logged
|
||||
*/
|
||||
public void audit(AuditEventType type, Map<String, Object> data, AuditLevel level) {
|
||||
// Skip auditing if this level is not enabled or if not Enterprise edition
|
||||
if (!auditConfig.isEnabled()
|
||||
|| !auditConfig.getAuditLevel().includes(level)
|
||||
|| !runningEE) {
|
||||
if (!shouldRecord(type, level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -126,8 +123,7 @@ public class AuditService {
|
||||
*/
|
||||
public void audit(
|
||||
String principal, AuditEventType type, Map<String, Object> data, AuditLevel level) {
|
||||
// Skip auditing if this level is not enabled or if not Enterprise edition
|
||||
if (!auditConfig.isLevelEnabled(level) || !runningEE) {
|
||||
if (!shouldRecord(type, level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -156,8 +152,7 @@ public class AuditService {
|
||||
* @param level The minimum audit level required for this event to be logged
|
||||
*/
|
||||
public void audit(String type, Map<String, Object> data, AuditLevel level) {
|
||||
// Skip auditing if this level is not enabled or if not Enterprise edition
|
||||
if (!auditConfig.isLevelEnabled(level) || !runningEE) {
|
||||
if (!shouldRecord(type, level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -192,8 +187,7 @@ public class AuditService {
|
||||
* @param level The minimum audit level required for this event to be logged
|
||||
*/
|
||||
public void audit(String principal, String type, Map<String, Object> data, AuditLevel level) {
|
||||
// Skip auditing if this level is not enabled or if not Enterprise edition
|
||||
if (!auditConfig.isLevelEnabled(level) || !runningEE) {
|
||||
if (!shouldRecord(type, level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -223,9 +217,7 @@ public class AuditService {
|
||||
AuditEventType type,
|
||||
Map<String, Object> data,
|
||||
AuditLevel level) {
|
||||
if (!auditConfig.isEnabled()
|
||||
|| !auditConfig.getAuditLevel().includes(level)
|
||||
|| !runningEE) {
|
||||
if (!shouldRecord(type, level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -250,9 +242,7 @@ public class AuditService {
|
||||
String type,
|
||||
Map<String, Object> data,
|
||||
AuditLevel level) {
|
||||
if (!auditConfig.isEnabled()
|
||||
|| !auditConfig.getAuditLevel().includes(level)
|
||||
|| !runningEE) {
|
||||
if (!shouldRecord(type, level)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -626,6 +616,55 @@ public class AuditService {
|
||||
return auditConfig.getAuditLevel().includes(requiredLevel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-aware variant used by the controller aspect, which resolves the event type before
|
||||
* deciding whether to record. Document-processing events feed the Documents tab (available to
|
||||
* every Processor user), so they audit without an Enterprise license; the rest of the audit log
|
||||
* stays Enterprise-only.
|
||||
*/
|
||||
public boolean shouldAudit(
|
||||
AuditEventType eventType, Method method, AuditConfigurationProperties auditConfig) {
|
||||
if (!auditConfig.isEnabled() || !isLicensedToRecord(eventType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Audited auditedAnnotation = method.getAnnotation(Audited.class);
|
||||
AuditLevel requiredLevel =
|
||||
(auditedAnnotation != null) ? auditedAnnotation.level() : AuditLevel.BASIC;
|
||||
|
||||
return auditConfig.getAuditLevel().includes(requiredLevel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an event of this type and level should be persisted: the configured audit level must
|
||||
* include it and the current license must permit recording it.
|
||||
*/
|
||||
private boolean shouldRecord(AuditEventType type, AuditLevel level) {
|
||||
return auditConfig.isLevelEnabled(level) && isLicensedToRecord(type);
|
||||
}
|
||||
|
||||
private boolean shouldRecord(String type, AuditLevel level) {
|
||||
return auditConfig.isLevelEnabled(level) && isLicensedToRecord(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current license permits recording this event type. Enterprise records everything;
|
||||
* without it only document-processing events (PDF_PROCESS, FILE_OPERATION) are captured,
|
||||
* because they back the Documents tab that is open to every Processor user (still subject to
|
||||
* audit being enabled at a level that includes them). Everything else stays Enterprise-only.
|
||||
*/
|
||||
private boolean isLicensedToRecord(AuditEventType type) {
|
||||
return runningEE
|
||||
|| type == AuditEventType.PDF_PROCESS
|
||||
|| type == AuditEventType.FILE_OPERATION;
|
||||
}
|
||||
|
||||
private boolean isLicensedToRecord(String type) {
|
||||
return runningEE
|
||||
|| AuditEventType.PDF_PROCESS.name().equals(type)
|
||||
|| AuditEventType.FILE_OPERATION.name().equals(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add timing and response status data to the audit record
|
||||
*
|
||||
|
||||
@@ -76,7 +76,8 @@ class ControllerAuditAspectTest {
|
||||
@DisplayName("shouldAudit false proceeds without recording")
|
||||
void skipsWhenShouldAuditFalse() throws Throwable {
|
||||
ProceedingJoinPoint jp = joinPointFor("getEndpoint");
|
||||
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(false);
|
||||
when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig)))
|
||||
.thenReturn(false);
|
||||
when(jp.proceed()).thenReturn("ok");
|
||||
|
||||
Object result = aspect.auditGetMethod(jp);
|
||||
@@ -102,7 +103,8 @@ class ControllerAuditAspectTest {
|
||||
@DisplayName("records success outcome and returns result")
|
||||
void recordsSuccess() throws Throwable {
|
||||
ProceedingJoinPoint jp = joinPointFor("postEndpoint");
|
||||
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true);
|
||||
when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig)))
|
||||
.thenReturn(true);
|
||||
when(auditService.captureCurrentPrincipal()).thenReturn("alice");
|
||||
when(auditService.captureCurrentOrigin()).thenReturn("WEB");
|
||||
when(auditService.createBaseAuditData(eq(jp), any(AuditLevel.class)))
|
||||
@@ -134,7 +136,8 @@ class ControllerAuditAspectTest {
|
||||
MDC.put("auditPrincipal", "fromMdc");
|
||||
MDC.put("auditOrigin", "API");
|
||||
ProceedingJoinPoint jp = joinPointFor("postEndpoint");
|
||||
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true);
|
||||
when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig)))
|
||||
.thenReturn(true);
|
||||
when(auditService.createBaseAuditData(eq(jp), any(AuditLevel.class)))
|
||||
.thenReturn(new HashMap<>());
|
||||
when(auditService.resolveEventType(
|
||||
@@ -166,7 +169,8 @@ class ControllerAuditAspectTest {
|
||||
@DisplayName("records failure outcome and rethrows")
|
||||
void recordsFailureAndRethrows() throws Throwable {
|
||||
ProceedingJoinPoint jp = joinPointFor("postEndpoint");
|
||||
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true);
|
||||
when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig)))
|
||||
.thenReturn(true);
|
||||
when(auditService.captureCurrentPrincipal()).thenReturn("alice");
|
||||
when(auditService.captureCurrentOrigin()).thenReturn("WEB");
|
||||
when(auditService.createBaseAuditData(eq(jp), any(AuditLevel.class)))
|
||||
@@ -204,7 +208,8 @@ class ControllerAuditAspectTest {
|
||||
@DisplayName("annotated method proceeds without double-auditing")
|
||||
void annotatedMethodSkips() throws Throwable {
|
||||
ProceedingJoinPoint jp = joinPointFor("annotatedEndpoint");
|
||||
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true);
|
||||
when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig)))
|
||||
.thenReturn(true);
|
||||
when(auditService.captureCurrentPrincipal()).thenReturn("alice");
|
||||
when(auditService.captureCurrentOrigin()).thenReturn("WEB");
|
||||
when(jp.proceed()).thenReturn("ok");
|
||||
@@ -232,7 +237,8 @@ class ControllerAuditAspectTest {
|
||||
@DisplayName("captures result when enabled and non-UI type")
|
||||
void capturesResult() throws Throwable {
|
||||
ProceedingJoinPoint jp = joinPointFor("postEndpoint");
|
||||
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true);
|
||||
when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig)))
|
||||
.thenReturn(true);
|
||||
when(auditService.captureCurrentPrincipal()).thenReturn("alice");
|
||||
when(auditService.captureCurrentOrigin()).thenReturn("WEB");
|
||||
when(auditService.createBaseAuditData(eq(jp), any(AuditLevel.class)))
|
||||
@@ -262,7 +268,8 @@ class ControllerAuditAspectTest {
|
||||
@DisplayName("UI_DATA result is not captured")
|
||||
void uiDataResultSkipped() throws Throwable {
|
||||
ProceedingJoinPoint jp = joinPointFor("getEndpoint");
|
||||
when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true);
|
||||
when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig)))
|
||||
.thenReturn(true);
|
||||
when(auditService.captureCurrentPrincipal()).thenReturn("alice");
|
||||
when(auditService.captureCurrentOrigin()).thenReturn("WEB");
|
||||
when(auditService.createBaseAuditData(eq(jp), any(AuditLevel.class)))
|
||||
|
||||
@@ -14,6 +14,7 @@ import static org.mockito.Mockito.when;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
@@ -76,7 +77,7 @@ class ClassifyLabelControllerTest {
|
||||
.thenReturn("{\"outcome\":\"classification\",\"labels\":[\"invoice\"]}");
|
||||
|
||||
try {
|
||||
controller.classifyAndLabel(file);
|
||||
controller.classifyAndLabel(file, false);
|
||||
} catch (Exception ignored) {
|
||||
// WebResponseUtils.pdfDocToWebResponse needs a real temp file; the engine call and
|
||||
// metadata write we assert on have already happened by the time it runs.
|
||||
@@ -89,6 +90,52 @@ class ClassifyLabelControllerTest {
|
||||
return objectMapper.readTree(body.getValue());
|
||||
}
|
||||
|
||||
/** Stubs a document that already carries a verdict, as a second run over a batch would see. */
|
||||
private MultipartFile alreadyClassifiedDocument() throws Exception {
|
||||
PDDocument document = mock(PDDocument.class);
|
||||
PDDocumentInformation info = mock(PDDocumentInformation.class);
|
||||
when(document.getDocumentInformation()).thenReturn(info);
|
||||
when(info.getCustomMetadataValue(PdfMetadataService.CLASSIFICATION_KEY))
|
||||
.thenReturn("{\"labels\":[\"invoice\"]}");
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.getOriginalFilename()).thenReturn("invoice.pdf");
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class), eq(true))).thenReturn(document);
|
||||
return file;
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyAndLabel_skipsADocumentThatAlreadyCarriesAVerdict() throws Exception {
|
||||
withLabels(List.of(new ClassificationLabel("invoice", "Invoice", null)));
|
||||
MultipartFile file = alreadyClassifiedDocument();
|
||||
|
||||
try {
|
||||
controller.classifyAndLabel(file, false);
|
||||
} catch (Exception ignored) {
|
||||
// The response needs a real temp file; the decision under test happens before it.
|
||||
}
|
||||
|
||||
// No second engine call, and no charge for one: re-classifying buys the same answer twice.
|
||||
verify(aiEngineClient, never()).post(anyString(), anyString(), any());
|
||||
verify(pdfMetadataService, never()).setClassificationMetadata(any(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyAndLabel_reclassifiesWhenAskedTo() throws Exception {
|
||||
withLabels(List.of(new ClassificationLabel("invoice", "Invoice", null)));
|
||||
MultipartFile file = alreadyClassifiedDocument();
|
||||
when(pdfContentExtractor.extractPageTextRaw(any(), eq(1))).thenReturn("Invoice total");
|
||||
when(aiEngineClient.post(eq("/api/v1/documents/classify"), anyString(), isNull()))
|
||||
.thenReturn("{\"outcome\":\"classification\",\"labels\":[\"receipt\"]}");
|
||||
|
||||
try {
|
||||
controller.classifyAndLabel(file, true);
|
||||
} catch (Exception ignored) {
|
||||
// As above.
|
||||
}
|
||||
|
||||
verify(aiEngineClient).post(eq("/api/v1/documents/classify"), anyString(), isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyAndLabel_writesClassificationWithoutOutcome() throws Exception {
|
||||
withLabels(List.of(new ClassificationLabel("invoice", "Invoice", null)));
|
||||
|
||||
@@ -36,10 +36,14 @@ class DefaultClassificationPolicySeederTest {
|
||||
}
|
||||
|
||||
private static Policy classificationPolicy(Long teamId) {
|
||||
return classificationPolicy(teamId, null);
|
||||
}
|
||||
|
||||
private static Policy classificationPolicy(Long teamId, String owner) {
|
||||
return new Policy(
|
||||
"p1",
|
||||
"Classification Policy",
|
||||
"system",
|
||||
owner,
|
||||
true,
|
||||
List.of(),
|
||||
List.of(),
|
||||
@@ -77,6 +81,29 @@ class DefaultClassificationPolicySeederTest {
|
||||
verify(policyStore, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void clearsAPlaceholderOwnerSeededBeforeOwnersHadToBeReal() {
|
||||
when(policyStore.findByTeam(7L)).thenReturn(List.of(classificationPolicy(7L, "system")));
|
||||
|
||||
seeder().onTeamCreated(new TeamCreatedEvent(7L, "Acme"));
|
||||
|
||||
// "system" was never a user row, and a step dispatch authenticates as the owner. Absence
|
||||
// is handled everywhere; a placeholder name is not.
|
||||
ArgumentCaptor<Policy> saved = ArgumentCaptor.forClass(Policy.class);
|
||||
verify(policyStore).save(saved.capture());
|
||||
assertThat(saved.getValue().owner()).isNull();
|
||||
assertThat(saved.getValue().id()).isEqualTo("p1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void leavesADeliberatelyChosenOwnerAlone() {
|
||||
when(policyStore.findByTeam(7L)).thenReturn(List.of(classificationPolicy(7L, "alice")));
|
||||
|
||||
seeder().onTeamCreated(new TeamCreatedEvent(7L, "Acme"));
|
||||
|
||||
verify(policyStore, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotSeedForTheInternalTeam() {
|
||||
seeder().onTeamCreated(new TeamCreatedEvent(2L, "Internal"));
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.config.AuditConfigurationProperties;
|
||||
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
|
||||
|
||||
class AuditCleanupServiceTest {
|
||||
|
||||
private final PersistentAuditEventRepository repository =
|
||||
mock(PersistentAuditEventRepository.class);
|
||||
|
||||
private AuditCleanupService service(boolean runningEE, int retentionDays) {
|
||||
ApplicationProperties props = new ApplicationProperties();
|
||||
var audit = props.getPremium().getEnterpriseFeatures().getAudit();
|
||||
audit.setEnabled(true);
|
||||
audit.setRetentionDays(retentionDays);
|
||||
return new AuditCleanupService(
|
||||
repository, new AuditConfigurationProperties(props), runningEE);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Enterprise keeps the configured retention, including infinite")
|
||||
void enterpriseUsesConfigured() {
|
||||
assertThat(service(true, 90).effectiveRetentionDays()).isEqualTo(90);
|
||||
assertThat(service(true, 365).effectiveRetentionDays()).isEqualTo(365);
|
||||
assertThat(service(true, 0).effectiveRetentionDays()).isEqualTo(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-Enterprise caps retention at 30 days")
|
||||
void nonEnterpriseCapsHigherValues() {
|
||||
assertThat(service(false, 90).effectiveRetentionDays()).isEqualTo(30);
|
||||
assertThat(service(false, 365).effectiveRetentionDays()).isEqualTo(30);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-Enterprise respects a shorter configured retention")
|
||||
void nonEnterpriseRespectsLower() {
|
||||
assertThat(service(false, 14).effectiveRetentionDays()).isEqualTo(14);
|
||||
assertThat(service(false, 7).effectiveRetentionDays()).isEqualTo(7);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-Enterprise cannot retain forever (<= 0 becomes the cap)")
|
||||
void nonEnterpriseNoInfinite() {
|
||||
assertThat(service(false, 0).effectiveRetentionDays()).isEqualTo(30);
|
||||
assertThat(service(false, -1).effectiveRetentionDays()).isEqualTo(30);
|
||||
}
|
||||
}
|
||||
@@ -166,6 +166,42 @@ class AuditServiceTest {
|
||||
verify(repository, never()).add(any(AuditEvent.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("records document-processing events even without EE (Documents feed)")
|
||||
void recordsDocumentEventsWithoutEE() {
|
||||
AuditService nonEe =
|
||||
new AuditService(
|
||||
repository, auditConfig, false, pdfDocumentFactory, jwtService);
|
||||
authenticateAs("alice");
|
||||
|
||||
nonEe.audit(AuditEventType.PDF_PROCESS, new HashMap<>(), AuditLevel.BASIC);
|
||||
nonEe.audit(AuditEventType.FILE_OPERATION, new HashMap<>(), AuditLevel.BASIC);
|
||||
|
||||
org.mockito.ArgumentCaptor<AuditEvent> captor =
|
||||
org.mockito.ArgumentCaptor.forClass(AuditEvent.class);
|
||||
verify(repository, org.mockito.Mockito.times(2)).add(captor.capture());
|
||||
assertThat(captor.getAllValues())
|
||||
.extracting(AuditEvent::getType)
|
||||
.containsExactly(
|
||||
AuditEventType.PDF_PROCESS.name(),
|
||||
AuditEventType.FILE_OPERATION.name());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("type-aware shouldAudit lets doc events through without EE, blocks others")
|
||||
void typeAwareShouldAuditWithoutEE() throws Exception {
|
||||
AuditService nonEe =
|
||||
new AuditService(
|
||||
repository, auditConfig, false, pdfDocumentFactory, jwtService);
|
||||
Method m = Object.class.getMethod("toString");
|
||||
|
||||
assertThat(nonEe.shouldAudit(AuditEventType.PDF_PROCESS, m, auditConfig)).isTrue();
|
||||
assertThat(nonEe.shouldAudit(AuditEventType.FILE_OPERATION, m, auditConfig)).isTrue();
|
||||
assertThat(nonEe.shouldAudit(AuditEventType.USER_LOGIN, m, auditConfig)).isFalse();
|
||||
// With EE, non-doc events at/under the configured level audit too.
|
||||
assertThat(service.shouldAudit(AuditEventType.USER_LOGIN, m, auditConfig)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("skips when audit disabled")
|
||||
void skipsWhenDisabled() {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package stirling.software.saas.security;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.audit.PortalAuditScope;
|
||||
import stirling.software.proprietary.audit.PortalAuditScopeResolver;
|
||||
import stirling.software.proprietary.audit.PortalDocumentsScopeResolver;
|
||||
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
|
||||
|
||||
/**
|
||||
* SaaS documents visibility: platform admins see the whole server; every other portal user sees
|
||||
* their own team's documents (by member email).
|
||||
*/
|
||||
@Component
|
||||
@Primary
|
||||
@Profile("saas")
|
||||
@RequiredArgsConstructor
|
||||
public class SaasPortalDocumentsScopeResolver implements PortalDocumentsScopeResolver {
|
||||
|
||||
private final TeamSecurityExpressions teamSecurity;
|
||||
private final TeamMembershipRepository membershipRepository;
|
||||
|
||||
@Override
|
||||
public PortalAuditScope resolve() {
|
||||
if (PortalAuditScopeResolver.hasAdminAuthority()) {
|
||||
return PortalAuditScope.server();
|
||||
}
|
||||
Long teamId = teamSecurity.currentUserTeamId();
|
||||
if (teamId == null) {
|
||||
return PortalAuditScope.denied();
|
||||
}
|
||||
List<String> memberEmails =
|
||||
membershipRepository.findByTeamId(teamId).stream()
|
||||
.map(m -> m.getUser() == null ? null : m.getUser().getEmail())
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
return PortalAuditScope.team("team:" + teamId, memberEmails);
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ ext {
|
||||
openSamlVersion = "5.2.1"
|
||||
commonmarkVersion = "0.28.0"
|
||||
googleJavaFormatVersion = "1.35.0"
|
||||
logback = "1.6.1"
|
||||
logback = "1.6.3"
|
||||
commonsIoVersion = "2.22.0"
|
||||
commonsLang3 = "3.20.0"
|
||||
rhinoVersion = "1.9.1"
|
||||
|
||||
@@ -67,6 +67,30 @@ COPY --from=app-build /app/app/core/build/libs/*.jar app.jar
|
||||
RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers
|
||||
|
||||
|
||||
# Stage 2b: AI engine. Built at its final path so the venv resolves after the copy, on uv's
|
||||
# managed CPython because the runtime base ships Python 3.12.
|
||||
FROM ghcr.io/astral-sh/uv:bookworm-slim@sha256:22334efe746f1b69217d455049b484d7b8cacfb2d5f42555580b62415a98e0a3 AS engine-build
|
||||
ENV UV_PYTHON_INSTALL_DIR=/opt/stirling-engine/python
|
||||
WORKDIR /opt/stirling-engine
|
||||
COPY engine/pyproject.toml engine/uv.lock ./
|
||||
# One layer: trimming in a second RUN would cache the untrimmed copy too, and this build
|
||||
# exports every layer to a GHA cache that is capped repo-wide. Trimming saves ~20MB.
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
set -eux; \
|
||||
apt-get update && apt-get install -y --no-install-recommends binutils; \
|
||||
uv python install 3.13; \
|
||||
uv sync --frozen --no-dev --no-install-project --group engine --python-preference only-managed; \
|
||||
P="$(ls -d /opt/stirling-engine/python/cpython-*)"; \
|
||||
rm -rf "$P/share" "$P/include" \
|
||||
"$P/lib/python3.13/idlelib" "$P/lib/python3.13/tkinter" \
|
||||
"$P/lib/python3.13/ensurepip" "$P/lib/python3.13/pydoc_data" \
|
||||
"$P/lib/python3.13/test" "$P/lib/python3.13/lib2to3"; \
|
||||
find /opt/stirling-engine -name '__pycache__' -type d -prune -exec rm -rf {} + ; \
|
||||
find /opt/stirling-engine \( -name '*.so' -o -name '*.so.*' \) -print0 \
|
||||
| xargs -0 -r strip --strip-unneeded 2>/dev/null || true; \
|
||||
apt-get purge -y binutils; apt-get autoremove -y; rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
# Stage 3: Final runtime image on top of pre-built base
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
@@ -84,6 +108,12 @@ COPY --link --from=app-build --chown=1000:1000 \
|
||||
/app/build/libs/restart-helper.jar /restart-helper.jar
|
||||
COPY --link --chown=1000:1000 scripts/ /scripts/
|
||||
|
||||
# init-without-ocr.sh starts the engine when this directory exists, so other images are unaffected.
|
||||
COPY --link --from=engine-build --chown=1000:1000 /opt/stirling-engine/python /opt/stirling-engine/python
|
||||
COPY --link --from=engine-build --chown=1000:1000 /opt/stirling-engine/.venv /opt/stirling-engine/.venv
|
||||
COPY --link --chown=1000:1000 engine/.env /opt/stirling-engine/.env
|
||||
COPY --link --chown=1000:1000 engine/src/ /opt/stirling-engine/src/
|
||||
|
||||
# Fonts go to system dir, root ownership is correct (world-readable)
|
||||
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/truetype/
|
||||
|
||||
@@ -97,6 +127,8 @@ RUN set -eux; \
|
||||
ln -s /storage /app/storage; \
|
||||
chown -h stirlingpdfuser:stirlingpdfgroup /app/logs /app/configs /app/customFiles /app/pipeline /app/storage; \
|
||||
chown stirlingpdfuser:stirlingpdfgroup /app; \
|
||||
mkdir -p /opt/stirling-engine/data; \
|
||||
chown -R stirlingpdfuser:stirlingpdfgroup /opt/stirling-engine/data; \
|
||||
chmod 750 /tmp/stirling-pdf; \
|
||||
chmod 750 /tmp/stirling-pdf/heap_dumps; \
|
||||
fc-cache -f
|
||||
@@ -116,6 +148,10 @@ ENV VERSION_TAG=$VERSION_TAG \
|
||||
PGID=1000 \
|
||||
UMASK=022 \
|
||||
FAT_DOCKER=true \
|
||||
AIENGINE_ENABLED=true \
|
||||
STIRLING_ENGINE_HOME=/opt/stirling-engine \
|
||||
STIRLING_ENGINE_PORT=5001 \
|
||||
STIRLING_ENGINE_WORKERS=2 \
|
||||
INSTALL_BOOK_AND_ADVANCED_HTML_OPS=false \
|
||||
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
|
||||
TMPDIR=/tmp/stirling-pdf \
|
||||
|
||||
@@ -1,35 +1,43 @@
|
||||
# syntax=docker/dockerfile:1.5
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim@sha256:531f855bda2c73cd6ef67d56b733b357cea384185b3022bd09f05e002cd144ca
|
||||
|
||||
ARG TASK_VERSION=3.52.0
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl ca-certificates \
|
||||
&& ARCH=$(dpkg --print-architecture) \
|
||||
&& curl -fsSL "https://github.com/go-task/task/releases/download/v${TASK_VERSION}/task_${TASK_VERSION}_linux_${ARCH}.deb" -o /tmp/task.deb \
|
||||
&& dpkg -i /tmp/task.deb \
|
||||
&& rm /tmp/task.deb \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# uv resolves the venv here so its ~52MB binary stays out of the runtime image.
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim@sha256:531f855bda2c73cd6ef67d56b733b357cea384185b3022bd09f05e002cd144ca AS builder
|
||||
|
||||
# Source under /app/engine/ to match root Taskfile's `includes.engine.dir: engine`.
|
||||
WORKDIR /app/engine
|
||||
COPY engine/pyproject.toml engine/uv.lock engine/.env ./
|
||||
COPY engine/scripts/ ./scripts/
|
||||
COPY engine/pyproject.toml engine/uv.lock ./
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-dev --group engine
|
||||
uv sync --frozen --no-dev --no-install-project --group engine
|
||||
|
||||
COPY engine/src/ ./src/
|
||||
WORKDIR /app
|
||||
COPY Taskfile.yml ./
|
||||
COPY .taskfiles/ ./.taskfiles/
|
||||
FROM python:3.13-slim-bookworm@sha256:00faa2debb87529f9f0764e9491d8ba400a3678976616c3bd7cb193745ac20d1 AS runtime
|
||||
|
||||
# Created before the COPYs so they land owned; a later chown -R duplicates the venv layer.
|
||||
RUN set -eux; \
|
||||
groupadd --system --gid 1000 stirling; \
|
||||
useradd --system --uid 1000 --gid 1000 --home /app/engine stirling; \
|
||||
mkdir -p /app/engine/data; \
|
||||
chown stirling:stirling /app/engine /app/engine/data
|
||||
|
||||
WORKDIR /app/engine
|
||||
COPY --from=builder --chown=stirling:stirling /app/engine/.venv ./.venv
|
||||
# settings.py resolves ENGINE_ROOT to /app/engine, so .env must sit here.
|
||||
COPY --chown=stirling:stirling engine/.env ./
|
||||
COPY --chown=stirling:stirling engine/src/ ./src/
|
||||
|
||||
ENV PATH="/app/engine/.venv/bin:$PATH"
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV STIRLING_ENGINE_WORKERS=4
|
||||
# Container runs on a fixed port; skip the host-only free-port probe (its script
|
||||
# is not shipped in the image). engine:run honours these.
|
||||
ENV ENGINE_PORT_PROBE=false
|
||||
ENV STIRLING_ENGINE_PORT=5001
|
||||
# Fail closed: without a secret the document routes trust caller-supplied X-User-Id.
|
||||
# Set STIRLING_ENGINE_SHARED_SECRET (the backend sends it as X-Engine-Auth), or false to opt out.
|
||||
ENV STIRLING_ENGINE_REQUIRE_AUTH=true
|
||||
|
||||
# `stirling` resolves from the working directory.
|
||||
WORKDIR /app/engine/src
|
||||
USER stirling
|
||||
|
||||
EXPOSE 5001
|
||||
|
||||
CMD ["task", "engine:run"]
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
|
||||
CMD ["python", "-c", "import os,sys,urllib.request; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:%s/health' % os.environ.get('STIRLING_ENGINE_PORT','5001'), timeout=4).status==200 else 1)"]
|
||||
|
||||
CMD ["sh", "-c", "exec uvicorn stirling.api.app:app --host 0.0.0.0 --port ${STIRLING_ENGINE_PORT:-5001} --workers ${STIRLING_ENGINE_WORKERS:-4}"]
|
||||
|
||||
@@ -16,8 +16,9 @@ engine = [
|
||||
"psycopg[binary,pool]>=3.3.4",
|
||||
"pydantic>=2.13.4",
|
||||
# <2 cap: 1.99.0 patches CVE-2026-46678; 2.0 is an untested major migration.
|
||||
"pydantic-ai>=1.107.2,<2.0.0",
|
||||
"pydantic-ai-slim[voyageai]>=1.107.2,<2.0.0",
|
||||
# Explicit extras: the `pydantic-ai` meta-package pulls all 20 providers (~230MB).
|
||||
# No `voyageai` extra either; stirling.documents.voyage speaks its API directly.
|
||||
"pydantic-ai-slim[anthropic,openai]>=1.107.2,<2.0.0",
|
||||
"pydantic-settings>=2.15.0",
|
||||
"python-dotenv>=1.2.2",
|
||||
"sqlite-vec>=0.1.9",
|
||||
|
||||
@@ -70,6 +70,19 @@ Provider credentials (and any local overrides) go in the uncommitted
|
||||
VOYAGE_API_KEY=your-key
|
||||
```
|
||||
|
||||
### Embedding providers
|
||||
|
||||
`STIRLING_RAG_EMBEDDING_MODEL` is a `provider:model` string. Any OpenAI-compatible
|
||||
`/v1/embeddings` endpoint (vLLM, Ollama, TEI, llama.cpp) works by pointing a base URL
|
||||
at it. Note `OPENAI_BASE_URL` is global and also redirects chat completions; push
|
||||
`provider`/`api_key`/`base_url` through admin AI settings to move embeddings only.
|
||||
Ollama reads `OLLAMA_BASE_URL`, and omitting it fails the first embed call, not startup.
|
||||
|
||||
```
|
||||
STIRLING_RAG_EMBEDDING_MODEL=ollama:nomic-embed-text
|
||||
OLLAMA_BASE_URL=http://ollama:11434/v1
|
||||
```
|
||||
|
||||
## Backends
|
||||
|
||||
**`sqlite`** - Embedded sqlite-vec. Single `.db` file, zero ops. Ideal for dev
|
||||
|
||||
@@ -6,6 +6,7 @@ from pydantic_ai.providers.openai import OpenAIProvider
|
||||
|
||||
from stirling.documents.chunker import chunk_text
|
||||
from stirling.documents.store import Document
|
||||
from stirling.documents.voyage import build_voyage_model
|
||||
|
||||
# Keep each upstream embed request under every major provider's per-call limit while
|
||||
# still batching large enough that a book-sized document ingests in a reasonable number
|
||||
@@ -14,6 +15,9 @@ from stirling.documents.store import Document
|
||||
DEFAULT_EMBED_BATCH_SIZE = 256
|
||||
|
||||
|
||||
VOYAGE_PROVIDER = "voyageai"
|
||||
|
||||
|
||||
def _build_embedder(
|
||||
model_name: str,
|
||||
*,
|
||||
@@ -23,11 +27,17 @@ def _build_embedder(
|
||||
) -> Embedder:
|
||||
"""Construct an :class:`Embedder`; explicit provider/api_key/base_url is the config-push path, else env form."""
|
||||
if not provider and not api_key and not base_url:
|
||||
# Env form is a "provider:model" string; Voyage needs the SDK-free adapter.
|
||||
env_provider, sep, env_model = model_name.partition(":")
|
||||
if sep and env_provider.lower() == VOYAGE_PROVIDER:
|
||||
return Embedder(build_voyage_model(env_model))
|
||||
return Embedder(model_name)
|
||||
|
||||
provider_name = (provider or "").lower()
|
||||
key = api_key or None
|
||||
if provider_name in ("voyageai", "openai"):
|
||||
if provider_name == VOYAGE_PROVIDER:
|
||||
return Embedder(build_voyage_model(model_name, api_key=key, base_url=base_url or None))
|
||||
if provider_name == "openai":
|
||||
return Embedder(f"{provider_name}:{model_name}")
|
||||
if provider_name in ("ollama", "custom"):
|
||||
openai_provider = OpenAIProvider(base_url=base_url or None, api_key=key or "ollama")
|
||||
|
||||
@@ -2,9 +2,11 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -19,11 +21,31 @@ _READ_PERMISSION = "read"
|
||||
# write lock. With multiple worker processes opening the same file, they collide on
|
||||
# startup schema-init and get "database is locked". Wait for the lock instead.
|
||||
_BUSY_TIMEOUT_MS = 5000
|
||||
# journal_mode answers SQLITE_BUSY without consulting the busy handler, so it needs its own retry.
|
||||
_WAL_SWITCH_ATTEMPTS = 10
|
||||
_WAL_RETRY_DELAY_S = 0.1
|
||||
# sqlite stores TIMESTAMP as TEXT. We normalise to UTC ISO 8601 ``YYYY-MM-DD HH:MM:SS``
|
||||
# so lexicographic comparison against ``datetime('now')`` matches chronological order.
|
||||
_SQLITE_DATETIME_FMT = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _enable_wal(conn: sqlite3.Connection) -> None:
|
||||
"""Switch the connection to WAL, tolerating workers racing to do the same."""
|
||||
for _ in range(_WAL_SWITCH_ATTEMPTS):
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
return
|
||||
except sqlite3.OperationalError:
|
||||
row = conn.execute("PRAGMA journal_mode").fetchone()
|
||||
if row is not None and str(row[0]).lower() == "wal":
|
||||
return # another worker won the race and already switched it
|
||||
time.sleep(_WAL_RETRY_DELAY_S)
|
||||
logger.warning("Could not switch the document store to WAL; continuing on the default journal mode.")
|
||||
|
||||
|
||||
def _to_sqlite_utc(dt: datetime | None) -> str | None:
|
||||
if dt is None:
|
||||
return None
|
||||
@@ -58,7 +80,7 @@ class SqliteVecStore(DocumentStore):
|
||||
if self._db_path is not None:
|
||||
# Set before the WAL switch below: that pragma also takes the lock.
|
||||
conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}")
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
_enable_wal(conn)
|
||||
|
||||
self._conn = conn
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""VoyageAI embeddings over its OpenAI-shaped REST API.
|
||||
|
||||
The `voyageai` SDK pulls PIL, numpy, tokenizers and langchain at import for multimodal,
|
||||
chunking and local-inference features the engine never uses (~207MB).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Sequence
|
||||
|
||||
from pydantic_ai.embeddings import EmbeddingResult, EmbeddingSettings
|
||||
from pydantic_ai.embeddings.openai import OpenAIEmbeddingModel
|
||||
from pydantic_ai.embeddings.result import EmbedInputType
|
||||
from pydantic_ai.providers.openai import OpenAIProvider
|
||||
|
||||
VOYAGE_BASE_URL = "https://api.voyageai.com/v1"
|
||||
VOYAGE_API_KEY_ENV = "VOYAGE_API_KEY"
|
||||
|
||||
# Keeps a keyless engine bootable, and stops the client falling back to OPENAI_API_KEY.
|
||||
_MISSING_API_KEY = "stirling-voyage-api-key-not-configured"
|
||||
|
||||
|
||||
class VoyageEmbeddingModel(OpenAIEmbeddingModel):
|
||||
"""Voyage embeddings spoken over the OpenAI wire format."""
|
||||
|
||||
async def embed(
|
||||
self,
|
||||
inputs: str | Sequence[str],
|
||||
*,
|
||||
input_type: EmbedInputType,
|
||||
settings: EmbeddingSettings | None = None,
|
||||
) -> EmbeddingResult:
|
||||
"""Embed `inputs`, forwarding Voyage's `input_type` that the OpenAI model drops."""
|
||||
if self._client.api_key == _MISSING_API_KEY:
|
||||
raise ValueError(
|
||||
f"VoyageAI embeddings need an API key: set {VOYAGE_API_KEY_ENV} or push one via admin AI settings."
|
||||
)
|
||||
merged: EmbeddingSettings = {**(settings or {})}
|
||||
# extra_body is declared `object`, so narrow rather than assume a mapping.
|
||||
current = merged.get("extra_body")
|
||||
extra_body: dict[str, object] = dict(current) if isinstance(current, dict) else {}
|
||||
extra_body.setdefault("input_type", input_type)
|
||||
merged["extra_body"] = extra_body
|
||||
return await super().embed(inputs, input_type=input_type, settings=merged)
|
||||
|
||||
|
||||
def build_voyage_model(
|
||||
model_name: str,
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
) -> VoyageEmbeddingModel:
|
||||
"""Build a Voyage embedding model; a missing key only fails once an embed is attempted."""
|
||||
key = api_key or os.environ.get(VOYAGE_API_KEY_ENV) or _MISSING_API_KEY
|
||||
provider = OpenAIProvider(base_url=base_url or VOYAGE_BASE_URL, api_key=key)
|
||||
return VoyageEmbeddingModel(model_name, provider=provider)
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from stirling.contracts import PageText
|
||||
@@ -647,3 +649,19 @@ def _dummy_tool_def() -> object:
|
||||
"""Sentinel passed to ``_prepare_search_knowledge``. The callback only inspects
|
||||
``_search_count``; it doesn't read anything off the tool_def or context."""
|
||||
return object()
|
||||
|
||||
|
||||
# concurrent store startup
|
||||
|
||||
|
||||
def test_many_stores_open_the_same_file_without_locking_out(tmp_path: Path) -> None:
|
||||
"""Workers all construct a store against one file on boot; the WAL switch races."""
|
||||
import concurrent.futures
|
||||
|
||||
db_path = tmp_path / "rag.db"
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
|
||||
stores = list(pool.map(lambda _: SqliteVecStore(db_path), range(8)))
|
||||
|
||||
assert len(stores) == 8
|
||||
mode = stores[0]._conn.execute("PRAGMA journal_mode").fetchone()[0]
|
||||
assert str(mode).lower() == "wal"
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pydantic_ai import Embedder
|
||||
from pydantic_ai.providers.openai import OpenAIProvider
|
||||
|
||||
from stirling.documents.embedder import _build_embedder
|
||||
from stirling.documents.voyage import VOYAGE_BASE_URL, VoyageEmbeddingModel, build_voyage_model
|
||||
|
||||
# Voyage's documented response body: OpenAI's shape, minus prompt_tokens.
|
||||
VOYAGE_RESPONSE = {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"object": "embedding", "embedding": [0.1, 0.2, 0.3], "index": 0},
|
||||
{"object": "embedding", "embedding": [0.4, 0.5, 0.6], "index": 1},
|
||||
],
|
||||
"model": "voyage-4",
|
||||
"usage": {"total_tokens": 7},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SentRequest:
|
||||
url: str
|
||||
auth: str | None
|
||||
body: dict[str, Any]
|
||||
|
||||
|
||||
def _recording_model(sent: list[SentRequest]) -> VoyageEmbeddingModel:
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
sent.append(
|
||||
SentRequest(
|
||||
url=str(request.url),
|
||||
auth=request.headers.get("authorization"),
|
||||
body=json.loads(request.content),
|
||||
)
|
||||
)
|
||||
return httpx.Response(200, json=VOYAGE_RESPONSE)
|
||||
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
provider = OpenAIProvider(base_url=VOYAGE_BASE_URL, api_key="pa-test-key", http_client=client)
|
||||
return VoyageEmbeddingModel("voyage-4", provider=provider)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_posts_to_voyage_embeddings_endpoint_with_bearer_auth() -> None:
|
||||
sent: list[SentRequest] = []
|
||||
await Embedder(_recording_model(sent)).embed_documents(["alpha", "beta"])
|
||||
|
||||
assert sent[0].url == f"{VOYAGE_BASE_URL}/embeddings"
|
||||
assert sent[0].auth == "Bearer pa-test-key"
|
||||
assert sent[0].body["model"] == "voyage-4"
|
||||
assert sent[0].body["input"] == ["alpha", "beta"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
("call", "expected"),
|
||||
[("embed_query", "query"), ("embed_documents", "document")],
|
||||
)
|
||||
async def test_forwards_voyage_input_type(call: str, expected: str) -> None:
|
||||
"""The stock OpenAI model drops this field; Voyage needs it."""
|
||||
sent: list[SentRequest] = []
|
||||
embedder = Embedder(_recording_model(sent))
|
||||
await getattr(embedder, call)(["text"])
|
||||
|
||||
assert sent[0].body["input_type"] == expected
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_caller_settings_win_over_the_default_input_type() -> None:
|
||||
sent: list[SentRequest] = []
|
||||
await Embedder(_recording_model(sent)).embed_documents(
|
||||
["text"], settings={"extra_body": {"input_type": "query", "output_dimension": 512}}
|
||||
)
|
||||
|
||||
assert sent[0].body["input_type"] == "query"
|
||||
assert sent[0].body["output_dimension"] == 512
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_parses_voyage_response_into_embeddings() -> None:
|
||||
result = await Embedder(_recording_model([])).embed_documents(["alpha", "beta"])
|
||||
|
||||
assert result.embeddings == [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]]
|
||||
|
||||
|
||||
def test_build_voyage_model_reads_the_api_key_from_the_environment(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("VOYAGE_API_KEY", "pa-env-key")
|
||||
|
||||
assert build_voyage_model("voyage-4").model_name == "voyage-4"
|
||||
|
||||
|
||||
def test_build_voyage_model_without_a_key_still_constructs(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("VOYAGE_API_KEY", raising=False)
|
||||
|
||||
assert build_voyage_model("voyage-4").model_name == "voyage-4"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_embedding_without_a_key_fails_with_a_clear_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("VOYAGE_API_KEY", raising=False)
|
||||
model = build_voyage_model("voyage-4")
|
||||
|
||||
with pytest.raises(ValueError, match="VoyageAI embeddings need an API key"):
|
||||
await Embedder(model).embed_documents(["text"])
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_an_openai_key_is_never_sent_to_voyage(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("VOYAGE_API_KEY", raising=False)
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-secret")
|
||||
model = build_voyage_model("voyage-4")
|
||||
|
||||
with pytest.raises(ValueError, match="VoyageAI embeddings need an API key"):
|
||||
await Embedder(model).embed_documents(["text"])
|
||||
|
||||
|
||||
def test_env_form_routes_voyageai_through_the_adapter(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("VOYAGE_API_KEY", "pa-env-key")
|
||||
|
||||
embedder = _build_embedder("voyageai:voyage-4")
|
||||
|
||||
assert isinstance(embedder.model, VoyageEmbeddingModel)
|
||||
assert embedder.model.model_name == "voyage-4"
|
||||
|
||||
|
||||
def test_config_push_form_routes_voyageai_through_the_adapter() -> None:
|
||||
embedder = _build_embedder("voyage-4", provider="voyageai", api_key="pa-pushed-key")
|
||||
|
||||
assert isinstance(embedder.model, VoyageEmbeddingModel)
|
||||
|
||||
|
||||
def test_the_voyageai_sdk_is_not_installed() -> None:
|
||||
"""Guards the ~207MB the SDK would add back."""
|
||||
with pytest.raises(ImportError):
|
||||
__import__("voyageai")
|
||||
|
||||
|
||||
# Live checks, skipped unless VOYAGE_API_KEY is set so CI stays offline.
|
||||
live_only = pytest.mark.skipif(
|
||||
not os.environ.get("VOYAGE_API_KEY"),
|
||||
reason="set VOYAGE_API_KEY to run the live VoyageAI checks",
|
||||
)
|
||||
|
||||
|
||||
def _cosine(a: Sequence[float], b: Sequence[float]) -> float:
|
||||
dot = sum(x * y for x, y in zip(a, b, strict=True))
|
||||
return dot / (math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b)))
|
||||
|
||||
|
||||
@live_only
|
||||
@pytest.mark.anyio
|
||||
async def test_live_voyage_returns_usable_embeddings() -> None:
|
||||
result = await Embedder(build_voyage_model("voyage-4")).embed_documents(["alpha", "beta"])
|
||||
|
||||
assert len(result.embeddings) == 2
|
||||
assert len(result.embeddings[0]) == 1024
|
||||
|
||||
|
||||
@live_only
|
||||
@pytest.mark.anyio
|
||||
async def test_live_voyage_honours_input_type_server_side() -> None:
|
||||
"""Voyage embeds the same text differently per input_type."""
|
||||
embedder = Embedder(build_voyage_model("voyage-4"))
|
||||
text = "How do I combine two PDFs?"
|
||||
|
||||
as_query = await embedder.embed_query(text)
|
||||
as_document = await embedder.embed_documents([text])
|
||||
|
||||
assert _cosine(as_query.embeddings[0], as_document.embeddings[0]) < 0.999
|
||||
|
||||
|
||||
@live_only
|
||||
@pytest.mark.anyio
|
||||
async def test_live_voyage_ranks_the_relevant_document_first() -> None:
|
||||
embedder = Embedder(build_voyage_model("voyage-4"))
|
||||
docs = await embedder.embed_documents(
|
||||
["Stirling PDF merges and splits PDF files.", "The capital of France is Paris."]
|
||||
)
|
||||
query = await embedder.embed_query("How do I combine two PDFs?")
|
||||
|
||||
relevant = _cosine(query.embeddings[0], docs.embeddings[0])
|
||||
irrelevant = _cosine(query.embeddings[0], docs.embeddings[1])
|
||||
assert relevant > irrelevant
|
||||
|
||||
|
||||
@live_only
|
||||
@pytest.mark.anyio
|
||||
async def test_live_voyage_accepts_voyage_only_parameters() -> None:
|
||||
"""output_dimension has no OpenAI equivalent, so this proves extra_body lands."""
|
||||
result = await Embedder(build_voyage_model("voyage-4")).embed_documents(
|
||||
["dimension test"], settings={"extra_body": {"output_dimension": 256}}
|
||||
)
|
||||
|
||||
assert len(result.embeddings[0]) == 256
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"endOfLine": "lf",
|
||||
"sortPackageJson": false,
|
||||
"ignorePatterns": [
|
||||
"dist/",
|
||||
"editor/dist/",
|
||||
"editor/src-tauri/**/target/",
|
||||
"editor/src-tauri/gen/",
|
||||
"node_modules/",
|
||||
"editor/public/vendor/",
|
||||
"editor/public/mockServiceWorker.js",
|
||||
"editor/public/og-metadata.json",
|
||||
"editor/public/og-metadata.saas.json",
|
||||
"editor/src/core/data/ogImageMap.json",
|
||||
"editor/src/portal/generated/docsManifest.json",
|
||||
"editor/public/pdfjs*/",
|
||||
"editor/public/js/thirdParty/",
|
||||
"editor/public/css/cookieconsent.css",
|
||||
"storybook-static/",
|
||||
"playwright-report/",
|
||||
"editor/playwright-report/",
|
||||
"test-results/",
|
||||
"editor/test-results/",
|
||||
"*.min.*",
|
||||
"*.md",
|
||||
"*.wxs",
|
||||
"*.toml",
|
||||
"editor/src/output.css"
|
||||
]
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
dist/
|
||||
editor/dist/
|
||||
# Tauri/Cargo build output (binary assets named *.js etc. confuse Prettier).
|
||||
# Match nested target/ dirs too - provisioner/ and thumbnail-handler/ each
|
||||
# have their own Cargo workspace under src-tauri/.
|
||||
editor/src-tauri/**/target/
|
||||
editor/src-tauri/gen/
|
||||
node_modules/
|
||||
editor/public/vendor/
|
||||
# Auto-generated by MSW (`msw init`); regenerated verbatim, not hand-formatted.
|
||||
editor/public/mockServiceWorker.js
|
||||
# Auto-generated OG/social-preview metadata (scripts/generate-og-metadata.mjs); regenerated verbatim.
|
||||
editor/public/og-metadata.json
|
||||
editor/public/og-metadata.saas.json
|
||||
editor/src/core/data/ogImageMap.json
|
||||
# Auto-generated portal docs manifest (scripts/sync-portal-docs.mts); regenerated verbatim.
|
||||
editor/src/portal/generated/docsManifest.json
|
||||
editor/public/pdfjs*/
|
||||
editor/public/js/thirdParty/
|
||||
editor/public/css/cookieconsent.css
|
||||
# Build / test artifacts that may exist locally even though they're gitignored
|
||||
storybook-static/
|
||||
playwright-report/
|
||||
editor/playwright-report/
|
||||
test-results/
|
||||
editor/test-results/
|
||||
*.min.*
|
||||
*.md
|
||||
*.wxs
|
||||
editor/src/output.css
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@@ -71,9 +71,9 @@ for (const [path, raw] of Object.entries(localeModules)) {
|
||||
}
|
||||
|
||||
if (!i18next.isInitialized) {
|
||||
// initImmediate: false → initialise synchronously from the inline resources
|
||||
// (there's no async backend here), so i18next is ready before the first story
|
||||
// renders. Without it the first render can beat init and stick on raw keys.
|
||||
// initAsync: false → initialise synchronously from the inline resources.
|
||||
// There is no asynchronous backend here, so i18next is ready before stories
|
||||
// render.
|
||||
void i18next.use(initReactI18next).init({
|
||||
lng: "en-US",
|
||||
fallbackLng: "en-US",
|
||||
@@ -81,7 +81,7 @@ if (!i18next.isInitialized) {
|
||||
resources,
|
||||
interpolation: { escapeValue: false },
|
||||
react: { useSuspense: false },
|
||||
initImmediate: false,
|
||||
initAsync: false,
|
||||
});
|
||||
} else {
|
||||
// Something initialised i18next first (e.g. the app's async TOML backend):
|
||||
|
||||
@@ -18,7 +18,7 @@ For desktop app development, see the [Tauri](#tauri) section below.
|
||||
`frontend/` is a workspace containing one or more apps. Today it holds the
|
||||
PDF editor under `frontend/editor/`; new apps (the developer portal, etc.)
|
||||
will sit alongside it as siblings. Shared tooling — `package.json`, `node_modules`,
|
||||
`.storybook/`, oxlint, Prettier — lives at `frontend/` so every app installs
|
||||
`.storybook/`, oxlint, oxfmt — lives at `frontend/` so every app installs
|
||||
once and lints with the same config.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
<meta charset="UTF-8" />
|
||||
<base href="%BASE_URL%" />
|
||||
<link rel="icon" href="modern-logo/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
|
||||
@@ -3969,6 +3969,7 @@ back = "Back"
|
||||
backToFolder = "Back to {{folder}}"
|
||||
backToMyFiles = "Back to My Files"
|
||||
breadcrumbs = "Folder path"
|
||||
bulkActions = "Actions"
|
||||
cancel = "Cancel"
|
||||
classification = "Classification"
|
||||
clearSelection = "Clear selection"
|
||||
@@ -4142,6 +4143,11 @@ totalSize = "Total size"
|
||||
type = "Type"
|
||||
versionHistory = "Version journey"
|
||||
|
||||
[filesPage.filters]
|
||||
activeCount = "{{count}} filters active"
|
||||
clearAll = "Clear filters"
|
||||
label = "Filters"
|
||||
|
||||
[filesPage.folderName]
|
||||
cancel = "Cancel"
|
||||
error = "Could not save folder. Try again."
|
||||
@@ -4176,6 +4182,7 @@ label = "Filter files by name"
|
||||
placeholder = "Filter files…"
|
||||
|
||||
[filesPage.sort]
|
||||
label = "Sort files"
|
||||
modifiedAsc = "Oldest first"
|
||||
modifiedDesc = "Recent first"
|
||||
nameAsc = "Name A→Z"
|
||||
@@ -4585,6 +4592,10 @@ desc = "Change document restrictions and permissions"
|
||||
tags = "permissions,restrictions,rights,access control,allow,deny,printing,copying,editing,modify permissions,security settings,user rights"
|
||||
title = "Change Permissions"
|
||||
|
||||
[home.classify]
|
||||
desc = "Identify what kind of document this is and tag it."
|
||||
title = "Classify"
|
||||
|
||||
[home.compare]
|
||||
desc = "Compares and shows the differences between 2 PDF Documents"
|
||||
tags = "difference,compare,diff,compare PDFs,compare documents,find differences,show differences,changes,what changed,track changes,revisions,version compare,side by side,contrast,delta"
|
||||
@@ -9696,7 +9707,9 @@ toolNotAvailableLocally = "Your Stirling-PDF server is offline and \"{{endpoint}
|
||||
expired = "Your session has expired. Please refresh the page and try again."
|
||||
|
||||
[settings]
|
||||
backToSections = "All settings"
|
||||
close = "Close"
|
||||
title = "Settings"
|
||||
|
||||
[settings.ai]
|
||||
documents = "Documents & RAG"
|
||||
@@ -10635,6 +10648,7 @@ ariaLabel = "Super search"
|
||||
filtersAriaLabel = "Search filters"
|
||||
hint = "Type to search"
|
||||
placeholder = "Search Stirling"
|
||||
placeholderShort = "Search"
|
||||
showLess = "Show less"
|
||||
showMore = "Show {{count}} more"
|
||||
|
||||
@@ -10831,6 +10845,7 @@ searchPlaceholder = "Search tools..."
|
||||
|
||||
[toolPicker.subcategories]
|
||||
advancedFormatting = "Advanced Formatting"
|
||||
ai = "AI"
|
||||
automation = "Automation"
|
||||
developerTools = "Developer Tools"
|
||||
documentReview = "Document Review"
|
||||
@@ -11335,8 +11350,11 @@ columnDefault = "Column {{index}}"
|
||||
convertToPdf = "Convert to PDF"
|
||||
csvStats = "{{rows}} rows · {{columns}} columns · {{size}}"
|
||||
emptyFile = "Empty file"
|
||||
htmlHidePreview = "Hide preview"
|
||||
htmlPreview = "HTML preview"
|
||||
htmlPreviewMobileHidden = "HTML pages are laid out for desktop widths, so the preview is off by default here."
|
||||
htmlPreviewWarning = "HTML preview - external resources may not load · {{size}}"
|
||||
htmlShowPreview = "Show preview anyway"
|
||||
invalidJson = "Invalid JSON - showing raw content"
|
||||
lineNumbers = "Line numbers"
|
||||
loading = "Loading..."
|
||||
@@ -11683,6 +11701,7 @@ exportAll = "Export PDF"
|
||||
exportSelected = "Export Selected Pages"
|
||||
formFill = "Fill Form"
|
||||
hideToolbar = "Hide toolbar"
|
||||
moreActions = "More actions"
|
||||
multiTool = "Multi-Tool"
|
||||
panMode = "Pan Mode"
|
||||
print = "Print PDF"
|
||||
@@ -11703,6 +11722,8 @@ selectAll = "Select All"
|
||||
selectByNumber = "Select by Page Numbers"
|
||||
selectLanguage = "Select language"
|
||||
share = "Share"
|
||||
showAllTools = "Show all tools"
|
||||
showFewerTools = "Collapse toolbar"
|
||||
showToolbar = "Show toolbar"
|
||||
toggleAnnotations = "Toggle Annotations Visibility"
|
||||
toggleAttachments = "Toggle Attachments"
|
||||
|
||||
@@ -195,6 +195,11 @@
|
||||
"title": "Compress - Stirling PDF",
|
||||
"description": "Compress PDFs to reduce their file size."
|
||||
},
|
||||
"classify": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Classify - Stirling PDF",
|
||||
"description": "Identify what kind of document this is and tag it."
|
||||
},
|
||||
"extractPages": {
|
||||
"image": "/og_images/extract-pages.png",
|
||||
"title": "Extract Pages - Stirling PDF",
|
||||
@@ -575,6 +580,7 @@
|
||||
"/remove-cert-sign": "removeCertSign",
|
||||
"/unlock-p-d-f-forms": "unlockPDFForms",
|
||||
"/compress": "compress",
|
||||
"/classify": "classify",
|
||||
"/extract-pages": "extractPages",
|
||||
"/reorganize-pages": "reorganizePages",
|
||||
"/extract-images": "extractImages",
|
||||
|
||||
@@ -196,6 +196,11 @@
|
||||
"title": "Compress - Stirling PDF",
|
||||
"description": "Compress PDFs to reduce their file size."
|
||||
},
|
||||
"classify": {
|
||||
"image": "/og_images/home.png",
|
||||
"title": "Classify - Stirling PDF",
|
||||
"description": "Identify what kind of document this is and tag it."
|
||||
},
|
||||
"extractPages": {
|
||||
"image": "/og_images/extract-pages.png",
|
||||
"title": "Extract Pages - Stirling PDF",
|
||||
@@ -588,6 +593,7 @@
|
||||
"/remove-cert-sign": "removeCertSign",
|
||||
"/unlock-p-d-f-forms": "unlockPDFForms",
|
||||
"/compress": "compress",
|
||||
"/classify": "classify",
|
||||
"/extract-pages": "extractPages",
|
||||
"/reorganize-pages": "reorganizePages",
|
||||
"/extract-images": "extractImages",
|
||||
|
||||
@@ -10,15 +10,9 @@ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { parseArgs } from "node:util";
|
||||
import { compile, type JSONSchema } from "json-schema-to-typescript";
|
||||
import * as prettier from "prettier";
|
||||
|
||||
// The API namespaces whose endpoints a pipeline can reference. `/api/v1/ai/tools/`
|
||||
// is absent from the spec, so it cannot appear here. Extend this list when other
|
||||
// namespaces become tools.
|
||||
//
|
||||
// `/api/v1/filter/` and `/api/v1/integration/` are included even though neither is a
|
||||
// user-facing tool: a stored pipeline can contain one, and ToolEndpoint keys the I/O
|
||||
// table, so leaving them out would stop a chain being checked past such a step.
|
||||
// Endpoints a pipeline can reference. filter/integration are not user-facing tools but a stored
|
||||
// pipeline can contain one; the AI namespace is admitted one endpoint at a time, not wholesale.
|
||||
const ALLOWED_PATH_PREFIXES = [
|
||||
"/api/v1/general/",
|
||||
"/api/v1/misc/",
|
||||
@@ -26,6 +20,7 @@ const ALLOWED_PATH_PREFIXES = [
|
||||
"/api/v1/convert/",
|
||||
"/api/v1/filter/",
|
||||
"/api/v1/integration/",
|
||||
"/api/v1/ai/tools/classify-and-label",
|
||||
];
|
||||
|
||||
// File plumbing, not user parameters: `fileInput` and `file` are the uploaded primary document
|
||||
@@ -227,11 +222,7 @@ function collectToolIO(
|
||||
return { table, dropped };
|
||||
}
|
||||
|
||||
async function renderToolIO(
|
||||
spec: Json,
|
||||
table: Record<string, unknown>,
|
||||
outputPath: string,
|
||||
): Promise<string> {
|
||||
function renderToolIO(spec: Json, table: Record<string, unknown>): string {
|
||||
if (Object.keys(table).length === 0) {
|
||||
throw new Error(
|
||||
`No ${IO_EXTENSION} declarations in the spec. The backend publishes these from @ToolIO; regenerate with 'task backend:swagger'.`,
|
||||
@@ -312,33 +303,12 @@ export function toolIOFor(
|
||||
}
|
||||
`;
|
||||
|
||||
const prettierConfig = await prettier.resolveConfig(outputPath);
|
||||
return prettier.format(body, { ...prettierConfig, parser: "typescript" });
|
||||
return body;
|
||||
}
|
||||
|
||||
/** In check mode, fail when the committed file is out of date. */
|
||||
function writeOrCheck(
|
||||
outputPath: string,
|
||||
formatted: string,
|
||||
check: boolean,
|
||||
task: string,
|
||||
): void {
|
||||
if (check) {
|
||||
let current = "";
|
||||
try {
|
||||
current = readFileSync(outputPath, "utf-8");
|
||||
} catch {
|
||||
// Missing file counts as out of date.
|
||||
}
|
||||
if (current !== formatted) {
|
||||
throw new Error(
|
||||
`${outputPath} is out of date. Run '${task}' and commit the result.`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
function writeOutput(outputPath: string, contents: string): void {
|
||||
mkdirSync(dirname(outputPath), { recursive: true });
|
||||
writeFileSync(outputPath, formatted, "utf-8");
|
||||
writeFileSync(outputPath, contents, "utf-8");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
@@ -347,12 +317,11 @@ async function main(): Promise<void> {
|
||||
spec: { type: "string" },
|
||||
output: { type: "string" },
|
||||
"io-output": { type: "string" },
|
||||
check: { type: "boolean", default: false },
|
||||
},
|
||||
});
|
||||
if (!values.spec || !values.output || !values["io-output"]) {
|
||||
throw new Error(
|
||||
"Usage: generate-tool-api-types.mts --spec <SwaggerDoc.json> --output <file.ts> --io-output <file.ts> [--check]",
|
||||
"Usage: generate-tool-api-types.mts --spec <SwaggerDoc.json> --output <file.ts> --io-output <file.ts>",
|
||||
);
|
||||
}
|
||||
const specPath = resolve(values.spec);
|
||||
@@ -478,14 +447,9 @@ async function main(): Promise<void> {
|
||||
`Dropped ${dropped.length} @ToolIO declaration(s) on paths that are not tool endpoints. Add the namespace to ALLOWED_PATH_PREFIXES if a pipeline can contain these steps:\n ${dropped.join("\n ")}`,
|
||||
);
|
||||
}
|
||||
writeOrCheck(
|
||||
ioOutputPath,
|
||||
await renderToolIO(spec, ioDeclarations, ioOutputPath),
|
||||
values.check ?? false,
|
||||
"task frontend:tool-models",
|
||||
);
|
||||
writeOutput(ioOutputPath, renderToolIO(spec, ioDeclarations));
|
||||
console.log(
|
||||
`${values.check ? "Up to date" : "Generated"}: ${Object.keys(ioDeclarations).length} tool I/O declarations.`,
|
||||
`Generated ${Object.keys(ioDeclarations).length} tool I/O declarations.`,
|
||||
);
|
||||
|
||||
// Transitively inline every referenced component into `definitions`, rewriting its refs too.
|
||||
@@ -508,7 +472,6 @@ async function main(): Promise<void> {
|
||||
definitions,
|
||||
fileFieldsByClass,
|
||||
outputPath,
|
||||
values.check ?? false,
|
||||
skipped,
|
||||
);
|
||||
}
|
||||
@@ -518,7 +481,6 @@ async function compileAndWrite(
|
||||
definitions: Record<string, Json>,
|
||||
fileFieldsByClass: Record<string, string[]>,
|
||||
outputPath: string,
|
||||
check: boolean,
|
||||
skipped: string[],
|
||||
): Promise<void> {
|
||||
// json-schema-to-typescript only emits a named, exported interface per schema
|
||||
@@ -597,17 +559,9 @@ async function compileAndWrite(
|
||||
].join("\n");
|
||||
|
||||
const body = `${FILE_HEADER}\n\n${models}\n\n${footer}\n`;
|
||||
const prettierConfig = await prettier.resolveConfig(outputPath);
|
||||
const formatted = await prettier.format(body, {
|
||||
...prettierConfig,
|
||||
parser: "typescript",
|
||||
});
|
||||
|
||||
writeOrCheck(outputPath, formatted, check, "task frontend:tool-models");
|
||||
console.log(
|
||||
`${check ? "Up to date" : "Generated"}: ${tools.length} tool endpoints.`,
|
||||
);
|
||||
if (!check && skipped.length > 0) {
|
||||
writeOutput(outputPath, body);
|
||||
console.log(`Generated ${tools.length} tool endpoints.`);
|
||||
if (skipped.length > 0) {
|
||||
console.log(
|
||||
`Skipped ${skipped.length} POST endpoint(s) with no request body: ${skipped.join(", ")}`,
|
||||
);
|
||||
|
||||
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 681 B |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 758 B |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 16 KiB |