Merge branch 'main' into add_contributing_agreement

This commit is contained in:
Ludy
2026-08-18 22:46:52 +02:00
committed by GitHub
174 changed files with 7349 additions and 3883 deletions
+18 -19
View File
@@ -1,7 +1,7 @@
# CI routing infra. Editing the top-level router (build.yml) or this filter
# config re-runs every area's jobs, so every job-gating filter below includes
# *ci. That makes a change to how jobs are dispatched actually exercise those
# jobs (self-testing), instead of a router edit only matching the project filter.
# CI routing infrastructure. Changes to the top-level router (build.yml) or
# this filter configuration rerun every area's jobs. Every job-gating filter
# therefore includes *ci, so routing changes exercise the jobs they affect
# instead of matching only the project filter.
ci: &ci
- .github/workflows/build.yml
- .github/config/.files.yaml
@@ -24,9 +24,9 @@ openapi: &openapi
docker-base: &docker-base
- docker/base/Dockerfile
# Dockerfiles only (base + embedded + unoserver). Gates the slow multi-arch
# (arm64) leg of the PR docker test build: arm64 is only rebuilt when a
# Dockerfile itself changes, not on every code PR.
# Dockerfiles only (base, embedded, and unoserver). The slow multi-architecture
# (arm64) leg of the PR Docker test build runs only when a Dockerfile changes,
# rather than for every code PR.
dockerfiles: &dockerfiles
- docker/**/Dockerfile*
@@ -68,7 +68,6 @@ project: &project
frontend: &frontend
- *ci
- frontend/**
- .github/workflows/testdriver.yml
- testing/**
- docker/**
- scripts/translations/*.py
@@ -88,8 +87,8 @@ frontend: &frontend
- .github/workflows/e2e-stubbed.yml
- .github/workflows/e2e-live.yml
# Files that affect the Tauri desktop bundle. Gate the multi-OS Tauri build
# job on changes to any of these.
# Files that affect the Tauri desktop bundle. Changes to any of these files
# trigger the multi-OS Tauri build job.
tauri: &tauri
- *ci
- frontend/editor/src-tauri/**
@@ -102,9 +101,9 @@ tauri: &tauri
- Taskfile.yml
- .taskfiles/desktop.yml
# Files that affect the AI engine (Python tool models, fixers, tests). Gate
# the engine validation job on changes to engine sources or to the Java
# tool surfaces it generates models from.
# Files that affect the AI engine, including its Python tool models, fixers,
# and tests. The engine validation job also runs when the Java tool surfaces
# used to generate those models change.
engine: &engine
- *ci
- engine/**
@@ -114,10 +113,10 @@ engine: &engine
- .taskfiles/engine.yml
# Files that can make the committed generated API models (frontend tool API
# types + engine tool models) go stale: the Java tool surfaces they derive from,
# the generators, the generated files themselves (to catch a hand-edit), and the
# tasks that drive generation. Deliberately excludes the broad frontend/docker/
# testing globs, so a CSS-only PR does not boot the backend to rebuild the spec.
# types and engine tool models) stale: their Java sources, generators,
# generated outputs (to catch hand edits), and generation tasks. Broad
# frontend, Docker, and testing globs are intentionally excluded, so a CSS-only
# PR does not start the backend to rebuild the specification.
generated-models: &generated-models
- *ci
- *openapi
@@ -141,8 +140,8 @@ licenses-backend: &licenses-backend
- ".github/workflows/frontend-backend-licenses-update.yml"
- *build
# Files that can affect premium / enterprise behaviour. Gate the enterprise
# Playwright job on changes to any of these on PRs.
# Files that can affect premium or enterprise behaviour. Changes to any of
# these files trigger the enterprise Playwright job for pull requests.
proprietary: &proprietary
- *ci
- app/proprietary/**
+4 -4
View File
@@ -39,7 +39,7 @@ jobs:
pr_ref: ${{ steps.resolve.outputs.ref }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -121,7 +121,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -334,7 +334,7 @@ jobs:
# under the exact same access rules as the app preview.
- name: Detect Storybook changes
id: sb-changes
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
with:
list-files: json
filters: |
@@ -472,7 +472,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
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@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -177,7 +177,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -191,22 +191,19 @@ jobs:
# untrusted tree gets built below - never leave credentials in .git/config
persist-credentials: false
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
- name: Cache Gradle
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ 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') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
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
with:
java-version: "25"
distribution: "temurin"
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
@@ -485,7 +482,7 @@ jobs:
issues: write # add/remove labels, delete the command comment
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
+1 -5
View File
@@ -7,10 +7,6 @@ on:
permissions:
contents: read
env:
SERVER_IP: ${{ secrets.NEW_VPS_IP }} # Add this to your GitHub secrets
CLEANUP_PERFORMED: "false" # Add flag to track if cleanup occurred
jobs:
cleanup:
environment: pr-preview
@@ -23,7 +19,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
+1 -1
View File
@@ -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@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
-221
View File
@@ -1,221 +0,0 @@
name: AI - PR Title Review
on:
pull_request:
types: [opened, edited]
branches: [main]
permissions: # required for secure-repo hardening
contents: read
jobs:
ai-title-review:
# GITHUB_TOKEN obeys this block, so it must cover every API call made below.
permissions:
contents: read # actions/checkout, git fetch/diff
issues: write # issues.listComments / createComment / updateComment on the PR
pull-requests: write # same endpoints when the target is a pull request
models: read # actions/ai-inference
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Configure Git to suppress detached HEAD warning
run: git config --global advice.detachedHead false
- name: Check if actor is repo developer
id: actor
run: |
if [[ "${{ github.actor }}" == *"[bot]" ]]; then
echo "PR opened by a bot skipping AI title review."
echo "is_repo_dev=false" >> $GITHUB_OUTPUT
exit 0
fi
if [ ! -f .github/config/repo_devs.json ]; then
echo "Error: .github/config/repo_devs.json not found" >&2
exit 1
fi
# Validate JSON and extract repo_devs
REPO_DEVS=$(jq -r '.repo_devs[]' .github/config/repo_devs.json 2>/dev/null || { echo "Error: Invalid JSON in repo_devs.json" >&2; exit 1; })
# Convert developer list into Bash array
mapfile -t DEVS_ARRAY <<< "$REPO_DEVS"
if [[ " ${DEVS_ARRAY[*]} " == *" ${{ github.actor }} "* ]]; then
echo "is_repo_dev=true" >> $GITHUB_OUTPUT
else
echo "is_repo_dev=false" >> $GITHUB_OUTPUT
fi
- name: Get PR diff
if: steps.actor.outputs.is_repo_dev == 'true'
id: get_diff
run: |
git fetch origin ${{ github.base_ref }}
git diff origin/${{ github.base_ref }}...HEAD | head -n 10000 | grep -vP '[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\x{202E}\x{200B}]' > pr.diff
echo "diff<<EOF" >> $GITHUB_OUTPUT
cat pr.diff >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Check and sanitize PR title
if: steps.actor.outputs.is_repo_dev == 'true'
id: sanitize_pr_title
env:
PR_TITLE_RAW: ${{ github.event.pull_request.title }}
run: |
# Sanitize PR title: max 72 characters, only printable characters
PR_TITLE=$(echo "$PR_TITLE_RAW" | tr -d '\n\r' | head -c 72 | sed 's/[^[:print:]]//g')
if [[ ${#PR_TITLE} -lt 5 ]]; then
echo "PR title is too short. Must be at least 5 characters." >&2
fi
echo "pr_title=$PR_TITLE" >> $GITHUB_OUTPUT
- name: AI PR Title Analysis
if: steps.actor.outputs.is_repo_dev == 'true'
id: ai-title-analysis
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
with:
model: openai/gpt-4o
system-prompt-file: ".github/config/system-prompt.txt"
prompt: |
Based on the following input data:
{
"diff": "${{ steps.get_diff.outputs.diff }}",
"pr_title": "${{ steps.sanitize_pr_title.outputs.pr_title }}"
}
Respond ONLY with valid JSON in the format:
{
"improved_rating": <0-10>,
"improved_ai_title_rating": <0-10>,
"improved_title": "<ai generated title>"
}
- name: Validate and set SCRIPT_OUTPUT
if: steps.actor.outputs.is_repo_dev == 'true'
run: |
cat <<EOF > ai_response.json
${{ steps.ai-title-analysis.outputs.response }}
EOF
# Validate JSON structure
jq -e '
(keys | sort) == ["improved_ai_title_rating", "improved_rating", "improved_title"] and
(.improved_rating | type == "number" and . >= 0 and . <= 10) and
(.improved_ai_title_rating | type == "number" and . >= 0 and . <= 10) and
(.improved_title | type == "string")
' ai_response.json
if [ $? -ne 0 ]; then
echo "Invalid AI response format" >&2
cat ai_response.json >&2
exit 1
fi
# Parse JSON fields
IMPROVED_RATING=$(jq -r '.improved_rating' ai_response.json)
IMPROVED_TITLE=$(jq -r '.improved_title' ai_response.json)
# Limit comment length to 1000 characters
COMMENT=$(cat <<EOF
## 🤖 AI PR Title Suggestion
**PR-Title Rating**: $IMPROVED_RATING/10
### ⬇️ Suggested Title (copy & paste):
\`\`\`
$IMPROVED_TITLE
\`\`\`
---
*Generated by GitHub Models AI*
EOF
)
echo "$COMMENT" > /tmp/ai-title-comment.md
# Log input and output to the GitHub Step Summary
echo "### 🤖 AI PR Title Analysis" >> $GITHUB_STEP_SUMMARY
echo "### Input PR Title" >> $GITHUB_STEP_SUMMARY
echo '```bash' >> $GITHUB_STEP_SUMMARY
echo "${{ steps.sanitize_pr_title.outputs.pr_title }}" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo '### AI Response (raw JSON)' >> $GITHUB_STEP_SUMMARY
echo '```json' >> $GITHUB_STEP_SUMMARY
cat ai_response.json >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
- name: Post comment on PR if needed
if: steps.actor.outputs.is_repo_dev == 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
continue-on-error: true
with:
github-token: ${{ github.token }}
script: |
const fs = require('fs');
const body = fs.readFileSync('/tmp/ai-title-comment.md', 'utf8');
const { GITHUB_REPOSITORY } = process.env;
const [owner, repo] = GITHUB_REPOSITORY.split('/');
const issue_number = context.issue.number;
const ratingMatch = body.match(/\*\*PR-Title Rating\*\*: (\d+)\/10/);
const rating = ratingMatch ? parseInt(ratingMatch[1], 10) : null;
const expectedActor = "github-actions[bot]";
const comments = await github.rest.issues.listComments({ owner, repo, issue_number });
const existing = comments.data.find(c =>
c.user?.login === expectedActor &&
c.body.includes("## 🤖 AI PR Title Suggestion")
);
if (rating === null) {
console.log("No rating found in AI response skipping.");
return;
}
if (rating <= 5) {
if (existing) {
await github.rest.issues.updateComment({
owner, repo,
comment_id: existing.id,
body
});
console.log("Updated existing suggestion comment.");
} else {
await github.rest.issues.createComment({
owner, repo, issue_number,
body
});
console.log("Created new suggestion comment.");
}
} else {
const praise = `## 🤖 AI PR Title Suggestion\n\nGreat job! The current PR title is clear and well-structured.\n\n✅ No suggestions needed.\n\n---\n*Generated by GitHub Models AI*`;
if (existing) {
await github.rest.issues.updateComment({
owner, repo,
comment_id: existing.id,
body: praise
});
console.log("Replaced suggestion with praise.");
} else {
console.log("Rating > 5 and no existing comment skipping comment.");
}
}
- name: is not repo dev
if: steps.actor.outputs.is_repo_dev != 'true'
run: |
exit 0 # Skip the AI title review for non-repo developers
- name: Clean up
if: always()
run: |
rm -f pr.diff ai_response.json /tmp/ai-title-comment.md
echo "Cleaned up temporary files."
continue-on-error: true # Ensure cleanup runs even if previous steps fail
+2 -2
View File
@@ -26,7 +26,7 @@ jobs:
jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -71,7 +71,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
issues: write # labels are applied through the issues API
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
+11 -13
View File
@@ -20,6 +20,7 @@ permissions:
jobs:
build:
environment: ci-unsigned
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -28,29 +29,26 @@ jobs:
flavor: [core, proprietary, saas]
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore cache Gradle User Home
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
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
with:
java-version: ${{ matrix.jdk-version }}
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ 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') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Check Java formatting (Spotless)
@@ -155,7 +153,7 @@ jobs:
STIRLING_FLAVOR: ${{ matrix.flavor }}
# Configure the Gradle daemon explicitly; GRADLE_OPTS alone only
# configures the Gradle client JVM.
GRADLE_OPTS: '-Dorg.gradle.jvmargs=-Xmx4g -XX:+UseG1GC'
GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx4g -XX:+UseG1GC"
- name: Check Test Reports Exist
if: always()
+28 -11
View File
@@ -15,6 +15,11 @@ name: Enterprise E2E (Playwright)
on:
workflow_call:
inputs:
use_shared_cache:
required: false
type: boolean
default: false
push:
branches: ["main"]
schedule:
@@ -37,6 +42,7 @@ jobs:
uses: ./.github/workflows/_runner-pick.yml
playwright-e2e-enterprise:
environment: ci-unsigned
needs: pick
# Skip on fork PRs / untrusted authors: they have no PREMIUM_KEY_ENTERPRISE,
# so the suite can't boot premium and would fail. See the header comment.
@@ -50,26 +56,36 @@ jobs:
SYSTEM_ENABLEANALYTICS: "false"
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
- name: Restore cache Gradle User Home
if: inputs.use_shared_cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
path: |
~/.gradle/caches
~/.gradle/wrapper
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: Restore cache Gradle
if: inputs.use_shared_cache == false
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ 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') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
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
with:
java-version: "25"
distribution: "temurin"
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
@@ -309,6 +325,7 @@ jobs:
# Multi-node regression: builds + seeds the clustered stack (testing/compose/docker-compose-multinode.yml)
# and runs behave features/multinode. Licence-gated, so it runs after the Playwright job (not in parallel).
multinode-e2e:
environment: ci-unsigned
needs: [pick, playwright-e2e-enterprise]
# Nightly cron + manual dispatch only (heavy build), fork-gated for the licence secret.
if: >-
@@ -324,7 +341,7 @@ jobs:
MN_COMPOSE: docker-compose-multinode.yml
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Checkout repository
+50 -18
View File
@@ -48,53 +48,73 @@ jobs:
proprietary: ${{ steps.changes.outputs.proprietary }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check for file changes
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
id: changes
with:
filters: .github/config/.files.yaml
gradle-cache-prime:
environment: ci-unsigned
name: Prime shared Gradle cache
needs: [files-changed]
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
- name: Calculate Gradle cache key
id: gradle-cache-key
shell: bash
run: |
echo "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') }}" >> "$GITHUB_OUTPUT"
- name: Cache Gradle (lookup-only)
id: cache-gradle-restore
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ 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') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
key: ${{ steps.gradle-cache-key.outputs.key }}
lookup-only: true
- name: Set up JDK 25
if: steps.cache-gradle-restore.outputs.cache-hit != 'true'
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Resolve backend dependencies
run: ./gradlew :stirling-pdf:classes -PnoSpotless --no-daemon
if: steps.cache-gradle-restore.outputs.cache-hit != 'true'
run: ./gradlew :stirling-pdf:classes --no-daemon
env:
STIRLING_FLAVOR: saas
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
- name: Save cache Gradle User Home
if: steps.cache-gradle-restore.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ steps.gradle-cache-key.outputs.key }}
build:
needs: [files-changed, gradle-cache-prime]
permissions:
@@ -169,6 +189,8 @@ jobs:
contents: read
uses: ./.github/workflows/build-enterprise.yml
secrets: inherit
with:
use_shared_cache: true
check-licence:
if: needs.files-changed.outputs.build == 'true'
@@ -192,7 +214,14 @@ jobs:
test-build-docker-images:
if: github.event_name == 'pull_request' && needs.files-changed.outputs.project == 'true'
needs: [files-changed, build, check-generateOpenApiDocs, check-licence, gradle-cache-prime]
needs:
[
files-changed,
build,
check-generateOpenApiDocs,
check-licence,
gradle-cache-prime,
]
permissions:
contents: read
packages: read
@@ -204,7 +233,7 @@ jobs:
tauri-build:
if: needs.files-changed.outputs.tauri == 'true'
needs: [files-changed]
needs: [files-changed, gradle-cache-prime]
permissions:
contents: read
pull-requests: write
@@ -218,6 +247,7 @@ jobs:
with:
platform: windows-macos
sign: true
use_shared_cache: true
ai-engine:
if: needs.files-changed.outputs.engine == 'true'
@@ -241,6 +271,8 @@ jobs:
pull-requests: write
uses: ./.github/workflows/check-generated-models.yml
secrets: inherit
with:
use_shared_cache: true
pre-commit:
needs: [files-changed]
@@ -306,7 +338,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
+22 -10
View File
@@ -9,6 +9,11 @@ name: Check generated models
# post-merge safety net.
on:
workflow_call:
inputs:
use_shared_cache:
required: false
type: boolean
default: false
push:
branches: [main]
@@ -23,7 +28,7 @@ jobs:
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -39,22 +44,29 @@ jobs:
engine/uv.lock
cache-suffix: generated-models
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
- name: Restore cache Gradle User Home
if: inputs.use_shared_cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
java-version: "25"
distribution: "temurin"
path: |
~/.gradle/caches
~/.gradle/wrapper
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: Cache Gradle User Home
- name: Restore cache Gradle
if: inputs.use_shared_cache == false
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ 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') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
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
with:
java-version: "25"
distribution: "temurin"
- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+10 -12
View File
@@ -10,33 +10,31 @@ permissions:
jobs:
check-licence:
environment: ci-unsigned
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore cache Gradle User Home
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
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
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ 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') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Check licenses for compatibility
+10 -12
View File
@@ -11,33 +11,31 @@ permissions:
jobs:
check-generate-openapi-docs:
environment: ci-unsigned
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore cache Gradle User Home
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
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
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ 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') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Generate OpenAPI documentation
+2 -2
View File
@@ -28,7 +28,7 @@ jobs:
pull-requests: write # Allow writing to pull requests
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -125,7 +125,7 @@ jobs:
const changedFiles = files
.filter(file =>
file.status !== "removed" &&
/^frontend\/public\/locales\/[a-zA-Z-]+\/translation\.toml$/.test(file.filename)
/^frontend\/editor\/public\/locales\/[a-zA-Z-]+\/translation\.toml$/.test(file.filename)
)
.map(file => file.filename);
+9 -12
View File
@@ -34,29 +34,26 @@ jobs:
timeout-minutes: 15
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore cache Gradle User Home
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
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
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ 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') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
+10 -12
View File
@@ -13,34 +13,32 @@ permissions:
jobs:
migration-test:
environment: ci-unsigned
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore cache Gradle User Home
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
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
with:
java-version: 25
distribution: temurin
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ 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') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
# Keep the normal formatting path here so this smoke test exercises the
# same Gradle configuration as the backend build.
- name: Build Stirling-PDF JAR
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
-209
View File
@@ -1,209 +0,0 @@
name: Auto V2 Deploy on Push
on:
push:
branches:
- V2
- deploy-on-v2-commit
permissions:
contents: read
jobs:
deploy-v2-on-push:
environment: pr-preview
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
concurrency:
group: deploy-v2-push-V2
cancel-in-progress: true
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Get commit hashes for frontend and backend
id: commit-hashes
run: |
# Get last commit that touched the frontend folder, docker/frontend, or docker/compose
FRONTEND_HASH=$(git log -1 --format="%H" -- frontend/ docker/frontend/ docker/compose/ 2>/dev/null || echo "")
if [ -z "$FRONTEND_HASH" ]; then
FRONTEND_HASH="no-frontend-changes"
fi
# Get last commit that touched backend code, docker/backend, or docker/compose
BACKEND_HASH=$(git log -1 --format="%H" -- app/ docker/backend/ docker/compose/ 2>/dev/null || echo "")
if [ -z "$BACKEND_HASH" ]; then
BACKEND_HASH="no-backend-changes"
fi
echo "Frontend hash: $FRONTEND_HASH"
echo "Backend hash: $BACKEND_HASH"
echo "frontend_hash=$FRONTEND_HASH" >> $GITHUB_OUTPUT
echo "backend_hash=$BACKEND_HASH" >> $GITHUB_OUTPUT
# Short hashes for tags
if [ "$FRONTEND_HASH" = "no-frontend-changes" ]; then
echo "frontend_short=no-frontend" >> $GITHUB_OUTPUT
else
echo "frontend_short=${FRONTEND_HASH:0:8}" >> $GITHUB_OUTPUT
fi
if [ "$BACKEND_HASH" = "no-backend-changes" ]; then
echo "backend_short=no-backend" >> $GITHUB_OUTPUT
else
echo "backend_short=${BACKEND_HASH:0:8}" >> $GITHUB_OUTPUT
fi
- name: Convert repository owner to lowercase
id: repoowner
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
- name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Check if frontend image exists
id: check-frontend
run: |
if docker manifest inspect ${IMAGE_BASE}:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} >/dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "Frontend image already exists, skipping build"
else
echo "exists=false" >> $GITHUB_OUTPUT
echo "Frontend image needs to be built"
fi
env:
IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test
- name: Check if backend image exists
id: check-backend
run: |
if docker manifest inspect ${IMAGE_BASE}:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} >/dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "Backend image already exists, skipping build"
else
echo "exists=false" >> $GITHUB_OUTPUT
echo "Backend image needs to be built"
fi
env:
IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test
- name: Build and push frontend image
if: steps.check-frontend.outputs.exists == 'false'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./docker/frontend/Dockerfile
push: true
cache-from: type=gha,scope=stirling-v2-frontend
cache-to: type=gha,mode=max,scope=stirling-v2-frontend
tags: |
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:v2-frontend-latest
build-args: VERSION_TAG=v2-alpha
platforms: linux/amd64
- name: Build and push backend image
if: steps.check-backend.outputs.exists == 'false'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./docker/backend/Dockerfile
push: true
cache-from: type=gha,scope=stirling-v2-backend
cache-to: type=gha,mode=max,scope=stirling-v2-backend
tags: |
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:v2-backend-latest
build-args: VERSION_TAG=v2-alpha
platforms: linux/amd64
- name: Set up SSH
run: |
mkdir -p ~/.ssh/
echo "${NEW_VPS_SSH_KEY}" > ../private.key
chmod 600 ../private.key
env:
NEW_VPS_SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }}
- name: Deploy to VPS on port 3000
run: |
export UNIQUE_NAME=docker-compose-v2-$GITHUB_RUN_ID.yml
cat > $UNIQUE_NAME << EOF
version: '3.3'
services:
backend:
container_name: stirling-v2-backend
image: ${IMAGE_BASE}:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
ports:
- "13000:8080"
volumes:
- /stirling/V2/data:/usr/share/tessdata:rw
- /stirling/V2/config:/configs:rw
- /stirling/V2/logs:/logs:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "true"
SECURITY_ENABLELOGIN: "false"
SYSTEM_DEFAULTLOCALE: en-US
UI_APPNAME: "Stirling-PDF V2"
UI_HOMEDESCRIPTION: "V2 Frontend/Backend Split"
UI_APPNAMENAVBAR: "V2 Deployment"
SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "false"
SWAGGER_SERVER_URL: "https://demo.stirlingpdf.cloud"
baseUrl: "https://demo.stirlingpdf.cloud"
restart: on-failure:5
frontend:
container_name: stirling-v2-frontend
image: ${IMAGE_BASE}:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
ports:
- "3000:80"
environment:
VITE_API_BASE_URL: "http://${NEW_VPS_HOST}:13000"
depends_on:
- backend
restart: on-failure:5
EOF
# Copy to remote with unique name
scp -i ../private.key -o StrictHostKeyChecking=no $UNIQUE_NAME ${NEW_VPS_USERNAME}@${NEW_VPS_HOST}:/tmp/$UNIQUE_NAME
# SSH and rename/move atomically to avoid interference
ssh -i ../private.key -o StrictHostKeyChecking=no ${NEW_VPS_USERNAME}@${NEW_VPS_HOST} << ENDSSH
mkdir -p /stirling/V2/{data,config,logs}
mv /tmp/$UNIQUE_NAME /stirling/V2/docker-compose.yml
cd /stirling/V2
docker-compose down || true
docker-compose pull
docker-compose up -d
docker system prune -af --volumes || true
docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
ENDSSH
env:
IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test
NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
NEW_VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }}
- name: Cleanup temporary files
if: always()
run: |
rm -f ../private.key
+10 -12
View File
@@ -17,6 +17,7 @@ permissions:
jobs:
docker-compose-tests:
environment: ci-unsigned
runs-on: ubuntu-latest
permissions:
actions: write
@@ -25,30 +26,27 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore cache Gradle User Home
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
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
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ 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') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
# When the PR changes the base image, test.sh builds it locally
# (stirling-pdf-base:local) into the daemon image store. A buildx
# container builder can't see that store, so skip it here and let
+12 -29
View File
@@ -11,48 +11,31 @@ permissions:
jobs:
playwright-e2e-live:
environment: ci-unsigned
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore cache Gradle User Home
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
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
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ 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') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
# Gradle does not retry 429s, and a cold cache resolving the buildscript
# classpath is exactly where Maven Central rate-limits us. Retry it here,
# where a failure is cheap, instead of inside the backgrounded bootRun.
- name: Prime Gradle dependencies
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
run: |
for attempt in 1 2 3; do
if ./gradlew --quiet -PnoSpotless :stirling-pdf:classes; then
exit 0
fi
echo "::warning::Gradle dependency resolution failed (attempt $attempt of 3)"
sleep $((attempt * 30))
done
echo "::error::Gradle could not resolve dependencies after 3 attempts"
exit 1
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
project: stubbed-webkit
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Checkout repository
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
timeout-minutes: 25
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
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@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -36,12 +36,14 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check for file changes
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
id: changes
with:
filters: .github/config/.files.yaml
generate-frontend-license-report:
# ci-bot, not bot-identity: this job runs on PRs too, and bot-identity is main-only.
environment: ci-bot
if: needs.files-changed.outputs.licenses-frontend == 'true'
name: Generate Frontend License Report
needs: files-changed
@@ -52,7 +54,7 @@ jobs:
repository-projects: write # Required for enabling automerge
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -316,6 +318,8 @@ jobs:
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
generate-backend-license-report:
# ci-bot, not bot-identity: this job runs on PRs too, and bot-identity is main-only.
environment: ci-bot
if: needs.files-changed.outputs.licenses-backend == 'true'
needs: files-changed
name: Generate Backend License Report
@@ -326,7 +330,7 @@ jobs:
repository-projects: write # Required for enabling automerge
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -344,22 +348,19 @@ jobs:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
- name: Cache Gradle
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ 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') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
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
with:
java-version: "25"
distribution: "temurin"
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Checkout repository
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
issues: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
+44 -84
View File
@@ -38,6 +38,7 @@ permissions:
jobs:
determine-matrix:
environment: ci-unsigned
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest
outputs:
@@ -45,29 +46,26 @@ jobs:
version: ${{ steps.versionNumber.outputs.versionNumber }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Cache Gradle
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
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
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ 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') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Get version number
@@ -118,6 +116,7 @@ jobs:
env:
INPUT_PLATFORM: ${{ github.event.inputs.platform }}
build-jars:
environment: ci-unsigned
needs: determine-matrix
runs-on: ubuntu-latest
strategy:
@@ -137,29 +136,26 @@ jobs:
file_suffix: "-server"
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Cache Gradle
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
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
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ 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') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Setup Node.js
if: matrix.variant.build_frontend == true
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@@ -204,11 +200,10 @@ jobs:
runs-on: ${{ matrix.platform }}
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
allowed-endpoints: >
@@ -237,6 +232,14 @@ jobs:
toolchain: stable
targets: ${{ matrix.platform == 'macos-15' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Cache Gradle
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
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') }}
# 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
@@ -260,17 +263,6 @@ jobs:
java-version: "25"
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ 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') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
@@ -295,7 +287,7 @@ jobs:
# DigiCert KeyLocker Setup (Cloud HSM)
- name: Setup DigiCert KeyLocker
id: digicert-setup
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
@@ -305,7 +297,7 @@ jobs:
SM_HOST: ${{ secrets.SM_HOST }}
- name: Setup DigiCert KeyLocker Certificate
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
shell: pwsh
run: |
Write-Host "Setting up DigiCert KeyLocker environment..."
@@ -344,40 +336,8 @@ jobs:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }}
SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
- name: Import Windows Code Signing Certificate
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY == '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
shell: powershell
run: |
if ($env:WINDOWS_CERTIFICATE) {
Write-Host "Importing Windows Code Signing Certificate..."
# Decode base64 certificate and save to file
$certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)
$certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx"
[IO.File]::WriteAllBytes($certPath, $certBytes)
# Import certificate to CurrentUser\My store
$cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force)
# Extract and set thumbprint as environment variable
$thumbprint = $cert.Thumbprint
Write-Host "Certificate imported with thumbprint: $thumbprint"
echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV
# Clean up certificate file
Remove-Item $certPath
Write-Host "Windows certificate import completed."
} else {
Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary"
}
- name: Import Apple Developer Certificate
if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
@@ -398,7 +358,7 @@ jobs:
rm certificate.p12
- name: Verify Certificate
if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')
run: |
echo "Verifying Apple Developer Certificate..."
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
@@ -414,7 +374,7 @@ jobs:
# Without this, signCommand failures are opaque (Tauri captures but drops
# smctl's stderr) - running these loudly surfaces auth/env/keypair issues.
- name: Preflight smctl
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
shell: pwsh
env:
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
@@ -445,7 +405,7 @@ jobs:
# smctl reads SM_HOST, SM_API_KEY, SM_CLIENT_CERT_FILE, SM_CLIENT_CERT_PASSWORD
# from env (set by prior DigiCert setup step). No --config-file needed.
- name: Configure Windows code signing
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
shell: bash
env:
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
@@ -466,7 +426,7 @@ jobs:
sed "s/${KEYPAIR_ALIAS}/***/g" ./frontend/editor/src-tauri/tauri.windows.conf.json
- name: Import release GPG signing key (Linux)
if: matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
if: matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')
run: |
echo "$RELEASE_GPG_PRIVATE_KEY" | gpg --batch --import
gpg --list-secret-keys --keyid-format=long
@@ -498,8 +458,8 @@ jobs:
# APPIMAGETOOL_SIGN_PASSPHRASE appimagetool uses this to unlock the GPG key non-interactively
# SIGN_KEY appimagetool picks the key matching this fingerprint
# Without SIGN=1, the other two are ignored and the AppImage is built unsigned even if a key is present.
# Mirror the Windows/macOS gate: only sign on a real release/dispatch+sign or V2-master, when secret is present.
SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')) && '1' || '0' }}
# Mirror the Windows/macOS gate: only sign on a real release/dispatch+sign or the release branch, when secret is present.
SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')) && '1' || '0' }}
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
@@ -525,7 +485,7 @@ jobs:
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
GPG_SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')) && '1' || '0' }}
GPG_SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')) && '1' || '0' }}
SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }}
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
run: |
@@ -564,7 +524,7 @@ jobs:
echo "Stripped bundled libwayland from $(basename "$AI")"
- name: Clear release GPG key from runner keyring (Linux)
if: always() && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
if: always() && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release')
env:
RELEASE_GPG_FINGERPRINT: ${{ vars.RELEASE_GPG_FINGERPRINT }}
run: |
@@ -579,7 +539,7 @@ jobs:
# artifact. Tauri signs a COPY when bundling into the MSI and leaves the raw
# cargo output unsigned, so checking it produces false negatives.
- name: Verify Windows Code Signature
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
if: ${{ startsWith(matrix.platform, 'windows') && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/release') }}
timeout-minutes: 15
shell: pwsh
run: |
@@ -740,7 +700,7 @@ jobs:
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -911,11 +871,11 @@ jobs:
# workflow_dispatch path requires platform=='all' so a single-platform
# dispatch can't overwrite an existing release's full latest.json with a
# partial one (action-gh-release defaults overwrite_files:true).
# release / V2-master always build the full matrix so no extra guard needed.
# release event / release branch always build the full matrix so no extra guard needed.
# fail_on_unmatched_files makes a missing latest.json or installer fail loudly
# instead of silently shipping a broken auto-update.
- name: Upload binaries to Release
if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true' && github.event.inputs.platform == 'all') || github.event_name == 'release' || github.ref == 'refs/heads/V2-master'
if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true' && github.event.inputs.platform == 'all') || github.event_name == 'release' || github.ref == 'refs/heads/release'
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
tag_name: v${{ needs.determine-matrix.outputs.version }}
+4 -3
View File
@@ -23,7 +23,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
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@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -127,6 +127,7 @@ jobs:
# Runs the @nightly tag (conversion scenarios) plus a 10-shard concurrency run
# of every other feature.
cucumber-nightly:
environment: ci-unsigned
name: Cucumber (nightly scenarios + full concurrency)
runs-on: ubuntu-latest
# Fork pull requests get no MAVEN_* secrets, so the image build cannot work.
@@ -137,7 +138,7 @@ jobs:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
+2 -2
View File
@@ -28,7 +28,7 @@ jobs:
jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -80,7 +80,7 @@ jobs:
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
+1 -1
View File
@@ -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@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
+2 -2
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -34,7 +34,7 @@ jobs:
cache-suffix: pre-commit
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Run pre-commit checks
run: task pre-commit
+4 -1
View File
@@ -17,6 +17,9 @@ permissions:
jobs:
push-base:
# Own environment: docker-publish is branch-locked to release/main,
# which excludes the baseDockerImage/accessIssueFix branches this runs on.
environment: docker-base-publish
if: ${{ vars.CI_PROFILE != 'lite' && github.actor == 'Frooodle' }}
runs-on: ubuntu-24.04-8core
permissions:
@@ -45,7 +48,7 @@ jobs:
echo "version=${VERSION}" >> $GITHUB_OUTPUT
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
+23 -27
View File
@@ -20,9 +20,8 @@ on:
default: false
push:
branches:
- master
- release
- main
- V2-master
# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
@@ -53,29 +52,26 @@ jobs:
RUN_UNOSERVER: ${{ github.event_name != 'workflow_dispatch' || inputs.build_unoserver }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Cache Gradle
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
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
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ 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') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
@@ -91,13 +87,13 @@ jobs:
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
- name: Install cosign
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
if: github.ref == 'refs/heads/release'
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
cosign-release: "v2.4.1"
- name: Install cosign
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
if: github.ref == 'refs/heads/release'
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
cosign-release: "v2.4.1"
@@ -133,8 +129,8 @@ jobs:
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/release' }}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/release' }}
- name: Build and push Unified Dockerfile (latest variant)
id: build-push-latest
@@ -158,7 +154,7 @@ jobs:
sbom: true
- name: Sign regular images
if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master') && steps.build-push-latest.outputs.digest != ''
if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/release') && steps.build-push-latest.outputs.digest != ''
env:
DIGEST: ${{ steps.build-push-latest.outputs.digest }}
TAGS: ${{ steps.meta.outputs.tags }}
@@ -182,8 +178,8 @@ jobs:
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/release' }}
type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/release' }}
- name: Build and push Unified Dockerfile (fat variant)
id: build-push-fat
@@ -204,7 +200,7 @@ jobs:
sbom: true
- name: Sign fat images
if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master') && steps.build-push-fat.outputs.digest != ''
if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/release') && steps.build-push-fat.outputs.digest != ''
env:
DIGEST: ${{ steps.build-push-fat.outputs.digest }}
TAGS: ${{ steps.meta-fat.outputs.tags }}
@@ -226,8 +222,8 @@ jobs:
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
type=raw,value=latest-ultra-lite,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite,enable=${{ github.ref == 'refs/heads/release' }}
type=raw,value=latest-ultra-lite,enable=${{ github.ref == 'refs/heads/release' }}
- name: Build and push Unified Dockerfile (ultra-lite variant)
id: build-push-lite
@@ -248,7 +244,7 @@ jobs:
sbom: true
- name: Sign ultra-lite images
if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master') && steps.build-push-lite.outputs.digest != ''
if: env.RUN_MAIN_APP == 'true' && (github.ref == 'refs/heads/release') && steps.build-push-lite.outputs.digest != ''
env:
DIGEST: ${{ steps.build-push-lite.outputs.digest }}
TAGS: ${{ steps.meta-lite.outputs.tags }}
@@ -260,7 +256,7 @@ jobs:
done
# Standalone unoserver image — versioned independently via
# docker/unoserver/VERSION. master/V2-master: publish <version>+latest
# docker/unoserver/VERSION. release: publish <version>+latest
# only when the version is new. main/testMain: republish :alpha only
# when the source hash differs from the published image's annotation.
- name: Read unoserver image version
@@ -319,7 +315,7 @@ jobs:
fi
case "$EFFECTIVE_REF" in
refs/heads/master|refs/heads/V2-master)
refs/heads/release)
if [ "${FORCE_REBUILD}" = "true" ]; then
echo "force_unoserver_rebuild=true — building stable regardless"
mode="stable"
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
packages: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
+2 -2
View File
@@ -35,7 +35,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -75,6 +75,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
with:
sarif_file: results.sarif
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
+13 -13
View File
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
push:
branches:
- master
- release
# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
@@ -23,33 +23,33 @@ permissions:
jobs:
push:
# package-publish holds SWAGGERHUB_API_KEY. It requires reviewer approval and
# is limited to main / release / v* tags, so every push to release waits on one.
environment: package-publish
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Cache Gradle
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
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
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ 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') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Generate Swagger documentation
run: ./gradlew :stirling-pdf:generateOpenApiDocs
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
+2 -2
View File
@@ -37,7 +37,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -66,7 +66,7 @@ jobs:
uv sync --project engine --locked --group tools
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Sync translation TOML files
run: |
+36 -49
View File
@@ -26,6 +26,10 @@ on:
required: false
type: boolean
default: false
use_shared_cache:
required: false
type: boolean
default: false
workflow_dispatch:
inputs:
platform:
@@ -57,13 +61,16 @@ permissions:
jobs:
determine-matrix:
# Only probes APPLE_CERTIFICATE for presence, so it stays on the unrestricted
# signing environment - release-signing would block every PR run.
environment: ci-signing
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -103,6 +110,12 @@ jobs:
echo "matrix={\"include\":[$JOINED]}" >> $GITHUB_OUTPUT
build:
# Windows/GPG signing only runs on main (see the per-step gates below), so only
# that path needs the reviewer-gated release-signing environment. Everything else
# (PRs, merge queue, nightly) signs macOS only and uses ci-signing, which has no
# approval or branch restriction.
environment:
name: ${{ (inputs.sign && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))) && 'release-signing' || 'ci-signing' }}
needs: determine-matrix
strategy:
fail-fast: false
@@ -110,7 +123,6 @@ jobs:
runs-on: ${{ matrix.platform }}
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }}
# Per-platform sign gate. macOS signs on any run with the cert available,
@@ -120,7 +132,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@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -150,7 +162,7 @@ jobs:
# only recompiles the app crate. Written on main; PRs and the merge queue
# restore from it.
- name: Cache Rust build
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
workspaces: frontend/editor/src-tauri
# Stable key shared across workflows so the nightly warmer.
@@ -160,6 +172,24 @@ jobs:
# Save the dependency cache even if a later step fails
cache-on-failure: true
- name: Restore cache Gradle User Home
if: inputs.use_shared_cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
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: Restore cache Gradle
if: inputs.use_shared_cache == false
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-tauri-build-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 x86_64 JDK 25 (macOS universal JRE)
if: matrix.platform == 'macos-15'
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
@@ -179,17 +209,6 @@ jobs:
java-version: "25"
distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }}
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ 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') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Setup Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
@@ -264,38 +283,6 @@ jobs:
}
}
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
- name: Import Windows Code Signing Certificate
if: ${{ inputs.sign && startsWith(matrix.platform, 'windows') && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
shell: powershell
run: |
if ($env:WINDOWS_CERTIFICATE) {
Write-Host "Importing Windows Code Signing Certificate..."
# Decode base64 certificate and save to file
$certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)
$certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx"
[IO.File]::WriteAllBytes($certPath, $certBytes)
# Import certificate to CurrentUser\My store
$cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force)
# Extract and set thumbprint as environment variable
$thumbprint = $cert.Thumbprint
Write-Host "Certificate imported with thumbprint: $thumbprint"
echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV
# Clean up certificate file
Remove-Item $certPath
Write-Host "Windows certificate import completed."
} else {
Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary"
}
- name: Import Apple Developer Certificate
if: env.SIGN_BUNDLE == 'true' && matrix.platform == 'macos-15'
env:
@@ -703,7 +690,7 @@ jobs:
pull-requests: write
steps:
- name: Harden the runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -795,7 +782,7 @@ jobs:
if: always()
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
+11 -13
View File
@@ -37,6 +37,7 @@ jobs:
# spring-security=true matrix entry if `task backend:build` and
# `task backend:build:ci` produce equivalent JARs (verify before wiring).
test-build-docker-images:
environment: ci-unsigned
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -53,7 +54,7 @@ jobs:
cache-scope: stirling-pdf-fat
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
@@ -78,23 +79,20 @@ jobs:
docker system prune -af || true
echo "Disk space after cleanup:" && df -h
- name: Restore cache Gradle User Home
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
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
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ 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') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Build application
@@ -191,7 +189,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
-235
View File
@@ -1,235 +0,0 @@
name: UI test with TestDriverAI
on:
push:
branches: ["master", "UITest", "testdriver"]
# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
# or a pull request is updated.
# It helps to save resources and time by ensuring that only the latest commit is built and tested
# This is particularly useful for long-running jobs that may take a while to complete.
# The `group` is set to a combination of the workflow name, event name, and branch name.
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
deploy:
environment: pr-preview
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up JDK 25
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
java-version: "25"
distribution: "temurin"
- name: Cache Gradle User Home
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ 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') }}
restore-keys: |
gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-
gradle-${{ runner.os }}-${{ runner.arch }}-
- name: Build with Gradle
run: ./gradlew build
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
DISABLE_ADDITIONAL_FEATURES: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Get version number
id: versionNumber
run: |
VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}')
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
- name: Convert repository owner to lowercase
id: repoowner
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
- name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Build and push test image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./docker/embedded/Dockerfile
push: true
cache-from: type=gha,scope=stirling-pdf-latest
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
tags: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test:test-${{ github.sha }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
platforms: linux/amd64
- name: Set up SSH
run: |
mkdir -p ~/.ssh/
echo "${NEW_VPS_SSH_KEY}" > ../private.key
sudo chmod 600 ../private.key
env:
NEW_VPS_SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }}
- name: Deploy to VPS
run: |
cat > docker-compose.yml << EOF
version: '3.3'
services:
stirling-pdf:
container_name: stirling-pdf-test-${{ github.sha }}
image: ${IMAGE_BASE}:test-${{ github.sha }}
ports:
- "1337:8080"
volumes:
- /stirling/test-${{ github.sha }}/data:/usr/share/tessdata:rw
- /stirling/test-${{ github.sha }}/config:/configs:rw
- /stirling/test-${{ github.sha }}/logs:/logs:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "true"
SECURITY_ENABLELOGIN: "false"
SYSTEM_DEFAULTLOCALE: en-US
UI_APPNAME: "Stirling-PDF Test"
UI_HOMEDESCRIPTION: "Test Deployment"
UI_APPNAMENAVBAR: "Test"
SYSTEM_MAXFILESIZE: "100"
METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "false"
SYSTEM_ENABLEANALYTICS: "false"
restart: on-failure:5
EOF
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${NEW_VPS_USERNAME}@${NEW_VPS_HOST}:/tmp/docker-compose.yml
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${NEW_VPS_USERNAME}@${NEW_VPS_HOST} << EOF
mkdir -p /stirling/test-${{ github.sha }}/{data,config,logs}
mv /tmp/docker-compose.yml /stirling/test-${{ github.sha }}/docker-compose.yml
cd /stirling/test-${{ github.sha }}
docker-compose pull
docker-compose up -d
EOF
env:
IMAGE_BASE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-test
NEW_VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }}
NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
files-changed:
if: always()
name: detect what files changed
runs-on: ubuntu-latest
timeout-minutes: 3
outputs:
frontend: ${{ steps.changes.outputs.frontend }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check for file changes
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
id: changes
with:
filters: ".github/config/.files.yaml"
test:
environment: pr-preview
if: needs.files-changed.outputs.frontend == 'true'
needs: [deploy, files-changed]
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Run TestDriver.ai
uses: testdriverai/action@f0d0f45fdd684db628baa843fe9313f3ca3a8aa8 #1.1.3
with:
key: ${{secrets.TESTDRIVER_API_KEY}}
prerun: |
choco install go-task -y
task frontend:build
cd frontend
npm install dashcam-chrome --save
Start-Process "C:/Program Files/Google/Chrome/Application/chrome.exe" -ArgumentList "--start-maximized", "--load-extension=$(pwd)/node_modules/dashcam-chrome/build", "http://${{ secrets.NEW_VPS_HOST }}:1337"
Start-Sleep -Seconds 20
prompt: |
1. /run testing/testdriver/test.yml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FORCE_COLOR: "3"
cleanup:
environment: pr-preview
needs: [deploy, test]
runs-on: ubuntu-latest
if: always()
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Set up SSH
run: |
mkdir -p ~/.ssh/
echo "${NEW_VPS_SSH_KEY}" > ../private.key
sudo chmod 600 ../private.key
env:
NEW_VPS_SSH_KEY: ${{ secrets.NEW_VPS_SSH_KEY }}
- name: Cleanup deployment
if: always()
run: |
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${NEW_VPS_USERNAME}@${NEW_VPS_HOST} << EOF
cd /stirling/test-${{ github.sha }}
docker-compose down
cd /stirling
rm -rf test-${{ github.sha }}
EOF
env:
NEW_VPS_USERNAME: ${{ secrets.NEW_VPS_USERNAME }}
NEW_VPS_HOST: ${{ secrets.NEW_VPS_HOST }}
continue-on-error: true # Ensure cleanup runs even if previous steps fail
+33 -38
View File
@@ -4,6 +4,11 @@ This guide explains how to set up Windows code signing for Stirling-PDF desktop
## Overview
Releases are signed with **DigiCert KeyLocker**, a cloud HSM: the private key never
leaves DigiCert, and the runner signs through a PKCS#11 provider. The older approach
of uploading a base64 `.pfx` to a repository secret has been removed from the
workflows - the sections below describe KeyLocker, which is what actually runs.
Windows code signing is essential for:
- Preventing Windows SmartScreen warnings
- Building trust with users
@@ -49,29 +54,19 @@ openssl pkcs12 -export -out certificate.pfx -inkey private-key.key -in certifica
### Required Secrets
Navigate to your GitHub repository → Settings → Secrets and variables → Actions
Navigate to your GitHub repository → Settings → Environments → `release-signing`.
Add the following secrets:
These live in the `release-signing` environment, not at repository scope. That
environment requires reviewer approval and is limited to `main`, `release`,
`hotfix/*` and `v*` tags. All five come from the DigiCert ONE console.
#### 1. `WINDOWS_CERTIFICATE`
- **Description**: Base64-encoded .pfx certificate file
- **How to create**:
**On macOS/Linux:**
```bash
base64 -i certificate.pfx | pbcopy # Copies to clipboard
```
**On Windows (PowerShell):**
```powershell
[Convert]::ToBase64String([IO.File]::ReadAllBytes("certificate.pfx")) | Set-Clipboard
```
Paste the entire base64 string into the GitHub secret.
#### 2. `WINDOWS_CERTIFICATE_PASSWORD`
- **Description**: Password for the .pfx certificate
- **Value**: The password you set when creating/exporting the .pfx file
| Secret | Description |
| --- | --- |
| `SM_API_KEY` | KeyLocker API key. Also acts as the on/off switch: signing steps are gated on it being non-empty. |
| `SM_CLIENT_CERT_FILE_B64` | Base64-encoded PKCS#12 client authentication certificate. |
| `SM_CLIENT_CERT_PASSWORD` | Password for that client certificate. |
| `SM_KEYPAIR_ALIAS` | Alias of the signing keypair to use. |
| `SM_HOST` | DigiCert ONE host, e.g. `https://clientauth.one.digicert.com`. |
### Optional Secrets for Tauri Updater
@@ -110,23 +105,23 @@ The Windows signing configuration is already set up:
### 2. GitHub Workflow (.github/workflows/tauri-build.yml)
The workflow includes three Windows signing steps:
The workflow includes four Windows signing steps, all gated on `SM_API_KEY` being
set and the ref being the release branch:
1. **Import Certificate**: Decodes and imports the .pfx certificate into Windows certificate store
2. **Build Tauri App**: Builds and signs the application using the imported certificate
3. **Verify Signature**: Validates that both .exe and .msi files are properly signed
1. **Setup DigiCert KeyLocker**: Installs the DigiCert signing tools via `digicert/ssm-code-signing`
2. **Setup DigiCert KeyLocker Certificate**: Writes the client cert and exports the PKCS#11 config
3. **Configure Windows code signing / Build Tauri app**: Signs through the PKCS#11 provider
4. **Verify Windows Code Signature**: Validates that the .exe and .msi are properly signed
## Testing the Setup
### 1. Local Testing (Windows Only)
Before pushing to GitHub, test locally:
KeyLocker is CI-only. To check signing locally, install your own certificate into
the Windows store and point Tauri at it; the build no longer reads any certificate
from an environment variable.
```powershell
# Set environment variables
$env:WINDOWS_CERTIFICATE = [Convert]::ToBase64String([IO.File]::ReadAllBytes("certificate.pfx"))
$env:WINDOWS_CERTIFICATE_PASSWORD = "your-certificate-password"
# Build the application
cd frontend
npm run tauri build
@@ -191,9 +186,10 @@ Look for:
- Consider EV certificate for immediate reputation
### Certificate Not Found During Build
- Verify `WINDOWS_CERTIFICATE` secret is set
- Check base64 encoding is correct (no extra whitespace)
- Ensure password is correct
- Verify `SM_API_KEY` is present in the `release-signing` environment. If it is empty
the signing steps skip silently and the build succeeds unsigned.
- Check `SM_CLIENT_CERT_FILE_B64` base64 encoding is correct (no extra whitespace)
- Ensure `SM_CLIENT_CERT_PASSWORD` and `SM_KEYPAIR_ALIAS` match the DigiCert keypair
## Security Best Practices
@@ -220,11 +216,10 @@ Look for:
## Certificate Lifecycle
### Before Expiration
1. Obtain new certificate from CA (typically annual renewal)
2. Convert to .pfx format if needed
3. Update `WINDOWS_CERTIFICATE` secret with new base64-encoded certificate
4. Update `WINDOWS_CERTIFICATE_PASSWORD` if password changed
5. Test build to verify new certificate works
1. Renew the certificate in the DigiCert ONE console (typically annual)
2. If the keypair alias changed, update `SM_KEYPAIR_ALIAS` in the `release-signing` environment
3. If the client authentication certificate was reissued, update `SM_CLIENT_CERT_FILE_B64` and `SM_CLIENT_CERT_PASSWORD`
4. Test build to verify the new certificate works
### Expired Certificates
- Signed binaries remain valid (timestamp proves signing time)
@@ -22,15 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
/**
* Read and triage recorded failures for the caller's team. Note the absence of a team parameter:
* the team comes from the authenticated principal, never the request.
* Read and triage recorded failures. Note the absence of a team parameter: the team comes from the
* authenticated principal, never the request.
*
* <p>Reviewing failures is a leader-level capability, gated the same way policy editing is: see
* {@link #requireFailureReviewAllowed()}.
* <p>Every endpoint is open to any authenticated user and scoped in the service instead: a leader
* reads and closes the whole team's failures, everyone else their own. Nothing here decides who may
* do what, so the two cannot drift apart.
*/
@Slf4j
@RestController
@@ -46,21 +44,21 @@ public class FileRunEventController {
private static final int DEFAULT_LIMIT = 50;
private final FileRunEventService service;
private final PolicyManagementAuthority policyManagementAuthority;
private final ApplicationProperties applicationProperties;
@GetMapping
@Operation(
summary = "List recorded failures",
description =
"Failures recorded for the caller's team, newest first. Each row carries its"
+ " available actions already resolved.")
"Failures the caller may see, newest first: their team's for a leader, their own"
+ " for everyone else. Each row carries its available actions already"
+ " resolved.")
public FileRunEventsResponse list(
// Spring's converter 400s on a value outside the enum, so no hand-rolled parse.
@RequestParam(required = false) FileRunEventStatus status,
@RequestParam(required = false) String kindId,
@RequestParam(required = false) Integer limit) {
requireFailureReviewAllowed();
// No role gate: the service scopes the read instead, so a member gets their own failures
// and a leader the team's.
int cappedLimit = Math.min(limit == null ? DEFAULT_LIMIT : Math.max(1, limit), MAX_LIMIT);
List<FileRunEventView> events =
@@ -82,7 +80,8 @@ public class FileRunEventController {
@PathVariable String eventId,
@PathVariable String actionId,
@RequestBody(required = false) ActionRequest request) {
requireFailureReviewAllowed();
// No role gate: the service decides, which lets someone close their own failure while
// still keeping a colleague's out of reach.
Map<String, String> inputs = request == null ? Map.of() : request.safeInputs();
try {
FileRunEvent updated = service.dispatch(eventId, actionId, inputs);
@@ -97,9 +96,9 @@ public class FileRunEventController {
summary = "Report a failure hit in the editor",
description =
"For failures the server never sees, because the editor calls tools directly."
+ " Open to any authenticated user, unlike the read and triage endpoints:"
+ " whoever's work failed can say so, and a leader reviews it. Rejected"
+ " with 400 if it names more files than one report may carry.")
+ " Open to any authenticated user: whoever's work failed can say so, and"
+ " reads it back scoped to themselves. Rejected with 400 if it names"
+ " more files than one report may carry.")
public ResponseEntity<Void> report(@RequestBody EditorFailureReport report) {
if (report == null || !report.hasOperation()) {
throw new ResponseStatusException(
@@ -128,8 +127,8 @@ public class FileRunEventController {
summary = "Close the incidents about files deleted from the editor",
description =
"Deleting the document leaves nothing to act on, so its incidents drop out of"
+ " the queue while the rows stay for audit. Open to any authenticated"
+ " user, and applies only to their own editor rows.")
+ " the queue while the rows stay for audit. Applies only to the"
+ " caller's own editor rows, however senior they are.")
public ResponseEntity<Void> filesRemoved(@RequestBody(required = false) RemovedFiles request) {
service.forgetFiles(request == null ? List.of() : request.safeFileIds());
// No body: the editor is telling the server, not asking it anything.
@@ -143,29 +142,11 @@ public class FileRunEventController {
"The failure registry. Lets a client describe kinds it was not built with, and"
+ " doubles as the probe for whether failure tracking exists at all.")
public List<FailureKindView> kinds() {
requireFailureReviewAllowed();
// The registry is copy and metadata, not anyone's data, and a member needs it to render the
// failures they can already see.
return Arrays.stream(FailureKind.values()).map(FailureKindView::of).toList();
}
/**
* Triage is for a team leader (SaaS) or admin (self-hosted), mirroring {@code
* PolicyController.requirePolicyEditingAllowed()} rather than inventing a second notion of who
* manages a team's automation: a member can trigger runs, a leader reviews them.
*
* <p>Login disabled means a single-user deployment with no roles to tell apart, the same
* carve-out the policy endpoints make. Team scoping is separate, and lives in the service.
*/
private void requireFailureReviewAllowed() {
if (!applicationProperties.getSecurity().isEnableLogin()) {
return;
}
if (!policyManagementAuthority.canEditPolicies()) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Recorded failures may only be reviewed by a team leader");
}
}
/**
* A closed row is a conflict rather than a bad request: the request was well-formed and would
* have been valid a moment earlier.
@@ -20,26 +20,33 @@ public interface FileRunEventRepository extends JpaRepository<FileRunEventEntity
* As {@link #findByTeamAndStatus} but for a set of statuses, e.g. the open ones. The kind
* filter is in the query, before the limit: filtering an already-limited page could return
* nothing while matching rows exist.
*
* <p>{@code actor} narrows to one person's own failures. Null means the whole team, which only
* a leader ever asks for: see {@code FileRunEventService#readScope}.
*/
@Query(
"select e from FileRunEventEntity e where ((:teamId is null and e.teamId is null) or"
+ " e.teamId = :teamId) and e.status in :statuses"
+ " and (:kindId is null or e.kindId = :kindId) order by e.lastSeenAt desc")
+ " and (:kindId is null or e.kindId = :kindId)"
+ " and (:actor is null or e.actor = :actor) order by e.lastSeenAt desc")
List<FileRunEventEntity> findByTeamAndStatusIn(
@Param("teamId") Long teamId,
@Param("statuses") List<FileRunEventStatus> statuses,
@Param("kindId") String kindId,
@Param("actor") String actor,
Pageable pageable);
/** As {@link #findByTeamAndStatusIn} but for exactly one status, for the surface's filters. */
@Query(
"select e from FileRunEventEntity e where ((:teamId is null and e.teamId is null) or"
+ " e.teamId = :teamId) and e.status = :status"
+ " and (:kindId is null or e.kindId = :kindId) order by e.lastSeenAt desc")
+ " and (:kindId is null or e.kindId = :kindId)"
+ " and (:actor is null or e.actor = :actor) order by e.lastSeenAt desc")
List<FileRunEventEntity> findByTeamAndStatus(
@Param("teamId") Long teamId,
@Param("status") FileRunEventStatus status,
@Param("kindId") String kindId,
@Param("actor") String actor,
Pageable pageable);
/**
@@ -13,12 +13,14 @@ import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
/**
* Reads and acts on incidents for the calling user's team.
* Reads and acts on the incidents the calling user is allowed to see, which is where that decision
* is made: a leader reads and closes the whole team's failures, everyone else their own. Keeping it
* here rather than on the endpoints means the read and the triage cannot drift apart.
*
* <p>Team scoping mirrors {@code PolicyAccessGuard}: everyone sees only their own team's rows, the
* team always comes from the authenticated principal, and scoping applies only when login is
* enabled so single-user deployments keep working. When the team cannot be resolved the caller
* reads nothing; see {@link #scope()}.
* reads nothing; see {@link #readScope()}.
*/
@Slf4j
@Service
@@ -41,7 +43,9 @@ public class FileRunEventService {
*/
public List<FileRunEvent> report(EditorFailureReport report) {
FailureKind kind = FailureKind.byErrorCode(report.errorCode()).orElse(FailureKind.UNKNOWN);
Long teamId = scope().teamId();
// The caller's team, not their read scope: recording is open to everyone, and a reader who
// may see nothing still has their failure filed under the team it happened in.
Long teamId = currentTeamId();
String actor = currentActor();
String detail = detailFor(report);
@@ -81,10 +85,14 @@ public class FileRunEventService {
* cleared cache or another device never will. Rows left open that way are retention's problem,
* not this method's.
*
* <p>Narrowed to the caller's own rows however senior they are, which is why it passes {@link
* #currentActor()} rather than the read scope's actor: file ids are minted by each client, so a
* leader reading with a null actor would match every unattributed row in the team.
*
* @return how many incidents were closed
*/
public int forgetFiles(List<String> fileIds) {
TeamScope scope = scope();
ReadScope scope = readScope();
if (!scope.permitted()) {
return 0;
}
@@ -92,13 +100,16 @@ public class FileRunEventService {
return store.markFilesRemoved(scope.teamId(), currentActor(), named);
}
/** The calling user's events, newest first. Empty when their team cannot be resolved. */
/**
* The events the caller may read, newest first: the team's for a leader, their own for everyone
* else. Empty when their team cannot be resolved.
*/
public List<FileRunEvent> list(FileRunEventStatus status, String kindId, int limit) {
TeamScope scope = scope();
ReadScope scope = readScope();
if (!scope.permitted()) {
return List.of();
}
return store.list(scope.teamId(), status, kindId, limit);
return store.list(scope.teamId(), status, kindId, scope.actor(), limit);
}
/**
@@ -108,7 +119,13 @@ public class FileRunEventService {
* event's kind does not declare the action, or the event is already closed
*/
public FileRunEvent dispatch(String eventId, String actionId, Map<String, String> inputs) {
TeamScope scope = scope();
// Whoever can see it can close it: a leader for the whole team, everyone else for the
// failures they caused. Someone who fixes their own problem should not have to ask a leader
// to clear the row.
//
// Closing the row is all this covers. Acting on the document behind it, such as supplying a
// password for a retry, would need its own permission, and no such action exists yet.
ReadScope scope = readScope();
if (!scope.permitted()) {
// Reported as "no such event", the same as an id from another team, so the response
// does
@@ -118,6 +135,12 @@ public class FileRunEventService {
}
FileRunEvent event =
store.find(eventId, scope.teamId())
// Reported as "no such event" rather than a refusal, so a member cannot
// learn that a colleague's incident exists by trying to close it.
.filter(
found ->
scope.actor() == null
|| scope.actor().equals(found.actor()))
.orElseThrow(
() ->
new FailureActionException(
@@ -179,31 +202,55 @@ public class FileRunEventService {
}
/**
* Which rows the caller may touch, since a null team id means two different things. Login
* disabled is the self-hosted setup with no users or teams, where unteamed rows are everyone's,
* as {@code PolicyAccessGuard} also treats them. Login enabled with no resolvable team reads
* nothing, because unteamed rows there are shared by every team's ad-hoc runs.
* Which rows the caller may read. A leader reviews the whole team's, as before. Everyone else
* reads the failures they caused themselves: a member can already report one, so letting them
* see their own back is what makes telling them about it worth anything, and it exposes nothing
* of a colleague's.
*
* <p>A null team id means two different things. Login disabled is the self-hosted setup with no
* users or teams, where unteamed rows are everyone's, as {@code PolicyAccessGuard} also treats
* them. Login enabled with no resolvable team reads nothing, because unteamed rows there are
* shared by every team's ad-hoc runs.
*/
private TeamScope scope() {
private ReadScope readScope() {
if (!enforced()) {
return TeamScope.of(null);
return ReadScope.wholeTeam(null);
}
Long teamId = policyManagementAuthority.currentUserTeamId();
return teamId == null ? TeamScope.denied() : TeamScope.of(teamId);
Long teamId = currentTeamId();
if (teamId == null) {
return ReadScope.denied();
}
if (policyManagementAuthority.canEditPolicies()) {
return ReadScope.wholeTeam(teamId);
}
// Narrowing to "mine" needs a name to narrow by. Without one the filter would be dropped
// and a member would read the whole team, so refuse rather than widen.
String actor = currentActor();
return actor == null ? ReadScope.denied() : ReadScope.mine(teamId, actor);
}
/**
* The caller's readable team, or a refusal. {@code teamId} is only meaningful when permitted.
* What the caller may read. {@code actor} is the person to narrow to, or null for the whole
* team; both are only meaningful when permitted.
*/
private record TeamScope(boolean permitted, Long teamId) {
private record ReadScope(boolean permitted, Long teamId, String actor) {
static TeamScope of(Long teamId) {
return new TeamScope(true, teamId);
static ReadScope wholeTeam(Long teamId) {
return new ReadScope(true, teamId, null);
}
static TeamScope denied() {
return new TeamScope(false, null);
static ReadScope mine(Long teamId, String actor) {
return new ReadScope(true, teamId, actor);
}
static ReadScope denied() {
return new ReadScope(false, null, null);
}
}
/** The team a row belongs to, which is nobody's when there are no teams to belong to. */
private Long currentTeamId() {
return enforced() ? policyManagementAuthority.currentUserTeamId() : null;
}
private String currentActor() {
@@ -121,16 +121,19 @@ public class FileRunEventStore {
*
* <p>Both filters live in the query, before the limit: filtering an already-limited page could
* return nothing while matching rows exist.
*
* <p>{@code actor} narrows to one person's own failures, or reads the whole team when null. Who
* gets which is the service's decision, not this method's.
*/
@Transactional(readOnly = true)
public List<FileRunEvent> list(
Long teamId, FileRunEventStatus status, String kindId, int limit) {
Long teamId, FileRunEventStatus status, String kindId, String actor, int limit) {
Pageable page = PageRequest.of(0, Math.max(1, limit));
List<FileRunEventEntity> rows =
status == null
? repository.findByTeamAndStatusIn(
teamId, FileRunEventStatus.open(), kindId, page)
: repository.findByTeamAndStatus(teamId, status, kindId, page);
teamId, FileRunEventStatus.open(), kindId, actor, page)
: repository.findByTeamAndStatus(teamId, status, kindId, actor, page);
return rows.stream().map(FileRunEvent::of).toList();
}
@@ -130,7 +130,15 @@ public class PolicyEngine {
// worker.
String principal = currentActingPrincipal();
return submitForPrincipal(
principal, principal, policyId, definition, inputs, listener, null, null);
principal,
principal,
principal,
policyId,
definition,
inputs,
listener,
null,
null);
}
/** Run a stored policy on demand. {@code enabled} gates triggers, not explicit runs. */
@@ -158,6 +166,10 @@ public class PolicyEngine {
// they can download their enforced file; otherwise an org-wide policy's output is owned by
// the admin and the triggering user is denied it. Trigger-fired runs have no such user, so
// the owner owns those outputs.
//
// The triggering user is also carried on the run, as the actor of any failure it records:
// null for a trigger-fired run, which is what makes an unattended incident ownerless rather
// than the owner's problem. Three identities, deliberately not interchangeable.
String triggeringUser = currentActingPrincipal();
String fileOwner = triggeringUser != null ? triggeringUser : policy.owner();
// Stored supporting files (certificates, watermark images, ...) load here, before the
@@ -172,6 +184,7 @@ public class PolicyEngine {
return submitForPrincipal(
policy.owner(),
fileOwner,
triggeringUser,
policy.id(),
definition,
// main's asset-resolved inputs, not the raw ones: stored certificates and watermark
@@ -185,6 +198,7 @@ public class PolicyEngine {
private PolicyRunHandle submitForPrincipal(
String billingPrincipal,
String fileOwner,
String triggeringUser,
String policyId,
PipelineDefinition definition,
PolicyInputs inputs,
@@ -199,7 +213,8 @@ public class PolicyEngine {
if (policyId != null) {
taskManager.putMetadata(runId, "policyId", policyId);
}
PolicyRun run = new PolicyRun(runId, policyId, definition, sourceId, fileIdentity);
PolicyRun run =
new PolicyRun(runId, policyId, definition, sourceId, fileIdentity, triggeringUser);
registry.register(run);
CompletableFuture<PolicyRun> completion = new CompletableFuture<>();
PolicyProgressListener tracking = trackingListener(runId, run, listener);
@@ -364,12 +379,14 @@ public class PolicyEngine {
taskManager.setError(run.getRunId(), message);
// No exception to classify here: nothing was thrown by a tool, the run simply was not
// admitted. Record it explicitly so a run lost to load pressure is still accounted for.
// Attributed like any other failure: a user whose run was refused is still the person
// holding that document, and an unattended sweep's run carries no triggering user.
failureRecorder.recordRunFailureAs(
FailureKind.UNKNOWN,
run.getRunId(),
run.getPolicyId(),
run.getSourceId(),
null,
run.getTriggeringUser(),
message);
completion.complete(run);
}
@@ -379,6 +396,11 @@ public class PolicyEngine {
/**
* Record why a run failed. Called after the run's own state transition and task-manager update,
* so a recording problem cannot change the outcome the caller observes.
*
* <p>The actor is the run's triggering user, not the MDC audit principal: that carries the
* BILLING identity, which for a stored policy is always its owner. Reading it here filed every
* failure under the owner — hiding an attended failure from the member who caused it and holds
* the document, and leaving an unattended sweep's failure looking attended.
*/
private void recordFailure(PolicyRun run, String message, Throwable cause) {
failureRecorder.recordRunFailure(
@@ -386,7 +408,7 @@ public class PolicyEngine {
run.getPolicyId(),
run.getSourceId(),
run.getFileIdentity(),
MDC.get(AUDIT_PRINCIPAL_MDC_KEY),
run.getTriggeringUser(),
message,
cause);
}
@@ -35,6 +35,14 @@ public class PolicyRun {
*/
private final String fileIdentity;
/**
* The user who triggered this run, or null when nothing attended it (a trigger-fired sweep).
* Recorded as a failure's actor, so an attended failure is handed to the person holding the
* document. Deliberately not the billing principal: a shared policy is billed to its owner, who
* may never have touched the file.
*/
private final String triggeringUser;
private final Instant createdAt = Instant.now();
private volatile PolicyRunStatus status = PolicyRunStatus.PENDING;
@@ -63,22 +71,24 @@ public class PolicyRun {
private volatile Instant updatedAt = Instant.now();
/**
* Both references are required rather than defaulted: a run with neither is a real case (a
* user's upload, an ad-hoc pipeline), but it should be stated at the call site. Overloads that
* omitted them would make losing the attribution the frictionless option, which is how both
* fields went unpopulated in the first place.
* All three attribution references are required rather than defaulted: a run with none is a
* real case (an unattended sweep of a generator pipeline), but it should be stated at the call
* site. Overloads that omitted them would make losing the attribution the frictionless option,
* which is how {@code sourceId} and {@code fileIdentity} went unpopulated in the first place.
*/
public PolicyRun(
String runId,
String policyId,
PipelineDefinition definition,
String sourceId,
String fileIdentity) {
String fileIdentity,
String triggeringUser) {
this.runId = runId;
this.policyId = policyId;
this.sourceId = sourceId;
this.definition = definition;
this.fileIdentity = fileIdentity;
this.triggeringUser = triggeringUser;
}
public int stepCount() {
@@ -51,9 +51,7 @@ class FileRunEventControllerTest {
List.of(new AcknowledgeAction(store), new DismissAction(store)));
controller =
new FileRunEventController(
new FileRunEventService(store, registry, authority, userService, props),
authority,
props);
new FileRunEventService(store, registry, authority, userService, props));
lenient().when(authority.canEditPolicies()).thenReturn(true);
lenient().when(authority.currentUserTeamId()).thenReturn(TEAM);
@@ -61,14 +59,27 @@ class FileRunEventControllerTest {
}
private FileRunEvent given(FailureKind kind, Long teamId, String fileId) {
return recorded("author@example.com", kind, teamId, fileId, "run-1");
}
/**
* As {@link #given} but naming who hit it, in its own run. A RUN-scoped kind keys on the run,
* so two rows sharing one run id are one incident, however they differ otherwise.
*/
private FileRunEvent givenHitBy(String actor, FailureKind kind, Long teamId, String fileId) {
return recorded(actor, kind, teamId, fileId, "run-" + fileId);
}
private FileRunEvent recorded(
String actor, FailureKind kind, Long teamId, String fileId, String runId) {
return store.record(
new RecordFailure(
kind,
FailureOrigin.POLICY,
teamId,
"author@example.com",
actor,
"policy-1",
"run-1",
runId,
null,
fileId,
"the raw failure message"));
@@ -281,48 +292,68 @@ class FileRunEventControllerTest {
}
@Nested
@DisplayName("only a team leader may review failures")
@DisplayName("a leader reviews the team's failures, everyone else their own")
class Authorization {
@Test
void aMemberCannotListThem() {
void aMemberSeesTheirOwnFailuresAndNobodyElses() {
// A member can report a failure, so they get to see it back. It must not widen to a
// colleague's.
givenHitBy("reviewer@example.com", FailureKind.UNKNOWN, TEAM, "mine");
givenHitBy("colleague@example.com", FailureKind.UNKNOWN, TEAM, "theirs");
when(authority.canEditPolicies()).thenReturn(false);
assertThatThrownBy(() -> controller.list(null, null, null))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
e ->
assertThat(((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.FORBIDDEN));
assertThat(controller.list(null, null, null).events())
.extracting(FileRunEventView::fileId)
.containsExactly("mine");
}
@Test
void aMemberCannotDispatchAnAction() {
// The read being refused is not enough on its own: an id learned any other way must
// not let a member close another user's failure.
FileRunEvent event = given(FailureKind.UNKNOWN, TEAM, "f-1");
when(authority.canEditPolicies()).thenReturn(false);
void aLeaderSeesTheWholeTeams() {
givenHitBy("reviewer@example.com", FailureKind.UNKNOWN, TEAM, "mine");
givenHitBy("colleague@example.com", FailureKind.UNKNOWN, TEAM, "theirs");
when(authority.canEditPolicies()).thenReturn(true);
assertThatThrownBy(() -> controller.act(event.id(), "DISMISS", null))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
e ->
assertThat(((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.FORBIDDEN));
assertThat(controller.list(null, null, null).events())
.extracting(FileRunEventView::fileId)
.containsExactlyInAnyOrder("mine", "theirs");
}
@Test
void theRegistryIsAlsoLeaderOnly() {
void aMemberMayCloseTheirOwn() {
// Someone who fixes their own problem should not have to ask a leader to clear the row.
FileRunEvent mine =
givenHitBy("reviewer@example.com", FailureKind.UNKNOWN, TEAM, "mine");
when(authority.canEditPolicies()).thenReturn(false);
assertThatThrownBy(() -> controller.kinds())
.isInstanceOf(ResponseStatusException.class);
assertThat(controller.act(mine.id(), "DISMISS", null).status())
.isEqualTo(FileRunEventStatus.DISMISSED);
}
@Test
void aMemberCannotCloseAColleaguesEvenKnowingTheId() {
// Refusing the read is not enough on its own: an id learned any other way must not work
// either. Answered as not-found rather than forbidden, so trying does not confirm the
// row exists.
FileRunEvent theirs =
givenHitBy("colleague@example.com", FailureKind.UNKNOWN, TEAM, "theirs");
when(authority.canEditPolicies()).thenReturn(false);
assertThat(statusOf(() -> controller.act(theirs.id(), "DISMISS", null)))
.isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
void theRegistryIsOpenBecauseItIsCopyNotData() {
// A member renders the failures they can see, so they need the labels for them. No role
// stub: the point is that kinds() never asks.
assertThat(controller.kinds()).isNotEmpty();
}
@Test
void loginDisabledTrustsTheLocalOperator() {
// A single-user deployment has no roles to distinguish, so the role gate must not lock
// the only user out of their own failures.
// A single-user deployment has no roles to distinguish, so the narrowing must not leave
// the only user reading nothing.
ApplicationProperties unsecured = new ApplicationProperties();
unsecured.getSecurity().setEnableLogin(false);
FileRunEventController noLogin =
@@ -335,9 +366,7 @@ class FileRunEventControllerTest {
new DismissAction(store))),
authority,
userService,
unsecured),
authority,
unsecured);
unsecured));
assertThatCode(() -> noLogin.list(null, null, null)).doesNotThrowAnyException();
// Not merely permitted: the role is never consulted at all, which is what makes the
@@ -382,9 +411,7 @@ class FileRunEventControllerTest {
new DismissAction(store))),
authority,
userService,
unsecured),
authority,
unsecured);
unsecured));
given(FailureKind.UNKNOWN, null, "unteamed");
given(FailureKind.UNKNOWN, TEAM, "teamed");
@@ -399,9 +426,9 @@ class FileRunEventControllerTest {
class Reporting {
@Test
void aMemberMayReportEvenThoughTheyMayNotRead() {
// The asymmetry is the point: anyone whose work failed can say so, but only a leader
// reviews the queue.
void aMemberMayReportAndThenSeeTheirOwnReport() {
// Reporting was always open to a member; reading their own back is the round trip that
// makes the report worth anything to them.
when(authority.canEditPolicies()).thenReturn(false);
assertThatCode(
@@ -410,8 +437,9 @@ class FileRunEventControllerTest {
new EditorFailureReport(
"compress", "E004", List.of("f-1"), "boom")))
.doesNotThrowAnyException();
assertThatThrownBy(() -> controller.list(null, null, null))
.isInstanceOf(ResponseStatusException.class);
assertThat(controller.list(null, null, null).events())
.extracting(FileRunEventView::fileId)
.containsExactly("f-1");
}
@Test
@@ -441,7 +469,7 @@ class FileRunEventControllerTest {
new EditorFailureReport("compress", "E004", atLimit, "boom");
assertThat(controller.report(report).getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
assertThat(store.list(TEAM, null, null, EditorFailureReport.MAX_FILE_IDS + 10))
assertThat(store.list(TEAM, null, null, null, EditorFailureReport.MAX_FILE_IDS + 10))
.hasSize(EditorFailureReport.MAX_FILE_IDS);
}
@@ -463,7 +491,7 @@ class FileRunEventControllerTest {
overLimit,
"boom"))))
.isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(store.list(TEAM, null, null, EditorFailureReport.MAX_FILE_IDS + 10))
assertThat(store.list(TEAM, null, null, null, EditorFailureReport.MAX_FILE_IDS + 10))
.isEmpty();
}
@@ -438,11 +438,8 @@ class FileRunEventHttpIntegrationTest {
}
@Bean
FileRunEventController fileRunEventController(
FileRunEventService service,
PolicyManagementAuthority authority,
ApplicationProperties props) {
return new FileRunEventController(service, authority, props);
FileRunEventController fileRunEventController(FileRunEventService service) {
return new FileRunEventController(service);
}
}
}
@@ -54,6 +54,9 @@ class FileRunEventServiceTest {
lenient().when(authority.currentUserTeamId()).thenReturn(TEAM);
lenient().when(userService.getCurrentUsername()).thenReturn(ACTOR);
// A leader unless a test says otherwise: most of these are about team scoping, which is
// what a leader sees. The member narrowing has its own tests.
lenient().when(authority.canEditPolicies()).thenReturn(true);
}
private FileRunEvent given(FailureKind kind, Long teamId, String fileId) {
@@ -291,7 +294,7 @@ class FileRunEventServiceTest {
}
@Nested
@DisplayName("team scoping")
@DisplayName("read scoping")
class Scoping {
@Test
@@ -304,6 +307,68 @@ class FileRunEventServiceTest {
.containsExactly("mine");
}
@Test
void aMemberReadsOnlyTheFailuresTheyCaused() {
// Reporting is open to a member, so reading their own back is what lets us tell them
// anything at all. A colleague's must not come with it.
store.record(RecordFailure.forEditor(FailureKind.UNKNOWN, TEAM, ACTOR, "mine", "boom"));
store.record(
RecordFailure.forEditor(
FailureKind.UNKNOWN, TEAM, "colleague@example.com", "theirs", "boom"));
when(authority.canEditPolicies()).thenReturn(false);
assertThat(service.list(null, null, 50))
.extracting(FileRunEvent::fileId)
.containsExactly("mine");
}
@Test
void aMemberWithNoResolvableNameReadsNothingRatherThanEverything() {
// Narrowing to "mine" needs a name to narrow by. Dropping the filter would hand the
// whole team to someone who may not have it.
given(FailureKind.UNKNOWN, TEAM, "mine");
when(authority.canEditPolicies()).thenReturn(false);
when(userService.getCurrentUsername()).thenReturn(null);
assertThat(service.list(null, null, 50)).isEmpty();
}
@Test
void aMemberCannotActOnAColleaguesRowEvenKnowingItsId() {
// Refusing the read is not enough on its own: an id learned any other way must not work
// either. Reported as not-found, so trying does not confirm the row exists.
FileRunEvent theirs =
store.record(
RecordFailure.forEditor(
FailureKind.UNKNOWN,
TEAM,
"colleague@example.com",
"theirs",
"boom"));
when(authority.canEditPolicies()).thenReturn(false);
assertThatThrownBy(() -> service.dispatch(theirs.id(), "DISMISS", Map.of()))
.isInstanceOf(FailureActionException.class)
.extracting(e -> ((FailureActionException) e).getReason())
.isEqualTo(FailureActionException.Reason.EVENT_NOT_FOUND);
assertThat(store.find(theirs.id(), TEAM).orElseThrow().status())
.isEqualTo(FileRunEventStatus.NEW);
}
@Test
void aMemberMayCloseTheirOwn() {
// Someone who fixes their own problem should not have to ask a leader to clear the row.
FileRunEvent mine =
store.record(
RecordFailure.forEditor(
FailureKind.UNKNOWN, TEAM, ACTOR, "mine", "boom"));
when(authority.canEditPolicies()).thenReturn(false);
assertThat(service.dispatch(mine.id(), "DISMISS", Map.of()).status())
.isEqualTo(FileRunEventStatus.DISMISSED);
}
@Test
void aCallerWhoseTeamCannotBeResolvedReadsNothing() {
// A run with no stored policy is recorded unteamed, and those rows are shared by every
@@ -399,7 +464,7 @@ class FileRunEventServiceTest {
service.report(
new EditorFailureReport("remove-password", "E004", List.of("f-1"), "boom"));
FileRunEvent event = store.list(TEAM, null, null, 10).getFirst();
FileRunEvent event = store.list(TEAM, null, null, null, 10).getFirst();
assertThat(event.kind()).isEqualTo(FailureKind.INPUT_PASSWORD_PROTECTED);
assertThat(event.origin()).isEqualTo(FailureOrigin.TOOL);
assertThat(event.fileId()).isEqualTo("f-1");
@@ -412,16 +477,32 @@ class FileRunEventServiceTest {
// session.
service.report(new EditorFailureReport("compress", "E004", List.of("f-1"), "boom"));
FileRunEvent event = store.list(TEAM, null, null, 10).getFirst();
FileRunEvent event = store.list(TEAM, null, null, null, 10).getFirst();
assertThat(event.teamId()).isEqualTo(TEAM);
assertThat(event.actor()).isEqualTo(ACTOR);
}
@Test
void stillFilesTheRowUnderTheTeamWhenTheReporterCannotBeNamed() {
// Recording is open to everyone and takes the caller's team, not their read scope: a
// reporter who cannot be named reads nothing back, but the row is still the team's
// rather than dropping into the unteamed bucket every team shares. No role stub either,
// since recording never asks.
when(userService.getCurrentUsername()).thenReturn(null);
service.report(new EditorFailureReport("compress", "E004", List.of("f-1"), "boom"));
assertThat(store.list(TEAM, null, null, null, 10))
.singleElement()
.extracting(FileRunEvent::teamId)
.isEqualTo(TEAM);
}
@Test
void recordsAnUnrecognisedCodeAsUnknownRatherThanDroppingIt() {
service.report(new EditorFailureReport("ocr", "E999", List.of("f-1"), "no idea"));
assertThat(store.list(TEAM, null, null, 10).getFirst().kind())
assertThat(store.list(TEAM, null, null, null, 10).getFirst().kind())
.isEqualTo(FailureKind.UNKNOWN);
}
@@ -429,7 +510,7 @@ class FileRunEventServiceTest {
void recordsAnAbsentCodeAsUnknown() {
service.report(new EditorFailureReport("ocr", null, List.of("f-1"), "network died"));
assertThat(store.list(TEAM, null, null, 10).getFirst().kind())
assertThat(store.list(TEAM, null, null, null, 10).getFirst().kind())
.isEqualTo(FailureKind.UNKNOWN);
}
@@ -439,7 +520,7 @@ class FileRunEventServiceTest {
new EditorFailureReport(
"compress", "E004", List.of("f-1", "f-2", "f-3"), "boom"));
assertThat(store.list(TEAM, null, null, 10))
assertThat(store.list(TEAM, null, null, null, 10))
.hasSize(3)
.extracting(FileRunEvent::fileId)
.containsExactlyInAnyOrder("f-1", "f-2", "f-3");
@@ -451,7 +532,7 @@ class FileRunEventServiceTest {
service.report(
new EditorFailureReport("compress", "E004", List.of("f-1"), "boom again"));
assertThat(store.list(TEAM, null, null, 10))
assertThat(store.list(TEAM, null, null, null, 10))
.singleElement()
.extracting(FileRunEvent::occurrences)
.isEqualTo(2);
@@ -461,7 +542,7 @@ class FileRunEventServiceTest {
void recordsOneUnattributedIncidentWhenNoFileWasNamed() {
service.report(new EditorFailureReport("compress", "E004", List.of(), "boom"));
assertThat(store.list(TEAM, null, null, 10))
assertThat(store.list(TEAM, null, null, null, 10))
.singleElement()
.extracting(FileRunEvent::fileId)
.isNull();
@@ -476,7 +557,7 @@ class FileRunEventServiceTest {
service.report(new EditorFailureReport("compress", "E004", many, "boom"));
assertThat(store.list(TEAM, null, null, 200)).hasSize(60);
assertThat(store.list(TEAM, null, null, null, 200)).hasSize(60);
}
@Test
@@ -486,7 +567,7 @@ class FileRunEventServiceTest {
service.report(
new EditorFailureReport("remove-password", "E004", List.of("f-1"), "boom"));
FileRunEvent event = store.list(TEAM, null, null, 10).getFirst();
FileRunEvent event = store.list(TEAM, null, null, null, 10).getFirst();
assertThat(event.detail()).contains("remove-password");
assertThat(event.fileId()).isEqualTo("f-1");
}
@@ -499,7 +580,7 @@ class FileRunEventServiceTest {
new EditorFailureReport(
"compress", "E004", List.of("f-1"), "Failed on Q4 report.pdf"));
assertThat(store.list(TEAM, null, null, 10).getFirst().detail())
assertThat(store.list(TEAM, null, null, null, 10).getFirst().detail())
.isEqualTo("compress: Failed on Q4 report.pdf");
}
@@ -540,7 +621,7 @@ class FileRunEventServiceTest {
reportedBy("alice@example.com", "a-1");
reportedBy("bob@example.com", "b-1");
assertThat(store.list(TEAM, null, null, 10))
assertThat(store.list(TEAM, null, null, null, 10))
.extracting(FileRunEvent::actor)
.containsExactlyInAnyOrder("alice@example.com", "bob@example.com");
}
@@ -550,7 +631,7 @@ class FileRunEventServiceTest {
reportedBy("alice@example.com", "a-1");
reportedBy("alice@example.com", "a-2");
assertThat(store.list(TEAM, null, null, 10))
assertThat(store.list(TEAM, null, null, null, 10))
.extracting(FileRunEvent::fileId)
.containsExactlyInAnyOrder("a-1", "a-2");
}
@@ -560,7 +641,7 @@ class FileRunEventServiceTest {
reportedBy("alice@example.com", "a-1");
reportedBy("alice@example.com", "a-1");
assertThat(store.list(TEAM, null, null, 10))
assertThat(store.list(TEAM, null, null, null, 10))
.singleElement()
.extracting(FileRunEvent::occurrences)
.isEqualTo(2);
@@ -612,8 +693,11 @@ class FileRunEventServiceTest {
}
@Test
void aColleaguesIncidentIsUntouched() {
// File ids come from the client, so naming one must not close someone else's row.
void aColleaguesIncidentIsUntouchedEvenForALeader() {
// File ids come from the client, so naming one must not close someone else's row. The
// caller here is a leader, who reads the whole team: this path narrows to their own
// rows
// regardless, since a null actor would otherwise match every unattributed row.
store.record(
RecordFailure.forEditor(
FailureKind.UNKNOWN, TEAM, "employee@example.com", "f-1", "theirs"));
@@ -55,11 +55,16 @@ class FileRunEventStoreDbTest {
}
private RecordFailure failure(FailureKind kind, Long teamId, String fileId) {
return failure(kind, teamId, "author@example.com", fileId);
}
/** As {@link #failure} but naming the actor, which is what the read scope narrows by. */
private RecordFailure failure(FailureKind kind, Long teamId, String actor, String fileId) {
return new RecordFailure(
kind,
FailureOrigin.POLICY,
teamId,
"author@example.com",
actor,
"policy-1",
"run-1",
null,
@@ -74,16 +79,37 @@ class FileRunEventStoreDbTest {
store.record(failure(FailureKind.UNKNOWN, OTHER_TEAM, "theirs"));
store.record(failure(FailureKind.UNKNOWN, null, "unteamed"));
assertThat(store.list(TEAM, null, null, 10))
assertThat(store.list(TEAM, null, null, null, 10))
.extracting(FileRunEvent::fileId)
.containsExactly("ours");
// A plain `e.teamId = :teamId` would return nothing here: SQL equality against NULL is
// never true, which is what the explicit null branch in the JPQL exists for.
assertThat(store.list(null, null, null, 10))
assertThat(store.list(null, null, null, null, 10))
.extracting(FileRunEvent::fileId)
.containsExactly("unteamed");
}
@Test
@DisplayName("actor narrowing is enforced by the query, within the team")
void actorNarrowingIsEnforcedBySql() {
// The clause that makes a member read only their own rows. Exercised here rather than only
// against the in-memory repository, which reimplements the filter in Java and would agree
// with a query that had lost it.
store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "mine@example.com", "f1"));
store.record(
failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "theirs@example.com", "f2"));
store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, null, "f3"));
assertThat(store.list(TEAM, null, null, "mine@example.com", 10))
.extracting(FileRunEvent::fileId)
.containsExactly("f1");
// A null actor is "no filter", which is what a leader reads with: the whole team, including
// the rows nobody is named on.
assertThat(store.list(TEAM, null, null, null, 10))
.extracting(FileRunEvent::fileId)
.containsExactlyInAnyOrder("f1", "f2", "f3");
}
@Test
@DisplayName("a fold lands on the row's current state, not the caller's snapshot")
void foldTargetsTheCurrentRowNotACallersSnapshot() {
@@ -170,7 +196,7 @@ class FileRunEventStoreDbTest {
assertThat(replacement.id()).isNotEqualTo(first.id());
assertThat(replacement.occurrences()).isEqualTo(1);
assertThat(store.list(TEAM, null, null, 10)).hasSize(1);
assertThat(store.list(TEAM, null, null, null, 10)).hasSize(1);
}
@Test
@@ -188,7 +214,7 @@ class FileRunEventStoreDbTest {
FileRunEvent folded = store.record(secondSweep);
assertThat(folded.occurrences()).isEqualTo(2);
assertThat(store.list(TEAM, null, null, 10))
assertThat(store.list(TEAM, null, null, null, 10))
.as("one incident per document, however many runs it failed in")
.extracting(FileRunEvent::fileId)
.containsExactlyInAnyOrder("file-hash-a", "file-hash-b");
@@ -267,7 +293,7 @@ class FileRunEventStoreDbTest {
store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "newer-" + i));
}
assertThat(store.list(TEAM, null, "UNKNOWN", 1))
assertThat(store.list(TEAM, null, "UNKNOWN", null, 1))
.extracting(FileRunEvent::fileId)
.containsExactly("old-unknown");
}
@@ -281,7 +281,7 @@ class FileRunEventStoreTest {
store.record(failure(FailureKind.UNKNOWN, TEAM, "mine", "a"));
store.record(failure(FailureKind.UNKNOWN, OTHER_TEAM, "theirs", "b"));
assertThat(store.list(TEAM, null, null, 50))
assertThat(store.list(TEAM, null, null, null, 50))
.extracting(FileRunEvent::fileId)
.containsExactly("mine");
}
@@ -293,7 +293,7 @@ class FileRunEventStoreTest {
store.record(failure(FailureKind.UNKNOWN, null, "unteamed", "a"));
store.record(failure(FailureKind.UNKNOWN, TEAM, "teamed", "b"));
assertThat(store.list(null, null, null, 50))
assertThat(store.list(null, null, null, null, 50))
.extracting(FileRunEvent::fileId)
.containsExactly("unteamed");
}
@@ -305,8 +305,8 @@ class FileRunEventStoreTest {
FileRunEvent event = store.record(failure(FailureKind.UNKNOWN, TEAM, "f1", "boom"));
store.applyStatus(event.id(), TEAM, FileRunEventStatus.DISMISSED, "reviewer");
assertThat(store.list(TEAM, null, null, 10)).isEmpty();
assertThat(store.list(TEAM, FileRunEventStatus.DISMISSED, null, 10)).hasSize(1);
assertThat(store.list(TEAM, null, null, null, 10)).isEmpty();
assertThat(store.list(TEAM, FileRunEventStatus.DISMISSED, null, null, 10)).hasSize(1);
}
@Test
@@ -314,7 +314,7 @@ class FileRunEventStoreTest {
FileRunEvent event = store.record(failure(FailureKind.UNKNOWN, TEAM, "f1", "boom"));
store.applyStatus(event.id(), TEAM, FileRunEventStatus.ACKNOWLEDGED, "reviewer");
assertThat(store.list(TEAM, null, null, 10)).hasSize(1);
assertThat(store.list(TEAM, null, null, null, 10)).hasSize(1);
}
@Test
@@ -323,7 +323,7 @@ class FileRunEventStoreTest {
store.record(failure(FailureKind.INPUT_PASSWORD_PROTECTED, TEAM, "closed", "b"));
store.applyStatus(open.id(), TEAM, FileRunEventStatus.ACKNOWLEDGED, "me");
assertThat(store.list(TEAM, FileRunEventStatus.ACKNOWLEDGED, null, 50))
assertThat(store.list(TEAM, FileRunEventStatus.ACKNOWLEDGED, null, null, 50))
.extracting(FileRunEvent::fileId)
.containsExactly("open");
}
@@ -54,9 +54,18 @@ class InMemoryFileRunEventRepository implements FileRunEventRepository {
return kindId == null || kindId.equals(entity.getKindId());
}
/** Null means the whole team, matching the JPQL's {@code :actor is null} branch. */
private static boolean sameActor(FileRunEventEntity entity, String actor) {
return actor == null || actor.equals(entity.getActor());
}
@Override
public List<FileRunEventEntity> findByTeamAndStatus(
Long teamId, FileRunEventStatus status, String kindId, Pageable pageable) {
Long teamId,
FileRunEventStatus status,
String kindId,
String actor,
Pageable pageable) {
return page(
newestFirst(
rows.values().stream()
@@ -64,7 +73,8 @@ class InMemoryFileRunEventRepository implements FileRunEventRepository {
e ->
sameTeam(e, teamId)
&& e.getStatus() == status
&& sameKind(e, kindId))
&& sameKind(e, kindId)
&& sameActor(e, actor))
.toList()),
pageable);
}
@@ -142,7 +152,11 @@ class InMemoryFileRunEventRepository implements FileRunEventRepository {
@Override
public List<FileRunEventEntity> findByTeamAndStatusIn(
Long teamId, List<FileRunEventStatus> statuses, String kindId, Pageable pageable) {
Long teamId,
List<FileRunEventStatus> statuses,
String kindId,
String actor,
Pageable pageable) {
return page(
newestFirst(
rows.values().stream()
@@ -150,7 +164,8 @@ class InMemoryFileRunEventRepository implements FileRunEventRepository {
e ->
sameTeam(e, teamId)
&& statuses.contains(e.getStatus())
&& sameKind(e, kindId))
&& sameKind(e, kindId)
&& sameActor(e, actor))
.toList()),
pageable);
}
@@ -0,0 +1,264 @@
package stirling.software.proprietary.failure;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.slf4j.MDC;
import org.springframework.core.io.ByteArrayResource;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.InternalApiClient;
import stirling.software.common.service.JobOwnershipService;
import stirling.software.common.service.JobQueue;
import stirling.software.common.service.ResourceMonitor;
import stirling.software.common.service.TaskManager;
import stirling.software.common.service.ToolMetadataService;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.TempFileRegistry;
import stirling.software.proprietary.policy.asset.InProcessPolicyAssetStore;
import stirling.software.proprietary.policy.asset.PolicyAssetResolver;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
import stirling.software.proprietary.policy.engine.PolicyEngine;
import stirling.software.proprietary.policy.engine.PolicyExecutor;
import stirling.software.proprietary.policy.engine.PolicyRunRegistry;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.PolicyInputs;
import stirling.software.proprietary.policy.output.InlineOutputSink;
import stirling.software.proprietary.policy.output.PolicyOutputResolver;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
import stirling.software.proprietary.policy.source.InProcessSourceStore;
import stirling.software.proprietary.policy.store.PolicyStore;
import tools.jackson.databind.json.JsonMapper;
/**
* Pins what the engine records as a failure's actor against what a reader gets back, because the
* two sides used to assert independently: the engine's test passed {@code any()} for the actor, and
* the service's fixtures assumed an actor the engine never actually produced. Every collaborator
* between the failing tool call and the read is real here, so a regression in either one fails.
*
* <p>This is what makes this PR's promise hold. Reads are narrowed to the rows the caller is the
* actor on, so if the engine names the wrong person, a member reads nothing at all.
*
* <p>The bug it exists for: the engine recorded the BILLING principal as the actor, which for a
* stored policy is always its owner. So an attended failure was filed under someone who never
* touched the document, and the member who did could not see it.
*/
@ExtendWith(MockitoExtension.class)
class PolicyFailureAttributionTest {
private static final String ROTATE = "/api/v1/general/rotate-pdf";
private static final Long TEAM = 3L;
@Mock private InternalApiClient internalApiClient;
@Mock private ToolMetadataService toolMetadataService;
@Mock private TaskManager taskManager;
@Mock private FileStorage fileStorage;
@Mock private JobOwnershipService jobOwnershipService;
@Mock private ResourceMonitor resourceMonitor;
@Mock private JobQueue jobQueue;
@Mock private PolicyStore policyStore;
@Mock private PolicyManagementAuthority authority;
@Mock private UserServiceInterface userService;
@TempDir Path tempDir;
private PolicyEngine engine;
private FileRunEventService service;
@BeforeEach
void setUp() {
ApplicationProperties props = new ApplicationProperties();
props.getSecurity().setEnableLogin(true);
props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString());
props.getSystem().getTempFileManagement().setPrefix("failure-attribution-test-");
FileRunEventStore store = new FileRunEventStore(new InMemoryFileRunEventRepository());
service =
new FileRunEventService(
store,
new FailureActionRegistry(
List.of(new AcknowledgeAction(store), new DismissAction(store))),
authority,
userService,
props);
PolicyFailureRecorder recorder =
new PolicyFailureRecorder(
new FailureClassifier(JsonMapper.builder().build()), store, policyStore);
PolicyExecutor executor =
new PolicyExecutor(
internalApiClient,
toolMetadataService,
new TempFileManager(new TempFileRegistry(), props),
JsonMapper.builder().build());
engine =
new PolicyEngine(
executor,
taskManager,
new PolicyRunRegistry(new ApplicationProperties()),
recorder,
fileStorage,
jobOwnershipService,
List.of(new InlineOutputSink(fileStorage)),
new PolicyOutputResolver(new InProcessSourceStore()),
resourceMonitor,
jobQueue,
new PolicyAssetResolver(new InProcessPolicyAssetStore()));
lenient()
.when(jobOwnershipService.createScopedJobKey(anyString()))
.thenAnswer(invocation -> invocation.getArgument(0));
lenient().when(resourceMonitor.shouldQueueJob(anyInt())).thenReturn(false);
lenient().when(toolMetadataService.isMultiInput(anyString())).thenReturn(false);
// The team is resolved from the policy, so the recorded row lands in the reader's team.
lenient().when(policyStore.get(anyString())).thenReturn(Optional.of(sharedPolicy()));
lenient().when(authority.currentUserTeamId()).thenReturn(TEAM);
}
/** Alice's policy, shared with her team. Bob is a member of it and does not own it. */
private static Policy sharedPolicy() {
return new Policy(
"p1",
"rotate",
"alice",
true,
List.of(),
List.of(new PipelineStep(ROTATE, Map.of())),
OutputSpec.inline(),
TEAM);
}
/**
* Run the shared policy so its single tool step fails, as {@code triggeredBy} (null = sweep).
*/
private void runAndFail(String triggeredBy, String sourceId, String fileIdentity)
throws Exception {
when(internalApiClient.post(eq(ROTATE), any())).thenThrow(new RuntimeException("boom"));
if (triggeredBy != null) {
MDC.put("auditPrincipal", triggeredBy);
}
try {
engine.runPolicy(
sharedPolicy(),
PolicyInputs.of(List.of(pdf())),
PolicyProgressListener.NOOP,
sourceId,
fileIdentity)
.completion()
.get(10, TimeUnit.SECONDS);
} finally {
MDC.remove("auditPrincipal");
}
}
private static ByteArrayResource pdf() {
return new ByteArrayResource("input".getBytes()) {
@Override
public String getFilename() {
return "input.pdf";
}
};
}
/** Read as a plain member, who is narrowed to the rows they are the actor on. */
private FileRunEvent asMember(String reader) {
lenient().when(userService.getCurrentUsername()).thenReturn(reader);
lenient().when(authority.canEditPolicies()).thenReturn(false);
List<FileRunEvent> visible = service.list(null, null, 10);
return visible.isEmpty() ? null : visible.getFirst();
}
/** Read as a team leader, who reviews the whole team's incidents. */
private FileRunEvent asReviewer(String reader) {
lenient().when(userService.getCurrentUsername()).thenReturn(reader);
lenient().when(authority.canEditPolicies()).thenReturn(true);
return service.list(null, null, 10).getFirst();
}
@Nested
@DisplayName("a non-owner runs a shared policy on their own upload")
class AttendedByANonOwner {
@Test
void theTriggeringUserCanReadTheFailureTheyCaused() throws Exception {
runAndFail("bob", null, "bob-doc-1");
// The whole point: Bob's read scope narrows to his own rows, so the row only reaches
// him if the engine named him. Before the fix this list was empty.
FileRunEvent mine = asMember("bob");
assertThat(mine).as("bob must be able to see the failure he caused").isNotNull();
assertThat(mine.actor()).isEqualTo("bob");
}
@Test
void thePolicyOwnerIsNotNamedAsTheActorMerelyForBeingBilled() throws Exception {
runAndFail("bob", null, "bob-doc-1");
// Alice owns the policy and pays for the run, but she never touched the document.
assertThat(asReviewer("alice").actor()).isEqualTo("bob");
}
@Test
void aColleagueWhoDidNotTriggerItCannotSeeItAtAll() throws Exception {
runAndFail("bob", null, "bob-doc-1");
assertThat(asMember("carol")).isNull();
}
}
@Nested
@DisplayName("an unattended sweep pulls a file from a source")
class UnattendedSweep {
@Test
void theRowIsRecordedWithNoActorWhileStillBillingTheOwner() throws Exception {
runAndFail(null, "src-watched-folder", "file-hash-1");
assertThat(asReviewer("alice").actor())
.as("a trigger-fired run has no user to name")
.isNull();
}
@Test
void theSourceThatFedItIsStillRecorded() throws Exception {
runAndFail(null, "src-watched-folder", "file-hash-1");
FileRunEvent unattended = asReviewer("alice");
assertThat(unattended.sourceId()).isEqualTo("src-watched-folder");
assertThat(unattended.fileId()).isEqualTo("file-hash-1");
}
@Test
void aMemberDoesNotInheritAnUnattendedFailureAsTheirOwn() throws Exception {
// An unowned row must not fall to whoever happens to be reading: with no actor there is
// nothing for a member's narrowed read to match.
runAndFail(null, "src-watched-folder", "file-hash-1");
assertThat(asMember("bob")).isNull();
}
}
}
@@ -95,7 +95,7 @@ class PolicyFailureRecorderTest {
"Policy run failed: locked",
passwordFailure());
FileRunEvent event = store.list(TEAM, null, null, 10).getFirst();
FileRunEvent event = store.list(TEAM, null, null, null, 10).getFirst();
assertThat(event.kind()).isEqualTo(FailureKind.INPUT_PASSWORD_PROTECTED);
assertThat(event.runId()).isEqualTo("run-1");
assertThat(event.policyId()).isEqualTo("policy-1");
@@ -121,7 +121,7 @@ class PolicyFailureRecorderTest {
"Policy run failed: something we do not recognise",
new RuntimeException("boom"));
FileRunEvent event = store.list(TEAM, null, null, 10).getFirst();
FileRunEvent event = store.list(TEAM, null, null, null, 10).getFirst();
assertThat(event.kind()).isEqualTo(FailureKind.UNKNOWN);
assertThat(event.detail()).contains("something we do not recognise");
}
@@ -139,7 +139,7 @@ class PolicyFailureRecorderTest {
"Policy run failed: java.lang.NullPointerException",
new RuntimeException("npe"));
FileRunEvent event = store.list(TEAM, null, null, 10).getFirst();
FileRunEvent event = store.list(TEAM, null, null, null, 10).getFirst();
assertThat(event.kind()).isEqualTo(FailureKind.UNKNOWN);
assertThat(event.detail()).contains("NullPointerException");
}
@@ -152,7 +152,7 @@ class PolicyFailureRecorderTest {
recorder.recordRunFailureAs(
FailureKind.UNKNOWN, "run-3", "policy-1", null, null, "could not be queued");
assertThat(store.list(TEAM, null, null, 10)).hasSize(1);
assertThat(store.list(TEAM, null, null, null, 10)).hasSize(1);
}
@Test
@@ -180,7 +180,7 @@ class PolicyFailureRecorderTest {
"locked",
passwordFailure());
List<FileRunEvent> events = store.list(TEAM, null, null, 10);
List<FileRunEvent> events = store.list(TEAM, null, null, null, 10);
assertThat(events).hasSize(2);
assertThat(events).allMatch(event -> event.occurrences() == 1);
assertThat(events)
@@ -203,7 +203,7 @@ class PolicyFailureRecorderTest {
"locked",
passwordFailure());
FileRunEvent event = store.list(TEAM, null, null, 10).getFirst();
FileRunEvent event = store.list(TEAM, null, null, null, 10).getFirst();
assertThat(event.sourceId()).isEqualTo("src-s3-invoices");
assertThat(event.actor()).isNull();
}
@@ -218,7 +218,7 @@ class PolicyFailureRecorderTest {
recorder.recordRunFailureAs(
FailureKind.UNKNOWN, "run-2", "policy-1", "src-b", null, "unreachable");
assertThat(store.list(TEAM, null, null, 10))
assertThat(store.list(TEAM, null, null, null, 10))
.hasSize(2)
.extracting(FileRunEvent::sourceId)
.containsExactlyInAnyOrder("src-a", "src-b");
@@ -245,7 +245,7 @@ class PolicyFailureRecorderTest {
"locked",
passwordFailure());
assertThat(store.list(TEAM, null, null, 10))
assertThat(store.list(TEAM, null, null, null, 10))
.singleElement()
.extracting(FileRunEvent::occurrences)
.isEqualTo(2);
@@ -263,7 +263,7 @@ class PolicyFailureRecorderTest {
recorder.recordRunFailure(
"run-1", "policy-1", null, null, null, "boom", new RuntimeException());
assertThat(store.list(TEAM, null, null, 10)).hasSize(1);
assertThat(store.list(TEAM, null, null, null, 10)).hasSize(1);
}
@Test
@@ -273,8 +273,8 @@ class PolicyFailureRecorderTest {
recorder.recordRunFailure(
"run-1", null, null, null, null, "boom", new RuntimeException());
assertThat(store.list(null, null, null, 10)).hasSize(1);
assertThat(store.list(TEAM, null, null, 10)).isEmpty();
assertThat(store.list(null, null, null, null, 10)).hasSize(1);
assertThat(store.list(TEAM, null, null, null, 10)).isEmpty();
}
@Test
@@ -293,7 +293,7 @@ class PolicyFailureRecorderTest {
new RuntimeException()))
.doesNotThrowAnyException();
// Still recorded, just unteamed: a lookup problem must not lose the incident.
assertThat(store.list(null, null, null, 10)).hasSize(1);
assertThat(store.list(null, null, null, null, 10)).hasSize(1);
}
}
@@ -344,7 +344,7 @@ class PolicyFailureRecorderTest {
"no cause",
null))
.doesNotThrowAnyException();
assertThat(store.list(TEAM, null, null, 10).getFirst().kind())
assertThat(store.list(TEAM, null, null, null, 10).getFirst().kind())
.isEqualTo(FailureKind.UNKNOWN);
}
}
@@ -362,7 +362,7 @@ class PolicyFailureRecorderTest {
recorder.recordRunFailure(
"run-1", "policy-1", null, null, null, "boom", new IOException("x"));
List<FileRunEvent> events = store.list(TEAM, null, null, 10);
List<FileRunEvent> events = store.list(TEAM, null, null, null, 10);
assertThat(events).hasSize(1);
assertThat(events.getFirst().occurrences()).isEqualTo(2);
}
@@ -377,7 +377,7 @@ class PolicyFailureRecorderTest {
recorder.recordRunFailure(
"run-2", "policy-1", null, null, null, "boom", new IOException("x"));
assertThat(store.list(TEAM, null, null, 10)).hasSize(2);
assertThat(store.list(TEAM, null, null, null, 10)).hasSize(2);
}
}
}
@@ -216,7 +216,7 @@ class PolicyControllerTest {
}
private static PolicyRunHandle handle(String runId) {
PolicyRun run = new PolicyRun(runId, null, definitionWithStep(), null, null);
PolicyRun run = new PolicyRun(runId, null, definitionWithStep(), null, null, null);
return new PolicyRunHandle(runId, CompletableFuture.completedFuture(run));
}
@@ -317,7 +317,7 @@ class PolicyControllerTest {
@Test
@DisplayName("returns the run view when present")
void found() {
PolicyRun run = new PolicyRun("run-3", null, definitionWithStep(), null, null);
PolicyRun run = new PolicyRun("run-3", null, definitionWithStep(), null, null, null);
when(runRegistry.get("run-3")).thenReturn(run);
ResponseEntity<PolicyRunView> response = controller.status("run-3");
@@ -346,11 +346,11 @@ class PolicyControllerTest {
@Test
@DisplayName("excludes ad-hoc runs and runs owned by others")
void filtersRuns() {
PolicyRun adHoc = new PolicyRun("adhoc", null, definitionWithStep(), null, null);
PolicyRun adHoc = new PolicyRun("adhoc", null, definitionWithStep(), null, null, null);
PolicyRun ownedStored =
new PolicyRun("owned", "policy-A", definitionWithStep(), null, null);
new PolicyRun("owned", "policy-A", definitionWithStep(), null, null, null);
PolicyRun otherStored =
new PolicyRun("other", "policy-B", definitionWithStep(), null, null);
new PolicyRun("other", "policy-B", definitionWithStep(), null, null, null);
when(runRegistry.all()).thenReturn(List.of(adHoc, ownedStored, otherStored));
// ownedByCurrentUser: strip then re-apply scope reproduces the key only for the owned
@@ -9,6 +9,7 @@ import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
@@ -231,20 +232,15 @@ class PolicyEngineTest {
@Test
void recordsWhichSourceFedAFailedRun() throws Exception {
// The source is threaded onto the run so an unattended failure is attributable: there is no
// user to name for a file that arrived from a bucket.
// user to name for a file that arrived from a bucket. The actor is asserted null rather
// than
// any(): a loose matcher here is what let the owner be recorded as the actor unnoticed.
when(toolMetadataService.isMultiInput(ROTATE)).thenReturn(false);
when(internalApiClient.post(eq(ROTATE), any())).thenThrow(new RuntimeException("boom"));
PolicyRunHandle handle =
engine.runPolicy(
new Policy(
"p1",
"rotate",
"owner",
true,
List.of(),
List.of(new PipelineStep(ROTATE, Map.of())),
OutputSpec.inline()),
policyOwnedBy("owner"),
PolicyInputs.of(List.of(pdf("input", "input.pdf"))),
PolicyProgressListener.NOOP,
"src-s3-invoices",
@@ -257,11 +253,136 @@ class PolicyEngineTest {
any(),
eq("src-s3-invoices"),
eq("file-hash-1"),
any(),
isNull(),
anyString(),
any(Throwable.class));
}
@Test
void anAttendedFailureIsRecordedAgainstWhoTriggeredItNotThePolicysOwner() throws Exception {
// Bob runs Alice's shared policy on his own upload and it fails. The row must name Bob: he
// is the one whose browser holds the document, and a member's read scope narrows to their
// own rows, so filing it under Alice hides it from the only person who can act on it.
when(toolMetadataService.isMultiInput(ROTATE)).thenReturn(false);
when(internalApiClient.post(eq(ROTATE), any())).thenThrow(new RuntimeException("boom"));
MDC.put("auditPrincipal", "bob"); // the request thread's acting user
try {
engine.runPolicy(
policyOwnedBy("alice"),
PolicyInputs.of(List.of(pdf("input", "input.pdf"))),
PolicyProgressListener.NOOP,
null,
"bob-doc-1")
.completion()
.get(10, TimeUnit.SECONDS);
} finally {
MDC.remove("auditPrincipal");
}
verify(failureRecorder)
.recordRunFailure(
anyString(),
any(),
isNull(),
eq("bob-doc-1"),
eq("bob"),
anyString(),
any(Throwable.class));
}
@Test
void anUnattendedFailureIsRecordedWithNoActorWhileStillBillingTheOwner() throws Exception {
// The two identities are deliberately different, and this pins both at once: usage is
// charged to the owner (MDC audit principal on the worker), but the failure has no actor,
// which is what makes it UNOWNED and hands the owner actions to the team's reviewer.
when(toolMetadataService.isMultiInput(ROTATE)).thenReturn(false);
String[] principalAtDispatch = {"<none>"};
when(internalApiClient.post(eq(ROTATE), any()))
.thenAnswer(
invocation -> {
principalAtDispatch[0] = MDC.get("auditPrincipal");
throw new RuntimeException("boom");
});
// No MDC and no security context: exactly a trigger-fired sweep.
engine.runPolicy(
policyOwnedBy("alice"),
PolicyInputs.of(List.of(pdf("input", "input.pdf"))),
PolicyProgressListener.NOOP,
"src-watched-folder",
"file-hash-1")
.completion()
.get(10, TimeUnit.SECONDS);
assertEquals("alice", principalAtDispatch[0], "billing must still be the policy owner");
verify(failureRecorder)
.recordRunFailure(
anyString(),
any(),
eq("src-watched-folder"),
eq("file-hash-1"),
isNull(),
anyString(),
any(Throwable.class));
}
@Test
void anAdHocFailureIsRecordedAgainstTheSubmittingUser() throws Exception {
// An ad-hoc run has no stored policy, so the submitter is both payer and actor. Asserted so
// the two entry points cannot drift apart.
when(toolMetadataService.isMultiInput(ROTATE)).thenReturn(false);
when(internalApiClient.post(eq(ROTATE), any())).thenThrow(new RuntimeException("boom"));
MDC.put("auditPrincipal", "bob");
try {
engine.submit(
definition(new PipelineStep(ROTATE, Map.of())),
PolicyInputs.of(List.of(pdf("input", "input.pdf"))),
PolicyProgressListener.NOOP)
.completion()
.get(10, TimeUnit.SECONDS);
} finally {
MDC.remove("auditPrincipal");
}
verify(failureRecorder)
.recordRunFailure(
anyString(),
any(),
any(),
any(),
eq("bob"),
anyString(),
any(Throwable.class));
}
@Test
void aRunRefusedAtAdmissionIsRecordedAgainstWhoeverTriggeredIt() throws Exception {
// The queue-full path records its own row, and it is attended: the user is still holding
// the
// document, so it must reach them rather than landing as an ownerless incident.
when(resourceMonitor.shouldQueueJob(anyInt())).thenReturn(true);
CompletableFuture<Object> rejected = new CompletableFuture<>();
rejected.completeExceptionally(new RuntimeException("Job queue full"));
doReturn(rejected).when(jobQueue).queueJob(anyString(), anyInt(), any(), anyLong());
MDC.put("auditPrincipal", "bob");
try {
engine.runPolicy(
policyOwnedBy("alice"),
PolicyInputs.of(List.of(pdf("input", "input.pdf"))),
PolicyProgressListener.NOOP,
null,
"bob-doc-1");
} finally {
MDC.remove("auditPrincipal");
}
verify(failureRecorder)
.recordRunFailureAs(any(), anyString(), any(), isNull(), eq("bob"), anyString());
}
@Test
void recordingAFailureNeverChangesTheRunsOutcome() throws Exception {
// Recording is best-effort: losing the incident row is bad, but turning a classified
@@ -540,6 +661,17 @@ class PolicyEngineTest {
return new PipelineDefinition("test", List.of(steps), OutputSpec.inline());
}
private static Policy policyOwnedBy(String owner) {
return new Policy(
"p1",
"rotate",
owner,
true,
List.of(),
List.of(new PipelineStep(ROTATE, Map.of())),
OutputSpec.inline());
}
private void stubEndpoint(String endpoint, Resource body) {
when(internalApiClient.post(eq(endpoint), any())).thenReturn(ResponseEntity.ok(body));
}
@@ -101,6 +101,7 @@ class PolicyRunRegistryTest {
null,
new PipelineDefinition(runId, List.of(), List.of()),
null,
null,
null);
registry.register(run);
return run;
@@ -0,0 +1,108 @@
package stirling.software.saas.config;
import org.hibernate.boot.model.relational.Namespace;
import org.hibernate.boot.model.relational.Sequence;
import org.hibernate.mapping.Table;
import org.hibernate.tool.schema.spi.SchemaFilter;
import org.hibernate.tool.schema.spi.SchemaFilterProvider;
/**
* Hides the migration-owned tables from Hibernate's schema management.
*
* <p>Wired on the SaaS profile only, via {@code hibernate.hbm2ddl.schema_filter_provider}.
* Self-hosted is untouched: there Hibernate rightly owns everything.
*
* <p>Why a filter rather than simply turning {@code ddl-auto} off: the SaaS database has two
* writers. The Supabase migrations own the SaaS tables, and Hibernate owns roughly thirty tables
* inherited from the self-hosted app that no migration has ever created. Turn {@code ddl-auto} off
* and a fresh preview branch is missing that second half; leave it on and Hibernate is free to
* reconcile migration-owned tables, which is how {@code team_memberships.role} ended up widened to
* varchar(255) and needed a migration to put back. A filter keeps the first half working and makes
* the second impossible.
*
* <p>Note that this is a per-table filter, not a per-schema one. Hibernate's schema management runs
* over every mapped entity regardless of namespace, so moving SaaS tables to their own schema would
* not by itself keep Hibernate out of them. {@link SaasSchemaOwnership} is the register; this class
* only applies it.
*
* <p><b>Foreign keys still cross the line, on purpose.</b> Several inherited tables reference
* migration-owned ones — {@code folders}, {@code stored_files} and {@code file_shares} all point at
* {@code users}/{@code teams}. Hibernate's {@code SchemaCreatorImpl.createForeignKeys} and {@code
* AbstractSchemaMigrator.applyForeignKeys} check {@code includeTable} against the *owning* table
* only and then emit every foreign key on it, without consulting the referenced table. So excluding
* {@code users} does not cost the branch its referential integrity, and a branch ends up matching
* staging. It does mean the referenced tables have to exist by the time Hibernate runs, which holds
* because a Supabase branch applies its migrations at build time and the app connects afterwards.
*
* <p><b>Known gap: this cannot detect drift.</b> Filtering means Hibernate never inspects these
* tables, and {@link #getValidateFilter()} extends that to {@code validate}, so nothing here
* compares a migration-owned table against its entity. Combined with the register being a
* hand-maintained list of another repo's contents (see {@link SaasSchemaOwnership}), there is
* currently no automated signal when the register, the entities and the database disagree. That is
* a deliberate trade for a boot that does not fail on differences we accept, not a claim that drift
* cannot happen; a non-fatal drift report is the missing piece and belongs outside this class.
*/
public class MigrationOwnedSchemaFilter implements SchemaFilterProvider, SchemaFilter {
/**
* The one decision this class makes. Everything Hibernate might do to a table it does not own —
* create, alter, drop, truncate — is refused.
*/
@Override
public boolean includeTable(Table table) {
return !SaasSchemaOwnership.isMigrationOwned(table.getName());
}
/**
* Namespaces are never filtered. The inherited tables and the migration-owned ones share {@code
* stirling_pdf}, so excluding the namespace would take both with it.
*/
@Override
public boolean includeNamespace(Namespace namespace) {
return true;
}
/**
* Sequences are left alone. Every id here is an identity column rather than a mapped generator,
* so there is nothing for Hibernate to create; filtering them would be dead code pretending to
* be a safeguard.
*/
@Override
public boolean includeSequence(Sequence sequence) {
return true;
}
@Override
public SchemaFilter getCreateFilter() {
return this;
}
@Override
public SchemaFilter getMigrateFilter() {
return this;
}
@Override
public SchemaFilter getDropFilter() {
return this;
}
@Override
public SchemaFilter getTruncatorFilter() {
return this;
}
/**
* Validation is filtered too, which is the one debatable call here.
*
* <p>Letting it through would give a useful signal when a migration-owned table drifts from its
* entity. But {@code ddl-auto=validate} fails startup, and it would fail on differences we have
* deliberately accepted — {@code ai_create_sessions} carries columns from a reverted feature
* that nothing maps, for instance. A boot failure over a table we have chosen not to manage is
* noise, so the rule stays uniform: Hibernate does not concern itself with these tables at all.
*/
@Override
public SchemaFilter getValidateFilter() {
return this;
}
}
@@ -0,0 +1,117 @@
package stirling.software.saas.config;
import java.util.Set;
/**
* Which side owns each table in the SaaS database.
*
* <p>The SaaS schema has two writers and always has: the Supabase migrations in the
* Stirling-PDF-SaaS repo, and Hibernate's {@code ddl-auto}. That was a convention rather than a
* rule, and it leaked twice. An older {@code ddl-auto} run widened {@code team_memberships.role} to
* varchar(255), which needed a dedicated migration to repair because RLS policies depended on the
* column. Separately {@code payg_instance_usage} went months with an entity and no migration, so it
* simply did not exist on a fresh preview branch.
*
* <p>This class makes the boundary explicit and {@code SaasSchemaOwnershipTest} makes it binding:
* every {@code @Entity} the SaaS app maps must appear in exactly one of these two sets. A new
* entity fails the build until someone states who owns its table, which is the decision that was
* previously made by accident.
*
* <p>{@link MigrationOwnedSchemaFilter} enforces it at runtime: Hibernate is never shown the
* migration-owned tables, so it cannot create, alter or drop them whatever {@code ddl-auto} says.
* Inherited tables stay under Hibernate, so a preview branch built from migrations alone still
* heals itself on first boot.
*
* <p><b>What this does not catch.</b> The register is a hand-maintained copy of what lives in
* another repository, and only one direction is enforced. The test fails when a *new* entity
* appears with no owner. It cannot notice a table changing sides: write a migration for {@code
* folders} in Stirling-PDF-SaaS and nothing here changes, the test still passes, and Hibernate
* carries on managing a table the migrations now own — which is precisely how {@code
* team_memberships.role} got widened. Adding a migration for anything in {@link #HIBERNATE_MANAGED}
* therefore means moving it to {@link #MIGRATION_OWNED} in the same change; nothing will remind
* you. Making that structural rather than remembered is what moving the SaaS tables into their own
* schema would buy, and is the reason this class is a stepping stone rather than the answer.
*/
public final class SaasSchemaOwnership {
/**
* Created and altered by the Supabase migrations. Hibernate must not touch these: the
* migrations carry constraints, defaults and RLS policies it knows nothing about and would
* reconcile away.
*/
public static final Set<String> MIGRATION_OWNED =
Set.of(
"ai_create_sessions",
"audit_events",
"authorities",
"billing_subscriptions",
"job_artifact_hash",
"legal_consent",
"linked_instance",
"payg_instance_usage",
"payg_meter_event_log",
"payg_prepaid_bundle",
"payg_shadow_charge",
"payg_team_extensions",
"persistent_logins",
"pricing_policy",
"processing_job",
"processing_job_step",
"procurement_agreement_signature",
"procurement_deal",
"procurement_quote",
"saas_team_extensions",
"saas_user_extensions",
"sessions",
"team_invitations",
"team_memberships",
"teams",
"users",
"wallet_entitlement_snapshot",
"wallet_ledger",
"wallet_policy");
/**
* Inherited from the self-hosted app, where {@code ddl-auto} owns the schema and no Supabase
* migration exists. Deliberately left under Hibernate so a fresh branch gets them on first
* boot.
*/
public static final Set<String> HIBERNATE_MANAGED =
Set.of(
"account_link_device_credential",
"account_link_metered_signature",
"account_link_sync_state",
"account_link_usage_counter",
"api_key_daily_usage",
"api_keys",
"file_encryption_keys",
"file_run_events",
"file_share_accesses",
"file_shares",
"folders",
"integration_configs",
"invite_tokens",
"jwt_signing_keys",
"policies",
"policy_assets",
"policy_completed_migrations",
"policy_processed_files",
"policy_source_doc_counts",
"policy_source_doc_totals",
"policy_sources",
"resource_grants",
"storage_cleanup_entries",
"stored_file_blobs",
"stored_files",
"user_license_settings",
"user_server_certificates",
"workflow_participants",
"workflow_sessions");
private SaasSchemaOwnership() {}
/** Case-insensitive: Hibernate hands us whatever casing the mapping used. */
public static boolean isMigrationOwned(String tableName) {
return tableName != null && MIGRATION_OWNED.contains(tableName.toLowerCase());
}
}
@@ -27,6 +27,13 @@ spring.jpa.properties.hibernate.hbm2ddl.create_namespaces=true
spring.jpa.hibernate.ddl-auto=update
# ...but only over the tables Hibernate actually owns. The SaaS database has two writers: the
# Supabase migrations own the SaaS tables, Hibernate owns ~30 inherited from the self-hosted app that
# no migration has ever created. This filter hides the former from schema management, so ddl-auto can
# still heal a fresh preview branch without being free to reconcile a migration-owned table — which
# is how team_memberships.role ended up widened to varchar(255). Register: SaasSchemaOwnership.
spring.jpa.properties.hibernate.hbm2ddl.schema_filter_provider=stirling.software.saas.config.MigrationOwnedSchemaFilter
# ---------- Supabase JWT auth ----------
# Required: set SAAS_DB_PROJECT_REF via env.
app.supabase.project-ref=${SAAS_DB_PROJECT_REF:}
@@ -0,0 +1,129 @@
package stirling.software.saas.config;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.InputStream;
import java.util.Properties;
import org.hibernate.mapping.Table;
import org.hibernate.tool.schema.spi.SchemaFilter;
import org.hibernate.tool.schema.spi.SchemaFilterProvider;
import org.junit.jupiter.api.Test;
/**
* Covers {@link MigrationOwnedSchemaFilter} and, just as importantly, its wiring.
*
* <p>{@link SaasSchemaOwnershipTest} proves the register is complete; nothing proved the filter
* applies it, or that Hibernate is even asking. A typo in the {@code
* hibernate.hbm2ddl.schema_filter_provider} key, a stale fully-qualified name after a package move,
* or a getter returning null would all leave every migration-owned table exposed to {@code
* ddl-auto} with a fully green build. Hence the property assertion below, which is the only thing
* here that would catch that.
*/
class MigrationOwnedSchemaFilterTest {
private static final String FILTER_PROPERTY =
"spring.jpa.properties.hibernate.hbm2ddl.schema_filter_provider";
private final MigrationOwnedSchemaFilter filter = new MigrationOwnedSchemaFilter();
/** "orm" is Hibernate's own default contributor; the value is irrelevant to the filter. */
private static Table table(String name) {
return new Table("orm", name);
}
@Test
void migrationOwnedTablesAreHiddenFromHibernate() {
assertThat(filter.includeTable(table("teams"))).isFalse();
assertThat(filter.includeTable(table("users"))).isFalse();
assertThat(filter.includeTable(table("team_memberships"))).isFalse();
assertThat(filter.includeTable(table("payg_instance_usage"))).isFalse();
}
@Test
void inheritedTablesStayUnderHibernate() {
assertThat(filter.includeTable(table("folders"))).isTrue();
assertThat(filter.includeTable(table("stored_files"))).isTrue();
assertThat(filter.includeTable(table("api_keys"))).isTrue();
}
@Test
void anUnknownTableIsLeftToHibernate() {
// Fail-open is the right default: an unrecognised table is either brand new or from a
// module
// we do not know about, and SaasSchemaOwnershipTest is what stops it staying unrecognised.
assertThat(filter.includeTable(table("no_such_table"))).isTrue();
}
@Test
void casingDoesNotDefeatTheFilter() {
assertThat(filter.includeTable(table("TEAMS"))).isFalse();
assertThat(filter.includeTable(table("Team_Memberships"))).isFalse();
}
/**
* Foreign keys from an inherited table into a migration-owned one survive the filter.
*
* <p>Worth pinning, because it is not obvious and it decides whether a preview branch keeps
* referential integrity. {@code folders}, {@code stored_files} and {@code file_shares} all
* reference {@code users}/{@code teams}, which the filter excludes. Hibernate 7.2's {@code
* SchemaCreatorImpl.createForeignKeys} (and {@code AbstractSchemaMigrator.applyForeignKeys})
* tests {@code includeTable} against the *owning* table only, then emits every foreign key on
* it; the referenced table is never consulted. So the constraints are still created and a
* branch matches staging.
*
* <p>The one thing this depends on is ordering: the referenced tables have to exist first. They
* do, because a Supabase branch runs its migrations at build time and the app connects after.
*/
@Test
void foreignKeysIntoMigrationOwnedTablesAreStillEmitted() {
assertThat(filter.includeTable(table("folders"))).isTrue();
assertThat(filter.includeTable(table("file_shares"))).isTrue();
assertThat(filter.includeTable(table("users"))).isFalse();
assertThat(filter.includeTable(table("teams"))).isFalse();
}
@Test
void everySchemaActionGetsTheSameFilter() {
assertThat(filter.getCreateFilter()).isSameAs(filter);
assertThat(filter.getMigrateFilter()).isSameAs(filter);
assertThat(filter.getDropFilter()).isSameAs(filter);
assertThat(filter.getTruncatorFilter()).isSameAs(filter);
assertThat(filter.getValidateFilter()).isSameAs(filter);
}
@Test
void namespacesAndSequencesAreNeverFiltered() {
// Both share the stirling_pdf namespace, so filtering it would take the inherited tables
// with it. Neither argument is read, so nulls are fine and keep the test free of Hibernate
// bootstrap machinery.
assertThat(filter.includeNamespace(null)).isTrue();
assertThat(filter.includeSequence(null)).isTrue();
}
@Test
void theFilterIsActuallyWiredIntoHibernate() throws Exception {
Properties properties = new Properties();
try (InputStream in = getClass().getResourceAsStream("/application-saas.properties")) {
assertThat(in)
.as("application-saas.properties must be on the test classpath to check wiring")
.isNotNull();
properties.load(in);
}
String configured = properties.getProperty(FILTER_PROPERTY);
assertThat(configured)
.as(
"%s is unset, so Hibernate installs its default filter and every"
+ " migration-owned table is back under ddl-auto",
FILTER_PROPERTY)
.isNotBlank();
Class<?> wired = Class.forName(configured.trim());
assertThat(SchemaFilterProvider.class)
.as("Hibernate only accepts a SchemaFilterProvider here")
.isAssignableFrom(wired);
assertThat(SchemaFilter.class).isAssignableFrom(wired);
assertThat(wired).isEqualTo(MigrationOwnedSchemaFilter.class);
}
}
@@ -0,0 +1,167 @@
package stirling.software.saas.config;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.boot.persistence.autoconfigure.EntityScan;
import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider;
import org.springframework.core.type.filter.AnnotationTypeFilter;
import org.springframework.util.ClassUtils;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import stirling.software.proprietary.security.configuration.DatabaseConfig;
/**
* Makes {@link SaasSchemaOwnership} binding rather than decorative.
*
* <p>Every {@code @Entity} the SaaS app maps has to be declared as owned by either the Supabase
* migrations or Hibernate. Adding an entity without saying which fails here, at build time, instead
* of months later on a preview branch that has no such table. That is not hypothetical: {@code
* payg_instance_usage} shipped with an entity and no migration and went unnoticed until a branch
* tried to use it.
*
* <p>"Maps" is meant precisely: the scan covers the packages named by the {@code @EntityScan}
* declarations the app actually boots with, not everything under {@code stirling.software}. See
* {@link #mappedPackages()}. Note this only enforces one direction — {@link SaasSchemaOwnership}
* documents the drift it cannot see.
*/
class SaasSchemaOwnershipTest {
/**
* The packages the running app actually maps, read off the two {@code @EntityScan} declarations
* that define them rather than hardcoded.
*
* <p>Scanning all of {@code stirling.software} would be easier and wrong in a quiet way: it is
* a superset, so it would force ownership declarations for entities Hibernate never sees and
* let the register claim tables that do not exist as far as the SaaS app is concerned. Deriving
* the list means this test measures the same set Hibernate does, and follows a package being
* added or moved without anyone updating it here.
*/
private static Set<String> mappedPackages() {
Set<String> packages = new TreeSet<>();
for (Class<?> config : List.of(SaasJpaConfig.class, DatabaseConfig.class)) {
EntityScan scan = config.getAnnotation(EntityScan.class);
assertThat(scan)
.as("%s must carry @EntityScan, or its entities are not mapped", config)
.isNotNull();
packages.addAll(Arrays.asList(scan.value()));
}
return packages;
}
private static TreeMap<String, String> mappedTables() {
ClassPathScanningCandidateComponentProvider scanner =
new ClassPathScanningCandidateComponentProvider(false);
scanner.addIncludeFilter(new AnnotationTypeFilter(Entity.class));
TreeMap<String, String> byTable = new TreeMap<>();
for (String basePackage : mappedPackages()) {
for (BeanDefinition bd : scanner.findCandidateComponents(basePackage)) {
String className = bd.getBeanClassName();
Class<?> type;
try {
type =
ClassUtils.forName(
className, SaasSchemaOwnershipTest.class.getClassLoader());
} catch (ClassNotFoundException | LinkageError e) {
continue; // not on this module's runtime classpath; nothing to own
}
Table table = type.getAnnotation(Table.class);
String name =
table != null && !table.name().isBlank()
? table.name()
: camelToSnake(type.getSimpleName());
byTable.put(name.toLowerCase(), className);
}
}
return byTable;
}
/** Mirrors Spring Boot's default CamelCaseToUnderscoresNamingStrategy for an unnamed @Table. */
private static String camelToSnake(String name) {
return name.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase();
}
@Test
void everyEntityTableIsOwnedByExactlyOneSide() {
TreeMap<String, String> mapped = mappedTables();
assertThat(mapped)
.as("entity scan found nothing, so this test proves nothing")
.isNotEmpty();
// The scan is derived from @EntityScan now, so a package quietly dropped from either
// declaration would shrink it and weaken this test rather than fail it. These four straddle
// the two declarations, so losing either side fails here instead of silently checking less.
assertThat(mapped.keySet())
.as("both @EntityScan declarations must have contributed to the scan")
.contains("users", "teams", "payg_instance_usage", "folders");
Set<String> undeclared = new TreeSet<>();
Set<String> both = new TreeSet<>();
for (String table : mapped.keySet()) {
boolean migration = SaasSchemaOwnership.MIGRATION_OWNED.contains(table);
boolean hibernate = SaasSchemaOwnership.HIBERNATE_MANAGED.contains(table);
if (migration && hibernate) both.add(table);
if (!migration && !hibernate) undeclared.add(table);
}
assertThat(undeclared)
.as(
"""
These entity tables are not declared in SaasSchemaOwnership, so nobody owns \
them. Decide and add each to exactly one set:
- MIGRATION_OWNED: also add a migration in Stirling-PDF-SaaS, or the table \
will not exist on a fresh preview branch.
- HIBERNATE_MANAGED: only correct for a table inherited from the \
self-hosted app that no Supabase migration creates.
Offending tables -> entities: %s"""
.formatted(
undeclared.stream()
.map(t -> t + " (" + mapped.get(t) + ")")
.toList()))
.isEmpty();
assertThat(both)
.as("declared as owned by both sides, which is the one thing it cannot be")
.isEmpty();
}
@Test
void theTwoSetsDoNotOverlap() {
Set<String> overlap = new TreeSet<>(SaasSchemaOwnership.MIGRATION_OWNED);
overlap.retainAll(SaasSchemaOwnership.HIBERNATE_MANAGED);
assertThat(overlap).isEmpty();
}
@Test
void tableNamesAreLowercaseSoLookupsCannotMiss() {
// isMigrationOwned() lowercases its input; a capital in either set would be unreachable.
assertThat(SaasSchemaOwnership.MIGRATION_OWNED)
.allSatisfy(t -> assertThat(t).isEqualTo(t.toLowerCase()));
assertThat(SaasSchemaOwnership.HIBERNATE_MANAGED)
.allSatisfy(t -> assertThat(t).isEqualTo(t.toLowerCase()));
}
@Test
void migrationOwnedTablesIncludeTheOnesThatBitUs() {
// team_memberships is the table an old ddl-auto run widened; payg_instance_usage is the one
// that had an entity and no migration. Both must be on the migrations' side of the line.
assertThat(SaasSchemaOwnership.MIGRATION_OWNED)
.contains("team_memberships", "payg_instance_usage", "teams", "users");
}
@Test
void isMigrationOwnedIsCaseInsensitiveAndNullSafe() {
assertThat(SaasSchemaOwnership.isMigrationOwned("TEAM_MEMBERSHIPS")).isTrue();
assertThat(SaasSchemaOwnership.isMigrationOwned("team_memberships")).isTrue();
assertThat(SaasSchemaOwnership.isMigrationOwned(null)).isFalse();
assertThat(SaasSchemaOwnership.isMigrationOwned("no_such_table")).isFalse();
}
}
+3 -3
View File
@@ -30,7 +30,7 @@ ext {
openSamlVersion = "5.2.1"
commonmarkVersion = "0.28.0"
googleJavaFormatVersion = "1.35.0"
logback = "1.5.32"
logback = "1.6.1"
commonsIoVersion = "2.22.0"
commonsLang3 = "3.20.0"
rhinoVersion = "1.9.1"
@@ -44,9 +44,9 @@ ext {
batikVersion = "1.19"
jpdfiumVersion = "1.0.4"
jwtVersion = "0.13.0"
awsSdkVersion = "2.44.12"
awsSdkVersion = "2.51.3"
jschVersion = "2.28.6"
commonsNetVersion = "3.11.1"
commonsNetVersion = "3.13.0"
smbjVersion = "0.14.0"
tinkVersion = "1.23.0"
testcontainersMinioVersion = "1.21.4"
+1 -1
View File
@@ -45,7 +45,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li
--no-daemon
# Stage 2: Extract Spring Boot Layers
FROM eclipse-temurin:25-jre-noble@sha256:2f1da100788559b397bcf48c736169ea5b070bde84e55f203bbee8e83d87a175 AS jar-extract
FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS jar-extract
WORKDIR /tmp
COPY --from=app-build /app/app/core/build/libs/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers
+1 -1
View File
@@ -364,7 +364,7 @@ RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \
# Final runtime image - the actual base image
FROM eclipse-temurin:25-jre-noble@sha256:2f1da100788559b397bcf48c736169ea5b070bde84e55f203bbee8e83d87a175 AS runtime
FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS runtime
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
+1 -1
View File
@@ -61,7 +61,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li
--no-daemon
# Stage 2: Extract Spring Boot Layers
FROM eclipse-temurin:25-jre-noble@sha256:2f1da100788559b397bcf48c736169ea5b070bde84e55f203bbee8e83d87a175 AS jar-extract
FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS jar-extract
WORKDIR /tmp
COPY --from=app-build /app/app/core/build/libs/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers
+1 -1
View File
@@ -56,7 +56,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li
--no-daemon
# Stage 2: Extract Spring Boot Layers
FROM eclipse-temurin:25-jre-noble@sha256:2f1da100788559b397bcf48c736169ea5b070bde84e55f203bbee8e83d87a175 AS jar-extract
FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS jar-extract
WORKDIR /tmp
COPY --from=app-build /app/app/core/build/libs/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers
+1 -1
View File
@@ -57,7 +57,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li
# Stage 2: Runtime image
# glibc base (not Alpine/musl): JPDFium's PDFium natives are glibc-linked.
FROM eclipse-temurin:25-jre-noble@sha256:2f1da100788559b397bcf48c736169ea5b070bde84e55f203bbee8e83d87a175
FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db
ENV DEBIAN_FRONTEND=noninteractive \
LANG=C.UTF-8 \
+1 -1
View File
@@ -64,7 +64,7 @@ updater-signatures = [
]
# Pinned repository-wide pre-commit tooling.
pre-commit = [
"codespell==2.4.2",
"codespell==2.4.3",
"ruff==0.15.5",
"tomli-w==1.2.0",
]
+4 -4
View File
@@ -414,11 +414,11 @@ wheels = [
[[package]]
name = "codespell"
version = "2.4.2"
version = "2.4.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2d/9d/1d0903dff693160f893ca6abcabad545088e7a2ee0a6deae7c24e958be69/codespell-2.4.2.tar.gz", hash = "sha256:3c33be9ae34543807f088aeb4832dfad8cb2dae38da61cac0a7045dd376cfdf3", size = 352058, upload-time = "2026-03-05T18:10:42.936Z" }
sdist = { url = "https://files.pythonhosted.org/packages/80/19/45e941380f69c042b43423513d201e6592346f992394347f5e7174c31407/codespell-2.4.3.tar.gz", hash = "sha256:cbe085e331227b37bb86ef8bddd08dc768c704ee9a07ca869852c093fa2793e2", size = 352773, upload-time = "2026-07-15T11:51:54.159Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/42/a1/52fa05533e95fe45bcc09bcf8a503874b1c08f221a4e35608017e0938f55/codespell-2.4.2-py3-none-any.whl", hash = "sha256:97e0c1060cf46bd1d5db89a936c98db8c2b804e1fdd4b5c645e82a1ec6b1f886", size = 353715, upload-time = "2026-03-05T18:10:41.398Z" },
{ url = "https://files.pythonhosted.org/packages/8b/bf/bdb951d34eb169140b546f44be9ec4525d1acefb9eb5572071f5492b19fc/codespell-2.4.3-py3-none-any.whl", hash = "sha256:af2505b335e8573dbd2d384d1c4ef498f4006f4ba2d6fceca01e55b91f52628a", size = 340736, upload-time = "2026-07-15T11:51:52.925Z" },
]
[[package]]
@@ -718,7 +718,7 @@ engine-dev = [
{ name = "ruff", specifier = "==0.15.5" },
]
pre-commit = [
{ name = "codespell", specifier = "==2.4.2" },
{ name = "codespell", specifier = "==2.4.3" },
{ name = "ruff", specifier = "==0.15.5" },
{ name = "tomli-w", specifier = "==1.2.0" },
]
@@ -1879,6 +1879,9 @@ width = "Width"
[app]
description = "The Free Adobe Acrobat alternative (10M+ Downloads)"
[appBanner]
dismiss = "Dismiss"
[attachments]
convertToPdfA3b = "Convert to PDF/A-3b"
convertToPdfA3bDescription = "Creates an archival PDF with embedded attachments"
@@ -3010,6 +3013,7 @@ saturation = "Saturation and brightness"
title = "Choose color"
[common]
actions = "Actions"
back = "Back"
cancel = "Cancel"
close = "Close"
@@ -3025,6 +3029,7 @@ error = "Error"
expand = "Expand"
loading = "Loading..."
next = "Next"
open = "Open"
preview = "Preview"
previous = "Previous"
refresh = "Refresh"
@@ -4825,9 +4830,6 @@ title = "Image to PDF"
[imageToPdf]
tags = "conversion,img,jpg,picture,photo"
[infoBanner]
dismiss = "Dismiss"
[invite]
acceptError = "Failed to create account"
accountFor = "Creating account for"
@@ -5055,6 +5057,14 @@ title = "Upload from Mobile"
tags = "Multi Tool,Multi operation,UI,click drag,front end,client side,interactive,intractable,move,delete,migrate,divide"
title = "PDF Multi Tool"
[navFooter]
openEditor = "Open PDF Editor"
openProcessor = "Open PDF Processor"
[navFooter.credits]
count = "{{remaining}} of {{total}}"
label = "Free credits"
[oauth.error]
message = "Authentication was not successful. You can close this window and try again."
title = "Authentication Failed"
@@ -5619,8 +5629,8 @@ freeBody = "View, edit, merge, split, sign, watermark, compress, convert and man
freeTitle = "Unlimited PDF editing"
[payg.free.hero]
barAria = "Free PDFs used"
capSuffix = "/ {{limit}} free PDFs"
barAria = "Free PDFs remaining"
capSuffix = "of {{limit}} free PDFs left"
metaCategories = "Automation · AI · API requests"
[payg.free.member]
@@ -6339,7 +6349,6 @@ revoked = "Revoked"
unnamed = "Unnamed instance"
[portal.accountLink.instances.columns]
actions = "Actions"
instance = "Instance"
lastSeen = "Last seen"
linked = "Linked"
@@ -6684,12 +6693,12 @@ reachedTitle = "Monthly spend limit reached"
title = "Couldn't open Stripe portal"
[portal.billing.walletMeter]
barAria = "Free PDFs used"
capSuffix_one = "of {{allowance}} free PDFs used"
capSuffix_other = "of {{allowance}} free PDFs used"
barAria = "Free PDFs remaining"
capSuffix_one = "of {{allowance}} free PDF left"
capSuffix_other = "of {{allowance}} free PDFs left"
eyebrow = "Processor trial"
statusLabel_one = "{{remaining}} left"
statusLabel_other = "{{remaining}} left"
statusLabel_one = "{{used}} used"
statusLabel_other = "{{used}} used"
sub = "Use the PDF Editor for free. Pay to process PDFs automatically."
title_one = "Process {{allowance}} PDFs free"
title_other = "Process {{allowance}} PDFs free"
@@ -7262,12 +7271,11 @@ editorAction = "Editor"
empty = "No documents match this filter."
rowActions = "Row actions"
sensitiveLabel = "Sensitive"
sensitiveTitle = "Sensitive — access required"
[portal.documents.table.columns]
action = "Pipeline / Action"
actions = "Actions"
document = "Document"
labels = "Labels"
product = "Product"
status = "Status"
time = "Time"
@@ -7283,6 +7291,7 @@ host = "Host"
lastSeen = "Last seen"
region = "Region"
status = "Status"
target = "Target"
version = "Version"
[portal.editorAdmin.health.empty]
@@ -7371,6 +7380,14 @@ confirm = "Are you sure?"
dismiss = "Dismiss"
dismissSkipFile = "Skip this file"
[portal.failures.debug]
copyJson = "Copy JSON"
dismissAll = "Dismiss all ({{total}})"
dismissing = "Dismissing..."
hideJson = "Hide raw JSON ({{total}})"
refresh = "Refresh failures"
showJson = "Show raw JSON ({{total}})"
[portal.failures.disabled]
closed = "This failure is already closed."
unavailable = "Not available for this failure."
@@ -7602,11 +7619,9 @@ security = "Security"
storage = "Storage"
[portal.integrations]
addAnother = "Add another"
availableHeading = "Available"
comingSoonHeading = "Coming soon"
connect = "Connect"
connectedHeading = "Connected"
connectionCount_one = "{{count}} connection"
connectionCount_other = "{{count}} connections"
customApi = "Custom API"
@@ -7624,6 +7639,10 @@ security = "Security"
signing = "Signing"
storage = "Storage"
[portal.integrations.noResults]
description = "No integrations match your filters. Try a different category or search."
title = "No matches"
[portal.integrations.status]
connected = "Connected"
@@ -7654,7 +7673,6 @@ integrations = "Integrations"
pipelines = "Pipelines"
policies = "Policies"
procurement = "Procurement"
settings = "Settings"
sources = "Sources"
usage = "Usage & Billing"
users = "Users"
@@ -7826,10 +7844,10 @@ paused = "Paused"
[portal.pipelines.table]
name = "Pipeline"
open = "Open"
sources = "Sources"
status = "Status"
steps = "Steps"
trigger = "Trigger"
[portal.pipelines.trigger]
folder-watch = "Folder watch"
@@ -8722,9 +8740,9 @@ unused = "Unused"
[portal.sources.table]
documents = "Documents"
open = "Open"
source = "Source"
status = "Status"
type = "Type"
usedBy = "Policies"
[portal.sources.types.box]
@@ -10948,6 +10966,13 @@ approver = "Approves policy"
editor = "Editor"
processor = "Processor"
[users.columns]
capabilities = "Capabilities"
email = "Email"
person = "Person"
role = "Role"
status = "Status"
[users.confirm]
cancelInviteBody = "Cancel the invitation to {{email}}? They won't be able to join with the current link."
cancelInviteTitle = "Cancel invitation"
@@ -10967,10 +10992,8 @@ title = "No members yet"
addToTeam = "Add to team"
guestCount = "{{count}} guest"
guests = "Guests"
guestsDesc = "External collaborators, scoped to what you shared. Editor only."
ledBy = "led by {{owner}}"
org = "Organization"
orgDesc = "Owners with org-wide authority and policy approval"
owners = "{{count}} owner"
team = "{{name}} team"
teamMeta = "{{count}} people"
@@ -11010,13 +11033,15 @@ usernamePlaceholder = "jsmith"
[users.invites]
by = "Invited by {{who}}"
cancel = "Cancel"
count = "{{count}} pending"
desc = "Invited people who haven't joined yet. They hold a seat until they accept."
expiresInDays_one = "Expires in {{count}} day"
expiresInDays_other = "Expires in {{count}} days"
expiresToday = "Expires today"
title = "Pending invitations"
[users.invites.columns]
expires = "Expires"
invitee = "Invitee"
[users.loadError]
description = "Something went wrong reaching the backend, or you don't have access. Try again."
title = "Couldn't load members"
@@ -642,7 +642,6 @@ const CODE_EXEMPT_PATH = [
/mantineTheme|\/theme\.ts$|toolsTaxonomy|LayoutPreview|PageNumberPreview|CloudStorageIcons|BrandMarks/,
/\/onboarding\//,
/addStamp|addWatermark|\/tooltips\//,
/UpgradeBanner|AdminPlanSection/,
// Stories are checked like app code; colour-as-data lines opt out with
// `theme-allow-color`.
/\.test\.[jt]sx?$|\/types\//,
+3 -3
View File
@@ -276,9 +276,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.0"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "bit-set"
@@ -4551,7 +4551,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
name = "stirling-pdf"
version = "0.1.0"
dependencies = [
"base64 0.23.0",
"base64 0.23.1",
"core-foundation 0.10.1",
"core-services",
"keyring",
@@ -3,7 +3,7 @@ import { Group, Text } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { InfoBanner } from "@app/components/shared/InfoBanner";
import { AppBanner } from "@app/components/shared/AppBanner";
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
/**
@@ -105,7 +105,7 @@ export function TeamInvitationBanner() {
);
return (
<InfoBanner
<AppBanner
icon="mail"
message={
<Group
@@ -120,10 +120,6 @@ export function TeamInvitationBanner() {
}
show={shouldShow}
dismissible={false}
background="var(--mantine-color-dark-7)"
borderColor="var(--mantine-color-dark-5)"
textColor="rgba(255, 255, 255, 0.95)"
iconColor="rgba(255, 255, 255, 0.95)"
/>
);
}
@@ -12,6 +12,7 @@ import {
formatPeriodDate,
MeterBar,
meterState,
remainingMeter,
} from "@app/billing";
import "@app/components/shared/config/configSections/Payg.css";
import "@app/components/shared/config/configSections/PaygFree.css";
@@ -48,7 +49,8 @@ export function useFreeSnapshot(): FreeSnapshot {
export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) {
const { t } = useTranslation();
const { state, pct } = meterState(snap.billableUsed, snap.billableLimit);
const remaining = Math.max(0, snap.billableLimit - snap.billableUsed);
const { state, pct } = remainingMeter(remaining, snap.billableLimit);
const stateLabel =
state === "DEGRADED"
? t("payg.free.state.limitReached", "Limit reached")
@@ -60,9 +62,9 @@ export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) {
<MeterBar
state={state}
pct={pct}
barLabel={t("payg.free.hero.barAria", "Free PDFs used")}
figure={snap.billableUsed.toLocaleString()}
capSuffix={t("payg.free.hero.capSuffix", "/ {{limit}} free PDFs", {
barLabel={t("payg.free.hero.barAria", "Free PDFs remaining")}
figure={remaining.toLocaleString()}
capSuffix={t("payg.free.hero.capSuffix", "of {{limit}} free PDFs left", {
limit: snap.billableLimit.toLocaleString(),
})}
statusLabel={stateLabel}
@@ -175,15 +177,15 @@ export function prepaidSnapshotFromWallet(
}
/**
* Prepaid capacity meter. The bar fills as the pool is drawn down ({@code used =
* total remaining}), so it WARNs when the pool is running low and DEGRADEs once
* exhausted — same bands as the free/cap meters. Prepaid is consumed ahead of the
* meter and outside the spend cap, so it reads as its own dimension.
* Prepaid capacity meter. The bar shows what is left and drains towards empty,
* while the bands still key on what is gone ({@code used = total remaining}), so
* it WARNs when the pool is running low and DEGRADEs once exhausted — same bands
* as the free/cap meters. Prepaid is consumed ahead of the meter and outside the
* spend cap, so it reads as its own dimension.
*/
export function PrepaidCapacityMeterPanel({ snap }: { snap: PrepaidSnapshot }) {
const { t } = useTranslation();
const used = Math.max(0, snap.total - snap.remaining);
const { state, pct } = meterState(used, snap.total);
const { state, pct } = remainingMeter(snap.remaining, snap.total);
const stateLabel =
state === "DEGRADED"
? t("payg.prepaid.state.exhausted", "Used up")
@@ -0,0 +1,50 @@
import { useEffect, useState } from "react";
import { useWallet } from "@app/hooks/useWallet";
import {
readCachedCredits,
writeCachedCredits,
type CachedCredits,
} from "@app/services/navFooterCache";
import { type NavFooterCredits } from "@app/components/shared/navFooter/NavFooterCreditsRow";
/** The wallet reduced to what the footer shows: figures, or null for a payer. */
function toCredits(
status: string,
freeRemaining: number,
freeAllowance: number,
): CachedCredits {
// Free teams only. The grant is a lifetime pool that survives subscribing, so
// a paying team would otherwise sit on a permanent "0 of 500" in red while
// nothing is wrong. Plan draws the same line — subscribed teams get the
// spend-vs-cap meter there, and admins get usage in the processor.
if (status === "subscribed") return null;
return { remaining: freeRemaining, total: freeAllowance };
}
/**
* Cloud builds read the free grant off the live wallet — the same snapshot the
* Plan page's free meter renders, so the sidebar and Plan can't disagree.
*
* Seeded from the last figures this browser saw, so the row is right at first
* paint and stays put while the wallet refetches underneath. Only a browser
* that has never loaded a wallet has nothing to show, and that one time the row
* animates in. The seed is read once, at first render: later reads would fight
* the live value, and the whole point is that the row stops moving.
*/
export function useFreeCreditsSummary(): NavFooterCredits | null {
const { wallet } = useWallet();
const [seed] = useState(readCachedCredits);
const live = wallet
? toCredits(wallet.status, wallet.freeRemaining, wallet.freeAllowance)
: undefined;
// useWallet reuses the snapshot reference when nothing changed, so keying on
// it writes only on a real change, not on every render.
useEffect(() => {
if (live !== undefined) writeCachedCredits(live);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wallet]);
return (live !== undefined ? live : seed) ?? null;
}
@@ -0,0 +1,13 @@
import { useCallback } from "react";
import { useNavigate } from "react-router-dom";
/**
* Cloud editor builds open the settings modal on its Plan section, which is
* where the free grant is explained and the Processor plan is switched on.
* Routed rather than called directly because the modal is URL-driven here
* (`/settings/*`), the same path the admin tour uses to open it.
*/
export function useOpenPlan(): (() => void) | null {
const navigate = useNavigate();
return useCallback(() => navigate("/settings/plan"), [navigate]);
}
+86 -4
View File
@@ -32,6 +32,14 @@
* promise see the UI flip exactly once the new state is visible — no
* intermediate flash of the old value.
*
* <h2>Freshness</h2>
*
* The figures drain as metered work runs, so a mounted consumer re-reads the
* wallet every {@link WALLET_POLL_MS} and again whenever the tab regains
* visibility. Those refreshes are silent — they leave {@code loading} and
* {@code error} alone and only commit fresher data — so consumers that gate on
* those flags don't flicker on a background tick.
*
* <h2>Dev preview fallback</h2>
*
* When the hook is rendered outside the saas app (e.g. on {@code
@@ -178,6 +186,13 @@ function reuseIfEqual(prev: Wallet | null, next: Wallet): Wallet {
return prev;
}
/**
* How often a mounted consumer re-reads the wallet. Matches the app query
* client's staleTime, so the sidebar meter and anything cached elsewhere age
* out on the same clock.
*/
const WALLET_POLL_MS = 30_000;
export function useWallet(): UseWalletResult {
// Resolved once: the dev-preview side-channel when rendered outside the real
// app (saas /dev/payg-preview route), else null (every real build + desktop).
@@ -201,13 +216,29 @@ export function useWallet(): UseWalletResult {
// "the request fired." Cleared when no load is pending.
const inFlight = useRef<Promise<void> | null>(null);
// Set for refreshes the user didn't ask for (the poll below). Silence governs
// whether a load may RAISE `loading` / `error`, never whether it may clear
// them: consumers gate on both — the limit modals do
// `if (loading || !wallet) return null`, and Plan swaps in an error alert —
// so a background tick must not blink an open modal out or replace a working
// page over a transient failure. Clearing is always the latest request's job,
// silent or not; a silent load that skipped the clear would strand `loading`
// true after superseding a visible one, which suppresses those modals for the
// rest of the session.
const silentRefresh = useRef(false);
useEffect(() => {
const reqId = ++latestReqId.current;
let cancelled = false;
const silent = silentRefresh.current;
silentRefresh.current = false;
const promise = (async () => {
setLoading(true);
setError(null);
if (!silent) {
setLoading(true);
setError(null);
}
if (devPreview) {
const synth = devPreview.buildWallet(devPreview.role());
@@ -221,11 +252,22 @@ export function useWallet(): UseWalletResult {
const res = await apiClient.get<Wallet>("/api/v1/payg/wallet");
if (cancelled || reqId !== latestReqId.current) return;
setWallet((prev) => reuseIfEqual(prev, res.data));
// Fresh data retires any earlier failure, including one a silent poll
// is recovering from — otherwise Plan keeps its alert over good data.
setError(null);
} catch (e: unknown) {
if (cancelled || reqId !== latestReqId.current) return;
console.warn("[useWallet] fetch failed", e);
setError(e instanceof Error ? e.message : "Failed to load wallet");
if (!silent) {
console.warn("[useWallet] fetch failed", e);
setError(e instanceof Error ? e.message : "Failed to load wallet");
}
// A failed background refresh is a non-event: the last good snapshot
// stands and the next tick self-heals, so it neither surfaces nor
// logs — otherwise an offline tab warns every WALLET_POLL_MS.
} finally {
// Deliberately not gated on `silent`: whichever load is latest owns
// settling the flag, or a silent refresh that supersedes a visible one
// leaves it stuck true.
if (!cancelled && reqId === latestReqId.current) {
setLoading(false);
}
@@ -242,6 +284,46 @@ export function useWallet(): UseWalletResult {
};
}, [devPreview, refetchTick]);
// The wallet drains as automation, AI and API work runs, so a figure fetched
// on mount goes stale while the user watches it. Refresh on a timer, and
// immediately on returning to the tab — coming back to a stale number is the
// case people actually notice. Hidden tabs don't poll, and the dev-preview
// wallet is synthesised locally so there is nothing to re-read.
useEffect(() => {
if (devPreview) return;
let timer: ReturnType<typeof setInterval> | undefined;
const refresh = () => {
silentRefresh.current = true;
setRefetchTick((t) => t + 1);
};
const stop = () => {
if (timer !== undefined) {
clearInterval(timer);
timer = undefined;
}
};
const start = () => {
stop();
timer = setInterval(refresh, WALLET_POLL_MS);
};
const onVisibilityChange = () => {
if (document.visibilityState === "visible") {
refresh();
start();
} else {
stop();
}
};
if (document.visibilityState === "visible") start();
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
stop();
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, [devPreview]);
const refetch = useCallback(async () => {
setRefetchTick((t) => t + 1);
// Snapshot the next-tick promise so the caller awaits this refetch
@@ -4,7 +4,7 @@ import { AppLayout } from "@app/components/AppLayout";
import { BannerProvider, useBanner } from "@app/contexts/BannerContext";
import { NavigationProvider } from "@app/contexts/NavigationContext";
import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider";
import { InfoBanner } from "@app/components/shared/InfoBanner";
import { AppBanner } from "@app/components/shared/AppBanner";
const meta = {
title: "Components/AppLayout",
@@ -49,7 +49,7 @@ function BannerSetter() {
const { setBanner } = useBanner();
useEffect(() => {
setBanner(
<InfoBanner
<AppBanner
icon="info-rounded"
title="Heads up"
message="This workspace is running in offline mode."
@@ -0,0 +1,115 @@
/* App-wide top bar. One shape, four tones — callers pick a tone, never a colour.
Named `app-banner`, not `sui-banner`: that belongs to the SUI Banner primitive. */
.app-banner {
display: flex;
align-items: center;
gap: 0.75rem;
min-height: 3.5rem;
padding: 0.75rem 1rem;
/* Full-bleed across the top of the app: square corners, one rule underneath. */
border-bottom: 1px solid var(--app-banner-border);
background: var(--app-banner-bg);
color: var(--c-text);
}
.app-banner--compact {
min-height: 2.75rem;
padding: 0.5rem 1rem;
gap: 0.5rem;
}
.app-banner--info {
--app-banner-bg: var(--c-primary-subtle);
--app-banner-border: var(--c-primary-border);
--app-banner-icon: var(--c-accent-fg, var(--c-primary));
}
/* The one bar meant to pop, so it takes the feature gradient rather than a tint.
Fixed hues by design — it doesn't follow the chosen accent. */
.app-banner--promo {
--app-banner-bg: linear-gradient(
135deg,
var(--c-hue-indigo) 0%,
var(--c-hue-purple) 100%
);
--app-banner-border: transparent;
--app-banner-icon: var(--color-text-on-accent);
color: var(--color-text-on-accent);
}
.app-banner--warning {
--app-banner-bg: var(--c-warning-subtle);
--app-banner-border: color-mix(in srgb, var(--c-warning) 32%, transparent);
--app-banner-icon: var(--c-warning);
}
.app-banner--danger {
--app-banner-bg: var(--c-danger-subtle);
--app-banner-border: color-mix(in srgb, var(--c-danger) 32%, transparent);
--app-banner-icon: var(--c-danger);
}
/* Only the icon carries the tone; text stays neutral in every tone. */
.app-banner__icon {
display: flex;
flex-shrink: 0;
align-items: center;
color: var(--app-banner-icon);
}
.app-banner__body {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 0.125rem;
}
.app-banner__title {
font-size: 0.875rem;
font-weight: 600;
}
.app-banner__message {
font-size: 0.8125rem;
line-height: 1.4;
color: var(--c-text-muted);
}
.app-banner__body:not(:has(.app-banner__title)) .app-banner__message {
color: inherit;
font-weight: 500;
}
.app-banner--compact .app-banner__title,
.app-banner--compact .app-banner__message {
font-size: 0.75rem;
}
/* On the gradient everything is white; muted grey would disappear. */
.app-banner--promo .app-banner__message,
.app-banner--promo .app-banner__actions .sui-btn--tertiary,
.app-banner--promo .app-banner__actions .sui-ai {
color: var(--color-text-on-accent);
}
/* Lifts the premium CTA off the gradient it sits on. */
.app-banner--promo .app-banner__actions .sui-btn--primary {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
}
.app-banner__actions {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 0.5rem;
}
/* Mantine trims the leading padding when a button has a left section, which reads
as off-centre next to the label. Even it back up. */
.app-banner__actions .sui-btn .mantine-Button-inner {
padding-inline: 0;
}
.app-banner__actions .sui-btn {
padding-inline: 0.875rem;
}
@@ -0,0 +1,194 @@
import type { ReactNode } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AppBanner } from "@app/components/shared/AppBanner";
const meta = {
title: "Shared/AppBanner",
component: AppBanner,
parameters: { layout: "fullscreen" },
} satisfies Meta<typeof AppBanner>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Info: Story = {
args: {
icon: "info-rounded",
title: "Heads up",
message: "This document contains form fields that will be flattened.",
},
};
export const Promo: Story = {
args: {
tone: "promo",
icon: "stars-rounded",
title: "Upgrade to Server Plan",
message:
"Get the most out of Stirling PDF with unlimited users and advanced features.",
buttonText: "Upgrade Now",
buttonIcon: "upgrade-rounded",
onButtonClick: () => {},
compact: true,
},
};
export const Warning: Story = {
args: {
tone: "warning",
icon: "warning-rounded",
title: "Action required",
message: "Some pages could not be processed and were skipped.",
buttonText: "Review",
onButtonClick: () => {},
},
};
export const Danger: Story = {
args: {
tone: "danger",
icon: "warning-rounded",
title: "This server needs admin attention",
message: "Review the license requirements to keep this server compliant.",
buttonText: "See info",
buttonIcon: "info-rounded",
onButtonClick: () => {},
dismissible: false,
},
};
export const Compact: Story = {
args: {
compact: true,
icon: "info-rounded",
message: "Autosave is enabled for this file.",
dismissible: false,
},
};
/** Message-only, no title: the message takes the title's weight so the bar still reads. */
export const MessageOnly: Story = {
args: {
icon: "picture-as-pdf-rounded",
message:
"Make Stirling PDF your default application for opening PDF files.",
buttonText: "Set Default",
onButtonClick: () => {},
secondaryButtonText: "Don't remind me again",
onSecondaryButtonClick: () => {},
},
};
function Row({ caption, children }: { caption: string; children: ReactNode }) {
return (
<section
style={{ display: "flex", flexDirection: "column", gap: "0.375rem" }}
>
<span
style={{
fontSize: "0.6875rem",
fontWeight: 600,
letterSpacing: "0.04em",
textTransform: "uppercase",
color: "var(--c-text-subtle)",
padding: "0 1rem",
}}
>
{caption}
</span>
{children}
</section>
);
}
/**
* Every top bar the app can show, in one place: each entry mirrors a real caller,
* so a change to the component is visible against the whole set at once. Renders a
* composition rather than the component, so it takes no args of its own.
*/
export const AllTopBars: StoryObj = {
render: () => (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "1.5rem",
padding: "1.5rem 0",
background: "var(--c-bg)",
}}
>
<Row caption="Upgrade prompt · UpgradeBanner (friendly)">
<AppBanner
tone="promo"
compact
icon="stars-rounded"
title="Upgrade to Server Plan"
message="Get the most out of Stirling PDF with unlimited users and advanced features."
buttonText="Upgrade Now"
buttonIcon="upgrade-rounded"
onButtonClick={() => {}}
/>
</Row>
<Row caption="Server needs attention · UpgradeBanner (urgent)">
<AppBanner
tone="warning"
icon="warning-rounded"
title="This server needs admin attention"
message="Review the license requirements to keep this server compliant."
buttonText="See info"
buttonIcon="info-rounded"
onButtonClick={() => {}}
dismissible={false}
/>
</Row>
<Row caption="Free tier limit reached · AdminPlanSection">
<AppBanner
tone="warning"
icon="warning-rounded"
title="Free self-hosted limit reached"
message="You have 12 users on a plan that covers 10."
buttonText="See plans"
buttonIcon="upgrade-rounded"
onButtonClick={() => {}}
dismissible={false}
/>
</Row>
<Row caption="Team invitation · TeamInvitationBanner">
<AppBanner
icon="mail"
message="You have been invited to join the Acme Legal team."
buttonText="Accept"
onButtonClick={() => {}}
secondaryButtonText="Decline"
onSecondaryButtonClick={() => {}}
dismissible={false}
/>
</Row>
<Row caption="Set as default app · DefaultAppBanner (desktop)">
<AppBanner
icon="picture-as-pdf-rounded"
message="Make Stirling PDF your default application for opening PDF files."
buttonText="Set Default"
onButtonClick={() => {}}
secondaryButtonText="Don't remind me again"
onSecondaryButtonClick={() => {}}
/>
</Row>
<Row caption="Danger tone (available, no caller yet)">
<AppBanner
tone="danger"
icon="warning-rounded"
title="Storage is full"
message="New uploads will fail until space is freed."
buttonText="Manage storage"
onButtonClick={() => {}}
dismissible={false}
/>
</Row>
</div>
),
};
@@ -0,0 +1,124 @@
import React, { ReactNode } from "react";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import "@app/components/shared/AppBanner.css";
/** Picks the whole look. Callers choose meaning, never colours. */
export type AppBannerTone = "info" | "promo" | "warning" | "danger";
/** Tone decides the button too, so the CTA can't drift from the bar it sits on. */
const TONE_BUTTON = {
info: { variant: "secondary", accent: "default" },
promo: { variant: "primary", accent: "premium" },
warning: { variant: "primary", accent: "warning" },
danger: { variant: "primary", accent: "danger" },
} as const;
interface AppBannerProps {
/** A LocalIcon name, or a pre-rendered node (e.g. a logo) dropped in as-is. */
icon?: string | ReactNode;
title?: ReactNode;
message: ReactNode;
buttonText?: string;
buttonIcon?: string;
onButtonClick?: () => void;
/** Muted secondary action, e.g. "Don't remind me again". */
secondaryButtonText?: string;
onSecondaryButtonClick?: () => void;
onDismiss?: () => void;
dismissible?: boolean;
loading?: boolean;
show?: boolean;
tone?: AppBannerTone;
compact?: boolean;
}
/** The app's top bar: dismissible messaging above the workspace. */
export const AppBanner: React.FC<AppBannerProps> = ({
icon,
title,
message,
buttonText,
buttonIcon = "check-circle-rounded",
onButtonClick,
secondaryButtonText,
onSecondaryButtonClick,
onDismiss,
dismissible = true,
loading = false,
show = true,
tone = "info",
compact = false,
}) => {
const { t } = useTranslation();
if (!show) return null;
const iconSize = compact ? "1rem" : "1.25rem";
return (
<div
className={[
"app-banner",
`app-banner--${tone}`,
compact ? "app-banner--compact" : "",
]
.filter(Boolean)
.join(" ")}
>
{icon != null && (
<span className="app-banner__icon" aria-hidden>
{typeof icon === "string" ? (
<LocalIcon icon={icon} width={iconSize} height={iconSize} />
) : (
icon
)}
</span>
)}
<div className="app-banner__body">
{title && <span className="app-banner__title">{title}</span>}
<span className="app-banner__message">{message}</span>
</div>
<div className="app-banner__actions">
{buttonText && onButtonClick && (
<Button
variant={TONE_BUTTON[tone].variant}
accent={TONE_BUTTON[tone].accent}
size="sm"
loading={loading}
onClick={onButtonClick}
leftSection={
<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />
}
>
{buttonText}
</Button>
)}
{secondaryButtonText && onSecondaryButtonClick && (
<Button
variant="tertiary"
accent="neutral"
size="sm"
onClick={onSecondaryButtonClick}
>
{secondaryButtonText}
</Button>
)}
{dismissible && (
<ActionIcon
variant="tertiary"
accent="neutral"
size="sm"
onClick={() => onDismiss?.()}
aria-label={t("appBanner.dismiss", "Dismiss")}
>
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
</ActionIcon>
)}
</div>
</div>
);
};
@@ -48,9 +48,55 @@
transform: matrix(0.483871, -0.017568, 0, 0.338028, 23.887097, 26.886428);
}
/* One-shot "thinking" drift — the two parallelograms swap past each other and
settle back. Same motion the chat FAB loops while the agent works, but this
pair starts and ends at rest (translate 0, full opacity) so a single
iteration can end without snapping. Callers apply it for one beat; see
NavFooter.css for the hover use. */
@keyframes sui-brandmark-drift-a {
0%,
100% {
transform: translate(0, 0);
opacity: 1;
}
25% {
transform: translate(-1px, -5px);
opacity: 0.55;
}
50% {
transform: translate(-6px, 0);
opacity: 0.9;
}
75% {
transform: translate(-1px, 5px);
opacity: 0.6;
}
}
@keyframes sui-brandmark-drift-b {
0%,
100% {
transform: translate(0, 0);
opacity: 1;
}
25% {
transform: translate(1px, 5px);
opacity: 0.85;
}
50% {
transform: translate(6px, 0);
opacity: 0.5;
}
75% {
transform: translate(1px, -5px);
opacity: 0.85;
}
}
@media (prefers-reduced-motion: reduce) {
.sui-brandmark__a,
.sui-brandmark__b {
transition: none;
animation: none;
}
}
@@ -75,16 +75,13 @@
padding: 0.25rem 0;
overflow: hidden;
}
.file-sidebar-footer-box {
padding: 0.25rem 0;
flex-shrink: 0;
}
/* The footer is the shared <NavFooter>: it brings its own boxes and padding,
so this class only positions it in the column. */
/* Collapsed rail: the file tree isn't rendered, so hide its (empty) box and
let the boxes stack at the top — controls, then the settings footer right
after — instead of the files box stretching to fill. */
.file-sidebar[data-collapsed="true"] .file-sidebar-controls,
.file-sidebar[data-collapsed="true"] .file-sidebar-footer-box {
.file-sidebar[data-collapsed="true"] .file-sidebar-controls {
padding: 0.25rem;
}
.file-sidebar[data-collapsed="true"] .file-sidebar-files-box {
@@ -538,86 +535,3 @@
pointer-events: none;
animation: none;
}
/* ---- Bottom bar (user + settings) ---- */
.file-sidebar-bottom-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
flex-shrink: 0;
min-height: 40px;
}
/* Bottom bar settings icon tracks the right edge during collapse animation */
.file-sidebar-bottom-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background-color: var(--c-accent-text);
color: var(--c-text-on-primary);
font-size: 12px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
user-select: none;
overflow: hidden;
}
/* No colored disc behind an actual photo; keep it for the initials fallback. */
.file-sidebar-bottom-avatar--picture {
background-color: transparent;
}
.file-sidebar-bottom-avatar-img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.file-sidebar-bottom-name {
flex: 1;
font-size: 13px;
font-weight: 500;
color: var(--c-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.file-sidebar-bottom-bar[role="button"]:hover {
background-color: var(--c-hover);
}
.file-sidebar-bottom-bar[role="button"]:focus-visible {
outline: 2px solid var(--c-primary);
outline-offset: -2px;
}
.file-sidebar-bottom-settings {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
color: var(--c-text-subtle);
padding: 0;
flex-shrink: 0;
margin-left: auto;
}
.file-sidebar[data-collapsed="true"] .file-sidebar-bottom-settings {
width: 32px;
height: 32px;
}
.file-sidebar[data-collapsed="true"] .file-sidebar-bottom-bar {
justify-content: center;
padding: 8px 0;
}
@@ -22,13 +22,15 @@ import {
} from "@app/contexts/NavigationContext";
import { useViewer } from "@app/contexts/ViewerContext";
import { useFileHandler } from "@app/hooks/useFileHandler";
import { useAuth } from "@app/auth/UseSession";
import { useProfilePictureUrl } from "@app/hooks/useProfilePictureUrl";
import { useAccountIdentity } from "@app/hooks/useAccountIdentity";
import { useFreeCreditsSummary } from "@app/hooks/useFreeCreditsSummary";
import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch";
import { useOpenPlan } from "@app/hooks/useOpenPlan";
import { NavFooter } from "@app/components/shared/navFooter/NavFooter";
import {
useIndexedDB,
useIndexedDBRevision,
} from "@app/contexts/IndexedDBContext";
import { accountService } from "@app/services/accountService";
import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
import { AppSwitcher } from "@app/components/shared/AppSwitcher";
import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon";
@@ -37,8 +39,7 @@ import FolderOpenIcon from "@mui/icons-material/FolderOpen";
import FolderSpecialIcon from "@mui/icons-material/FolderSpecial";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import AddIcon from "@mui/icons-material/Add";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import SettingsIcon from "@mui/icons-material/Settings";
import OpenInFullIcon from "@mui/icons-material/OpenInFull";
import type { FileId } from "@app/types/file";
import { FileItem } from "@app/components/shared/FileSidebarFileItem";
import { useLabelName } from "@app/data/labelDisplay";
@@ -241,43 +242,11 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
const { addFiles } = useFileHandler();
const indexedDB = useIndexedDB();
// Each auth layer derives its own displayName from its native user shape.
// Fall back to the proprietary REST endpoint only when the auth
// context yields nothing - then to "User" as a generic last resort.
const { displayName: authDisplayName, isAnonymous } = useAuth();
const [accountUsername, setAccountUsername] = useState<string | null>(null);
const displayName =
authDisplayName ?? accountUsername ?? t("auth.displayName.user", "User");
const profilePictureUrl = useProfilePictureUrl();
const [pictureFailed, setPictureFailed] = useState(false);
useEffect(() => setPictureFailed(false), [profilePictureUrl]);
const showProfilePicture = !!profilePictureUrl && !pictureFailed;
useEffect(() => {
if (!config?.enableLogin) {
setAccountUsername(null);
return;
}
if (authDisplayName) {
// The auth context has a name; don't bother hitting the REST
// endpoint, but clear any stale cached value from a prior call.
setAccountUsername(null);
return;
}
accountService
.getAccountData()
.then((data) => {
// Always reflect the latest result - including clearing it on
// sign-out, when the endpoint returns no username (or 401s into
// the catch branch below). Without this, signing out would leave
// the old username on screen.
setAccountUsername(data?.username ?? null);
})
.catch(() => {
setAccountUsername(null);
});
}, [config?.enableLogin, authDisplayName]);
const { displayName, profilePictureUrl, isAnonymous } =
useAccountIdentity();
const credits = useFreeCreditsSummary();
const otherApp = useOtherAppSwitch();
const openPlan = useOpenPlan();
// Leaf files = user-visible files (excludes intermediate tool outputs)
const [allFileStubs, setAllFileStubs] = useState<StirlingFileStub[]>([]);
@@ -1115,7 +1084,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
)}
data-testid="open-files-page"
>
<OpenInNewIcon sx={{ fontSize: "1rem" }} />
<OpenInFullIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
<ActionIcon
variant="quiet"
@@ -1264,70 +1233,17 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
{/* Getting-started checklist, floating above the footer (SaaS only). */}
<SidebarChecklistSlot collapsed={collapsed} />
{/* Box 3 — account footer (avatar + name + settings). */}
<NavSurface className="file-sidebar-footer-box">
{/* Bottom bar: user name + settings */}
<Tooltip
label={
onOpenSettings
? `${displayName} - ${t("fileSidebar.openSettings", "Open settings")}`
: displayName
}
position="right"
withinPortal
disabled={!collapsed}
>
<div
className="file-sidebar-bottom-bar"
onClick={onOpenSettings}
role={onOpenSettings ? "button" : undefined}
tabIndex={onOpenSettings ? 0 : undefined}
onKeyDown={
onOpenSettings
? (e) => e.key === "Enter" && onOpenSettings()
: undefined
}
data-testid={onOpenSettings ? "config-button" : undefined}
data-tour={onOpenSettings ? "config-button" : undefined}
aria-label={
onOpenSettings
? t("fileSidebar.openSettings", "Open settings")
: displayName
}
style={onOpenSettings ? { cursor: "pointer" } : undefined}
>
<div
className={`file-sidebar-bottom-avatar${
showProfilePicture
? " file-sidebar-bottom-avatar--picture"
: ""
}`}
aria-label={displayName}
>
{showProfilePicture ? (
<img
src={profilePictureUrl}
alt=""
className="file-sidebar-bottom-avatar-img"
onError={() => setPictureFailed(true)}
/>
) : (
displayName.charAt(0).toUpperCase()
)}
</div>
{!collapsed && (
<span className="file-sidebar-bottom-name sidebar-content-fade">
{displayName}
</span>
)}
{onOpenSettings && !collapsed && (
<div className="file-sidebar-bottom-settings">
<SettingsIcon sx={{ fontSize: "1.1rem" }} />
</div>
)}
</div>
</Tooltip>
</NavSurface>
{/* Box 3 — the shared footer: credits, app switch, account row. */}
<NavFooter
className="file-sidebar-footer-box"
displayName={displayName}
profilePictureUrl={profilePictureUrl}
onOpenSettings={onOpenSettings}
credits={credits}
onOpenPlan={openPlan ?? undefined}
otherApp={otherApp}
collapsed={collapsed}
/>
</div>
);
},
@@ -1,38 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { InfoBanner } from "@app/components/shared/InfoBanner";
const meta = {
title: "Shared/InfoBanner",
component: InfoBanner,
parameters: { layout: "padded" },
} satisfies Meta<typeof InfoBanner>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
icon: "info-rounded",
title: "Heads up",
message: "This document contains form fields that will be flattened.",
},
};
export const Warning: Story = {
args: {
tone: "warning",
icon: "warning-rounded",
title: "Action required",
message: "Some pages could not be processed and were skipped.",
buttonText: "Review",
onButtonClick: () => {},
},
};
export const Compact: Story = {
args: {
compact: true,
icon: "info-rounded",
message: "Autosave is enabled for this file.",
dismissible: false,
},
};
@@ -1,263 +0,0 @@
import React, { ReactNode } from "react";
import { Paper, Group, Text, Stack } from "@mantine/core";
import { Button, type ButtonVariant, type ButtonAccent } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
type InfoBannerTone = "info" | "warning";
const toneStyles: Record<
InfoBannerTone,
{
background: string;
border: string;
text: string;
icon: string;
buttonColor: string;
}
> = {
info: {
background: "var(--mantine-color-blue-0)",
border: "var(--mantine-color-blue-2)",
text: "var(--mantine-color-blue-9)",
icon: "var(--mantine-color-blue-6)",
buttonColor: "blue",
},
warning: {
background: "var(--mantine-color-orange-0)",
border: "var(--mantine-color-orange-3)",
text: "var(--color-amber-dark)",
icon: "var(--mantine-color-orange-7)",
buttonColor: "orange",
},
};
function toSharedButtonVariant(
variant: "light" | "filled" | "white" | "outline" | "subtle",
): ButtonVariant {
switch (variant) {
case "filled":
return "primary";
case "outline":
return "secondary";
case "subtle":
return "tertiary";
case "light":
case "white":
default:
return "secondary";
}
}
function toSharedButtonAccent(color: string | undefined): ButtonAccent {
// Mantine colours may carry a shade suffix (e.g. "orange.7"); use the hue.
const hue = (color ?? "").split(".")[0];
switch (hue) {
case "red":
return "danger";
case "green":
return "success";
case "yellow":
case "orange":
return "warning";
case "blue":
default:
return "default";
}
}
interface InfoBannerProps {
/**
* Either a LocalIcon name (string) for the standard sized icon slot, or a
* pre-rendered ReactNode (e.g. a logo image) which is dropped in as-is.
*/
icon?: string | ReactNode;
title?: ReactNode;
message: ReactNode;
buttonText?: string;
buttonIcon?: string;
onButtonClick?: () => void;
/** Optional muted secondary action (e.g. "Don't remind me again"). */
secondaryButtonText?: string;
onSecondaryButtonClick?: () => void;
onDismiss?: () => void;
dismissible?: boolean;
loading?: boolean;
show?: boolean;
tone?: InfoBannerTone;
background?: string;
borderColor?: string;
textColor?: string;
iconColor?: string;
buttonColor?: string;
buttonVariant?: "light" | "filled" | "white" | "outline" | "subtle";
/** Override the button label colour (for dark/custom theme variants). */
buttonTextColor?: string;
minHeight?: number | string;
closeIconColor?: string;
compact?: boolean;
}
/**
* Generic info banner component for displaying dismissible messages at the top of the app
*/
export const InfoBanner: React.FC<InfoBannerProps> = ({
icon,
title,
message,
buttonText,
buttonIcon = "check-circle-rounded",
onButtonClick,
secondaryButtonText,
onSecondaryButtonClick,
onDismiss,
dismissible = true,
loading = false,
show = true,
tone = "info",
background,
borderColor,
textColor,
iconColor,
buttonColor,
buttonVariant = "light",
buttonTextColor,
minHeight = 56,
closeIconColor,
compact = false,
}) => {
const { t } = useTranslation();
if (!show) {
return null;
}
const toneStyle = toneStyles[tone] ?? toneStyles.info;
const resolvedTextColor = textColor ?? toneStyle.text;
const handleDismiss = () => {
onDismiss?.();
};
const iconSize = compact ? "1rem" : "1.2rem";
const textSize = compact ? "xs" : "sm";
return (
<Paper
p={compact ? "xs" : "sm"}
radius={0}
style={{
background: background ?? toneStyle.background,
border: "none",
borderBottom:
borderColor === "transparent"
? "none"
: `1px solid ${borderColor ?? toneStyle.border}`,
minHeight,
display: "flex",
alignItems: "center",
}}
>
<Group
gap="sm"
align="center"
wrap="nowrap"
justify="space-between"
style={{ width: "100%" }}
>
<Group
gap={compact ? "xs" : "sm"}
align="center"
wrap="nowrap"
style={{ flex: 1, minWidth: 0 }}
>
{icon != null &&
(typeof icon === "string" ? (
<LocalIcon
icon={icon}
width={iconSize}
height={iconSize}
style={{ color: iconColor ?? toneStyle.icon, flexShrink: 0 }}
/>
) : (
<div
style={{ flexShrink: 0, display: "flex", alignItems: "center" }}
>
{icon}
</div>
))}
<Stack gap={compact ? 1 : 2} style={{ flex: 1, minWidth: 0 }}>
{title && (
<Text
fw={600}
size={textSize}
style={{ color: resolvedTextColor }}
>
{title}
</Text>
)}
<Text
fw={title ? 400 : 500}
size={textSize}
style={{ color: resolvedTextColor }}
lineClamp={compact ? 1 : 2}
>
{message}
</Text>
</Stack>
</Group>
<Group gap="xs" align="center" wrap="nowrap">
{buttonText && onButtonClick && (
<Button
variant={toSharedButtonVariant(buttonVariant)}
accent={toSharedButtonAccent(
buttonColor ?? toneStyle.buttonColor,
)}
size="sm"
loading={loading}
onClick={onButtonClick}
leftSection={
<LocalIcon
icon={buttonIcon}
width={compact ? "0.75rem" : "0.9rem"}
height={compact ? "0.75rem" : "0.9rem"}
/>
}
style={buttonTextColor ? { color: buttonTextColor } : undefined}
>
{buttonText}
</Button>
)}
{secondaryButtonText && onSecondaryButtonClick && (
<Button
variant="tertiary"
accent="neutral"
size="sm"
onClick={onSecondaryButtonClick}
style={{ color: "var(--c-text-muted)" }}
>
{secondaryButtonText}
</Button>
)}
{dismissible && (
<ActionIcon
variant="tertiary"
accent="neutral"
size="sm"
onClick={handleDismiss}
aria-label={t("infoBanner.dismiss", "Dismiss")}
style={{
color: closeIconColor ?? "var(--c-text-muted)",
}}
>
<LocalIcon
icon="close-rounded"
width={compact ? "0.85rem" : "1rem"}
height={compact ? "0.85rem" : "1rem"}
/>
</ActionIcon>
)}
</Group>
</Group>
</Paper>
);
};
@@ -0,0 +1,156 @@
/* Shared sidebar footer: one surface holding the link-account CTA, the credits
meter, the other-app switch and the account row, hairline-separated.
Structural only — every colour comes from a --c-* semantic token. */
.nav-footer {
display: flex;
flex-direction: column;
flex-shrink: 0;
/* Vertical only: the slots carry the horizontal padding so their separator
runs the full width of the surface. */
padding: 0.25rem 0;
overflow: hidden;
}
.nav-footer__slot {
padding-inline: 0.375rem;
}
/* Separators are drawn by the slots themselves, never as their own elements.
A slot whose contents render nothing (the link-account CTA returns null once
the org is linked, and an element is truthy even when it renders null) is
:empty, so it is skipped by both rules below — it can't leave a line behind,
and it can't push one to the top or bottom of the surface. A rule that only
ever matches a slot PRECEDED by another visible slot cannot draw a leading
separator, whatever the caller passes in. */
.nav-footer__slot:empty {
display: none;
}
.nav-footer__slot:not(:empty) ~ .nav-footer__slot:not(:empty) {
border-top: 1px solid var(--c-border-subtle);
margin-top: 0.25rem;
padding-top: 0.25rem;
}
/* Fades the rows up on the first footer mount of a page session only. They are
seeded from cache, so they're already present at first paint; replaying this
on every later mount (switching apps, remounting a view) would animate
content that never changed and read as a twitch. */
@keyframes nav-footer-row-in {
from {
opacity: 0;
transform: translateY(0.25rem);
}
to {
opacity: 1;
transform: none;
}
}
.nav-footer[data-animate] .nav-footer__slot:not(:empty) {
animation: nav-footer-row-in var(--motion-enter) both;
}
@media (prefers-reduced-motion: reduce) {
.nav-footer[data-animate] .nav-footer__slot:not(:empty) {
animation: none;
}
}
/* ---- Rows (link-account, credits, switch, account) ---- */
.nav-footer__row {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
min-height: 2.25rem;
padding: 0.25rem 0.375rem;
border: 0;
border-radius: 0.5rem;
background: none;
color: var(--c-text);
font: inherit;
text-align: left;
cursor: pointer;
}
.nav-footer__row:disabled {
cursor: default;
}
.nav-footer__row:not(:disabled):hover {
background-color: var(--c-hover);
}
.nav-footer__row:focus-visible {
outline: 2px solid var(--c-primary);
outline-offset: -2px;
}
.nav-footer__row-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 1.625rem;
}
/* Hovering the switch row plays the mark's "thinking" drift once — the same
motion the chat FAB loops, for a single beat, as a hint that the row hands
off to the other app. One iteration only: it starts and ends at rest, so
nothing snaps when it finishes, and re-entering the row replays it. */
.nav-footer__row:hover .sui-brandmark__a {
animation: sui-brandmark-drift-a 1.1s ease-in-out 1;
}
.nav-footer__row:hover .sui-brandmark__b {
animation: sui-brandmark-drift-b 1.1s ease-in-out 1;
}
.nav-footer__row-label {
flex: 1;
min-width: 0;
font-size: 0.8125rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Trailing affordance on a row: the account row's gear, the switch row's
leaving-this-app arrow. */
.nav-footer__trailing {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-left: auto;
color: var(--c-text-subtle);
}
/* Rows contributed by a caller (the link-account NavItem) sit in the same
surface, so match this footer's row metrics rather than the nav rail's. */
.nav-footer .sui-navitem {
min-height: 2.25rem;
padding: 0.25rem 0.375rem;
margin: 0;
border-radius: 0.5rem;
font-size: 0.8125rem;
}
/* ---- Collapsed icon rail ---- */
.nav-footer[data-collapsed] .nav-footer__slot {
padding-inline: 0.25rem;
}
.nav-footer[data-collapsed] .nav-footer__row {
justify-content: center;
padding-inline: 0;
}
.nav-footer[data-collapsed] .sui-navitem {
justify-content: center;
padding-inline: 0;
}
@@ -0,0 +1,119 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import LinkIcon from "@mui/icons-material/Link";
import { NavItem } from "@app/ui/NavItem";
import { NavFooter } from "@app/components/shared/navFooter/NavFooter";
/** Stands in for a CTA that has decided it has nothing to show. */
function RendersNothing() {
return null;
}
const meta: Meta<typeof NavFooter> = {
title: "Shared/NavFooter",
component: NavFooter,
parameters: { layout: "padded" },
args: {
displayName: "admin",
onOpenSettings: () => {},
credits: { remaining: 247, total: 500 },
onOpenPlan: () => {},
otherApp: { app: "processor", onOpen: () => {} },
},
decorators: [
(S) => (
<div
style={{
width: "16.25rem",
background: "var(--c-bg)",
padding: "0.5rem",
}}
>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof NavFooter>;
/** The editor's footer: credits, "Open PDF Processor", the account row. */
export const InEditor: Story = {};
/** The processor's footer. Same three boxes, opposite switch target. */
export const InProcessor: Story = {
args: { otherApp: { app: "editor", onOpen: () => {} } },
};
/** Self-hosted processor: no wallet, so no meter, and the link-account CTA
* rides along in the account box. */
export const WithLinkAccountCta: Story = {
args: {
credits: null,
otherApp: { app: "editor", onOpen: () => {} },
accountExtras: (
<NavItem
id="account-link"
label="Link Stirling account"
icon={<LinkIcon sx={{ fontSize: "1.1rem" }} />}
/>
),
},
};
/** Regression guard: the processor always passes its link-account CTA, but that
* component renders null once the org is linked. An element is truthy even
* when it renders nothing, so this must not leave a separator above the first
* visible row. */
export const ExtrasThatRenderNothing: Story = {
args: { accountExtras: <RendersNothing /> },
};
/** A real profile picture replaces the initials disc. */
export const WithProfilePicture: Story = {
args: {
displayName: "Ada Lovelace",
profilePictureUrl:
"data:image/svg+xml;utf8," +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="black"/><circle cx="32" cy="24" r="12" fill="white"/><ellipse cx="32" cy="56" rx="20" ry="16" fill="white"/></svg>',
),
},
};
/** Credits running low — the dot and bar shift to the warning tone at 20% left. */
export const CreditsLow: Story = {
args: { credits: { remaining: 42, total: 500 } },
};
/** Allowance exhausted. */
export const CreditsExhausted: Story = {
args: { credits: { remaining: 0, total: 500 } },
};
/** Core OSS: no wallet, no second app, settings only. */
export const MinimalBuild: Story = {
args: { credits: null, otherApp: null },
};
/** No settings handler — the account row is inert identity, not a button. */
export const NoSettings: Story = {
args: { onOpenSettings: undefined },
};
/** Collapsed icon rail: labels become tooltips. */
export const Collapsed: Story = {
args: { collapsed: true },
decorators: [
(S) => (
<div
style={{
width: "3.5rem",
background: "var(--c-bg)",
padding: "0.5rem",
}}
>
<S />
</div>
),
],
};
@@ -0,0 +1,58 @@
import { describe, it, expect } from "vitest";
import { cleanup, render } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { NavFooter } from "@app/components/shared/navFooter/NavFooter";
/** The footer's tooltips need Mantine's theme context. */
function withProviders(ui: React.ReactNode) {
return <MantineProvider>{ui}</MantineProvider>;
}
function renderFooter() {
const { container } = render(
withProviders(
<NavFooter
displayName="admin"
onOpenSettings={() => {}}
credits={{ remaining: 247, total: 500 }}
otherApp={{ app: "processor", onOpen: () => {} }}
/>,
),
);
return container.querySelector(".nav-footer") as HTMLElement;
}
describe("NavFooter — enter animation", () => {
it("plays once per page session, not on every remount", () => {
// The rows are seeded from cache, so they're present at first paint. Every
// later mount — switching apps, remounting a view — would otherwise replay
// the fade on content that never changed, which reads as a twitch.
expect(renderFooter().dataset.animate).toBe("true");
cleanup();
expect(renderFooter().dataset.animate).toBeUndefined();
cleanup();
expect(renderFooter().dataset.animate).toBeUndefined();
});
});
describe("NavFooter — separators", () => {
it("never renders a divider beside a row that renders nothing", () => {
// Dividers are CSS between adjacent non-empty slots, so an extras element
// that returns null (the linked org's link-account CTA) can't leave a line.
const { container } = render(
withProviders(
<NavFooter
displayName="admin"
onOpenSettings={() => {}}
credits={null}
otherApp={null}
accountExtras={<>{null}</>}
/>,
),
);
const slots = container.querySelectorAll(".nav-footer__slot");
const filled = [...slots].filter((s) => s.childElementCount > 0);
expect(filled).toHaveLength(1);
expect(container.querySelectorAll(".nav-footer__divider")).toHaveLength(0);
});
});
@@ -0,0 +1,213 @@
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Tooltip } from "@mantine/core";
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import SettingsIcon from "@mui/icons-material/Settings";
import { Avatar, NavSurface } from "@app/ui";
import { BrandMark } from "@app/components/shared/BrandMark";
import { type AppSwitchTarget } from "@app/components/shared/AppSwitch";
import {
NavFooterCreditsRow,
type NavFooterCredits,
} from "@app/components/shared/navFooter/NavFooterCreditsRow";
import "@app/components/shared/navFooter/NavFooter.css";
export interface NavFooterAppLink {
/** The app this footer is NOT in — the one the row opens. */
app: AppSwitchTarget;
onOpen: () => void;
}
export interface NavFooterProps {
/** Name shown next to the avatar, and the source of its initials fallback. */
displayName: string;
/** Profile picture; initials are drawn when absent or the URL fails to load. */
profilePictureUrl?: string | null;
/** Omit to render the account row as static text (no settings affordance). */
onOpenSettings?: () => void;
/** Null/undefined hides the meter — builds with no wallet never show it. */
credits?: NavFooterCredits | null;
/** Opens the plan surface from the credits row; omit to leave it inert. */
onOpenPlan?: () => void;
/** Null/undefined hides the switch row — e.g. no access to the other app. */
otherApp?: NavFooterAppLink | null;
/** Extra rows above the account row (the self-hosted link-account CTA). */
accountExtras?: ReactNode;
/** Icon-rail state: labels collapse to tooltips. */
collapsed?: boolean;
className?: string;
}
/**
* Whether the enter animation has already played this page session. The rows
* are seeded from cache now, so they're present from first paint and every
* later mount — switching apps, remounting a view — would otherwise replay the
* animation on content that never changed, which reads as the UI twitching.
*/
let hasPlayedEnter = false;
/**
* The bottom section every sidebar ends with, shared by the editor and the
* processor so both present the same rows. ONE surface, hairline-separated, in
* this order:
*
* 1. caller-contributed rows (the self-hosted link-account CTA)
* 2. free credits remaining
* 3. "Open <the other app>"
* 4. the account row — avatar, name, settings
*
* Purely presentational: each app resolves its own identity, wallet and
* app-switch access and passes them in, so this file carries no build-specific
* gating. A row whose data is absent is dropped, and so is the separator that
* would have sat beside it.
*/
export function NavFooter({
displayName,
profilePictureUrl,
onOpenSettings,
credits,
onOpenPlan,
otherApp,
accountExtras,
collapsed = false,
className,
}: NavFooterProps) {
const { t } = useTranslation();
const [animate] = useState(() => {
if (hasPlayedEnter) return false;
hasPlayedEnter = true;
return true;
});
const settingsLabel = t("fileSidebar.openSettings", "Open settings");
const accountLabel = onOpenSettings
? `${displayName} - ${settingsLabel}`
: displayName;
// One surface, hairline-separated rows. Each row gets a slot; the separators
// are drawn by CSS between adjacent NON-EMPTY slots (see NavFooter.css), so a
// row that renders nothing — the link-account CTA returns null once the org is
// linked, and an element is truthy even then — can't leave a line behind.
const rows: Array<{ key: string; node: ReactNode }> = [];
if (accountExtras) rows.push({ key: "extras", node: accountExtras });
if (credits) {
rows.push({
key: "credits",
node: (
<NavFooterCreditsRow
credits={credits}
collapsed={collapsed}
label={t("navFooter.credits.label", "Free credits")}
onOpen={onOpenPlan}
/>
),
});
}
if (otherApp) {
rows.push({
key: "switch",
node: (
<Tooltip
label={openAppLabel(otherApp.app, t)}
position="right"
withinPortal
disabled={!collapsed}
>
<button
type="button"
className="nav-footer__row"
onClick={() => otherApp.onOpen()}
// Collapsed drops the visible label, so name the button here too.
aria-label={openAppLabel(otherApp.app, t)}
>
<span className="nav-footer__row-icon" aria-hidden>
<BrandMark height="1.125rem" />
</span>
{!collapsed && (
<>
<span className="nav-footer__row-label">
{openAppLabel(otherApp.app, t)}
</span>
{/* "Takes you there", not "opens a new tab" — both apps are
one SPA, so this navigates in place. */}
<span className="nav-footer__trailing" aria-hidden>
<ArrowForwardIcon sx={{ fontSize: "1rem" }} />
</span>
</>
)}
</button>
</Tooltip>
),
});
}
rows.push({
key: "account",
node: (
<Tooltip
label={accountLabel}
position="right"
withinPortal
disabled={!collapsed}
>
<button
type="button"
className="nav-footer__row nav-footer__account"
// Called with no args: handlers that take optional params (the
// processor's openSettings(section?)) must not receive the event.
onClick={onOpenSettings ? () => onOpenSettings() : undefined}
disabled={!onOpenSettings}
data-testid={onOpenSettings ? "config-button" : undefined}
data-tour={onOpenSettings ? "config-button" : undefined}
aria-label={accountLabel}
>
{/* Decorative: the button's own label already names the account, so
an alt/label here would just repeat it to a screen reader. */}
<span aria-hidden>
<Avatar
size="sm"
name={displayName}
src={profilePictureUrl ?? undefined}
/>
</span>
{!collapsed && (
<span className="nav-footer__row-label sidebar-content-fade">
{displayName}
</span>
)}
{onOpenSettings && !collapsed && (
<span className="nav-footer__trailing" aria-hidden>
<SettingsIcon sx={{ fontSize: "1.1rem" }} />
</span>
)}
</button>
</Tooltip>
),
});
return (
<NavSurface
className={["nav-footer", className ?? ""].filter(Boolean).join(" ")}
data-collapsed={collapsed || undefined}
data-animate={animate || undefined}
>
{rows.map((row) => (
<div key={row.key} className="nav-footer__slot">
{row.node}
</div>
))}
</NavSurface>
);
}
function openAppLabel(
app: AppSwitchTarget,
t: (key: string, fallback: string) => string,
): string {
return app === "editor"
? t("navFooter.openEditor", "Open PDF Editor")
: t("navFooter.openProcessor", "Open PDF Processor");
}
@@ -0,0 +1,83 @@
/* Free-credits meter inside the sidebar footer. The row base (padding, hover,
focus) comes from NavFooter.css; these rules are the meter itself. */
.nav-footer__credits {
flex-direction: column;
align-items: stretch;
gap: 0.375rem;
cursor: default;
}
/* Inert by default, so it must not read as hoverable; the actionable variant
opts back into the shared row hover. */
.nav-footer__credits:hover {
background: none;
}
.nav-footer__credits--actionable {
cursor: pointer;
}
.nav-footer__credits--actionable:hover {
background-color: var(--c-hover);
}
.nav-footer__credits-head {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
}
.nav-footer__dot {
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
flex-shrink: 0;
background-color: var(--c-success);
}
.nav-footer__dot[data-tone="warning"] {
background-color: var(--c-warning);
}
.nav-footer__dot[data-tone="danger"] {
background-color: var(--c-danger);
}
.nav-footer__credits-label {
flex: 1;
min-width: 0;
font-weight: 500;
color: var(--c-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.nav-footer__credits-count {
flex-shrink: 0;
color: var(--c-text-muted);
font-variant-numeric: tabular-nums;
}
/* ---- Collapsed rail ---- */
/* Rotated so the fill starts at 12 o'clock and runs clockwise. */
.nav-footer__credits-ring {
width: 1.25rem;
height: 1.25rem;
margin-inline: auto;
transform: rotate(-90deg);
}
.nav-footer__credits-ring-track,
.nav-footer__credits-ring-fill {
fill: none;
stroke-width: 3;
}
.nav-footer__credits-ring-track {
stroke: var(--c-surface-sunken);
}
.nav-footer__credits-ring-fill {
stroke-linecap: round;
}
@@ -0,0 +1,158 @@
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Tooltip } from "@mantine/core";
import { ProgressBar } from "@app/ui";
import "@app/components/shared/navFooter/NavFooterCreditsRow.css";
export interface NavFooterCredits {
/** Free credits still available to spend. */
remaining: number;
/** Size of the free allowance — the "of N" denominator. */
total: number;
}
/** Remaining-credit bands, mirroring the usage meters' 80% / 100% thresholds. */
function creditsTone(remaining: number, total: number): string {
if (remaining <= 0) return "danger";
return total > 0 && remaining / total <= 0.2 ? "warning" : "success";
}
interface NavFooterCreditsRowProps {
credits: NavFooterCredits;
/** Icon rail: the figures drop and the bar alone carries the state. */
collapsed: boolean;
/** Row label, passed in so the meter owns no copy of its own. */
label: string;
/** Opens the plan surface. Omit to render the meter as inert text. */
onOpen?: () => void;
}
/**
* The free-credits meter as it appears in the sidebar footer: a state dot, the
* label, "X of Y" remaining, and a fill bar underneath. Figures are clamped
* here so a wallet that reports more remaining than the allowance (or negative)
* can't overflow the bar.
*
* Rendered as a {@code nav-footer__row}, so it inherits that row's metrics
* from NavFooter.css and only brings its own meter styling.
*/
export function NavFooterCreditsRow({
credits,
collapsed,
label,
onOpen,
}: NavFooterCreditsRowProps) {
const { t } = useTranslation();
const total = Math.max(0, credits.total);
const remaining = Math.min(Math.max(0, credits.remaining), total);
const tone = creditsTone(remaining, total);
const count = t("navFooter.credits.count", "{{remaining}} of {{total}}", {
remaining: remaining.toLocaleString(),
total: total.toLocaleString(),
});
return (
<Tooltip
label={`${label}: ${count}`}
position="right"
withinPortal
disabled={!collapsed}
>
<Row onOpen={onOpen} label={`${label}: ${count}`}>
{collapsed ? (
// The rail is one icon wide, so a full-width bar would read as a
// stray line; a ring carries the same fraction at icon size.
<CreditsRing
fraction={total > 0 ? remaining / total : 0}
tone={tone}
label={`${label}: ${count}`}
/>
) : (
<>
<div className="nav-footer__credits-head">
<span className="nav-footer__dot" data-tone={tone} aria-hidden />
<span className="nav-footer__credits-label">{label}</span>
<span className="nav-footer__credits-count">{count}</span>
</div>
<ProgressBar
value={total > 0 ? remaining / total : 0}
height={6}
color={`var(--c-${tone})`}
label={`${label}: ${count}`}
/>
</>
)}
</Row>
</Tooltip>
);
}
/** Icon-sized donut carrying the same remaining fraction as the expanded bar. */
function CreditsRing({
fraction,
tone,
label,
}: {
fraction: number;
tone: string;
label: string;
}) {
const RADIUS = 8;
const circumference = 2 * Math.PI * RADIUS;
const filled = Math.min(1, Math.max(0, fraction)) * circumference;
return (
<svg
className="nav-footer__credits-ring"
viewBox="0 0 20 20"
role="img"
aria-label={label}
>
<circle
className="nav-footer__credits-ring-track"
cx="10"
cy="10"
r={RADIUS}
/>
<circle
className="nav-footer__credits-ring-fill"
cx="10"
cy="10"
r={RADIUS}
stroke={`var(--c-${tone})`}
strokeDasharray={`${filled} ${circumference - filled}`}
/>
</svg>
);
}
/**
* The meter is a button only where there is a plan surface to open — otherwise
* it stays a plain div, so a build with nowhere to go doesn't advertise a
* click that does nothing.
*/
function Row({
onOpen,
label,
children,
}: {
onOpen?: () => void;
label: string;
children: ReactNode;
}) {
const className = `nav-footer__row nav-footer__credits${
onOpen ? " nav-footer__credits--actionable" : ""
}`;
if (!onOpen) return <div className={className}>{children}</div>;
return (
<button
type="button"
className={className}
onClick={() => onOpen()}
aria-label={label}
>
{children}
</button>
);
}
@@ -1,5 +1,7 @@
import { useContext, useEffect, useState } from "react";
import { Stack, Divider, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ViewerContext } from "@app/contexts/ViewerContext";
import {
ChangeMetadataParameters,
createCustomMetadataFunctions,
@@ -19,6 +21,31 @@ interface ChangeMetadataSingleStepProps {
disabled?: boolean;
}
/**
* Pre-fills the form from the currently open document's existing metadata.
* Isolated in its own component so it only mounts where a ViewerProvider exists
* (the editor and the in-editor Automate modal). The pipeline builder has no
* viewer and no single "current document", so it is skipped there rather than
* crashing on useViewer.
*/
const MetadataPrefill = ({
onParameterChange,
onExtractingChange,
}: {
onParameterChange: ChangeMetadataSingleStepProps["onParameterChange"];
onExtractingChange: (extracting: boolean) => void;
}) => {
const { isExtractingMetadata } = useMetadataExtraction({
updateParameter: onParameterChange,
});
useEffect(() => {
onExtractingChange(isExtractingMetadata);
}, [isExtractingMetadata, onExtractingChange]);
return null;
};
const ChangeMetadataSingleStep = ({
parameters,
onParameterChange,
@@ -26,77 +53,85 @@ const ChangeMetadataSingleStep = ({
}: ChangeMetadataSingleStepProps) => {
const { t } = useTranslation();
// Auto-prefill reads the viewer/file contexts, which only exist in the editor.
// Gate on the viewer so the pipeline builder renders the fields without it.
const hasViewerContext = useContext(ViewerContext) !== null;
const [isExtractingMetadata, setIsExtractingMetadata] = useState(false);
// Get custom metadata functions using the utility
const { addCustomMetadata, removeCustomMetadata, updateCustomMetadata } =
createCustomMetadataFunctions(parameters, onParameterChange);
// Extract metadata from uploaded files
const { isExtractingMetadata } = useMetadataExtraction({
updateParameter: onParameterChange,
});
const isDeleteAllEnabled = parameters.deleteAll;
const fieldsDisabled = disabled || isDeleteAllEnabled || isExtractingMetadata;
return (
<Stack gap="md">
{/* Delete All */}
<Stack gap="md">
<Text size="sm" fw={500}>
{t("changeMetadata.deleteAll.label", "Delete All Metadata")}
</Text>
<DeleteAllStep
parameters={parameters}
<>
{hasViewerContext && (
<MetadataPrefill
onParameterChange={onParameterChange}
disabled={disabled}
onExtractingChange={setIsExtractingMetadata}
/>
</Stack>
<Divider />
{/* Standard Metadata Fields */}
)}
<Stack gap="md">
<Text size="sm" fw={500}>
{t("changeMetadata.standardFields.title", "Standard Metadata")}
</Text>
<StandardMetadataStep
parameters={parameters}
onParameterChange={onParameterChange}
disabled={fieldsDisabled}
/>
{/* Delete All */}
<Stack gap="md">
<Text size="sm" fw={500}>
{t("changeMetadata.deleteAll.label", "Delete All Metadata")}
</Text>
<DeleteAllStep
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
</Stack>
<Divider />
{/* Standard Metadata Fields */}
<Stack gap="md">
<Text size="sm" fw={500}>
{t("changeMetadata.standardFields.title", "Standard Metadata")}
</Text>
<StandardMetadataStep
parameters={parameters}
onParameterChange={onParameterChange}
disabled={fieldsDisabled}
/>
</Stack>
<Divider />
{/* Document Dates */}
<Stack gap="md">
<Text size="sm" fw={500}>
{t("changeMetadata.dates.title", "Document Dates")}
</Text>
<DocumentDatesStep
parameters={parameters}
onParameterChange={onParameterChange}
disabled={fieldsDisabled}
/>
</Stack>
<Divider />
{/* Advanced Options */}
<Stack gap="md">
<Text size="sm" fw={500}>
{t("changeMetadata.advanced.title", "Advanced Options")}
</Text>
<AdvancedOptionsStep
parameters={parameters}
onParameterChange={onParameterChange}
disabled={fieldsDisabled}
addCustomMetadata={addCustomMetadata}
removeCustomMetadata={removeCustomMetadata}
updateCustomMetadata={updateCustomMetadata}
/>
</Stack>
</Stack>
<Divider />
{/* Document Dates */}
<Stack gap="md">
<Text size="sm" fw={500}>
{t("changeMetadata.dates.title", "Document Dates")}
</Text>
<DocumentDatesStep
parameters={parameters}
onParameterChange={onParameterChange}
disabled={fieldsDisabled}
/>
</Stack>
<Divider />
{/* Advanced Options */}
<Stack gap="md">
<Text size="sm" fw={500}>
{t("changeMetadata.advanced.title", "Advanced Options")}
</Text>
<AdvancedOptionsStep
parameters={parameters}
onParameterChange={onParameterChange}
disabled={fieldsDisabled}
addCustomMetadata={addCustomMetadata}
removeCustomMetadata={removeCustomMetadata}
updateCustomMetadata={updateCustomMetadata}
/>
</Stack>
</Stack>
</>
);
};
@@ -1,3 +1,4 @@
import { useContext, useRef } from "react";
import {
Stack,
Text,
@@ -7,6 +8,7 @@ import {
Divider,
} from "@mantine/core";
import { Button } from "@app/ui/Button";
import { FilePicker } from "@app/ui/FilePicker";
import { ActionIcon } from "@app/ui/ActionIcon";
import { SegmentedControl } from "@app/ui/SegmentedControl";
import { useTranslation } from "react-i18next";
@@ -15,7 +17,7 @@ import {
type OverlayMode,
} from "@app/hooks/tools/overlayPdfs/useOverlayPdfsParameters";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import { FilesModalContext } from "@app/contexts/FilesModalContext";
import styles from "@app/components/tools/overlayPdfs/OverlayPdfsSettings.module.css";
import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex";
@@ -34,7 +36,12 @@ export default function OverlayPdfsSettings({
disabled = false,
}: OverlayPdfsSettingsProps) {
const { t } = useTranslation();
const { openFilesModal } = useFilesModalContext();
// Read optionally: the portal pipeline builder mounts no FilesModalProvider.
// Present (editor tool + Automate modal) -> keep the workspace file picker;
// absent (portal) -> fall back to the plain file input below.
const filesModal = useContext(FilesModalContext);
// Clears the FilePicker so the same file can be re-selected (Mantine resetRef).
const resetOverlayPicker = useRef<() => void>(null);
const handleOverlayFilesChange = (files: File[]) => {
onParameterChange("overlayFiles", files);
@@ -66,8 +73,8 @@ export default function OverlayPdfsSettings({
};
const handleOpenOverlayFilesModal = () => {
if (disabled) return;
openFilesModal({
if (disabled || !filesModal) return;
filesModal.openFilesModal({
customHandler: (files: File[]) => {
handleOverlayFilesChange([
...(parameters.overlayFiles || []),
@@ -77,6 +84,17 @@ export default function OverlayPdfsSettings({
});
};
const appendOverlayFiles = (files: File[]) => {
if (files.length === 0) return;
handleOverlayFilesChange([...(parameters.overlayFiles || []), ...files]);
resetOverlayPicker.current?.();
};
const overlayFilesButtonLabel =
parameters.overlayFiles?.length > 0
? t("overlay-pdfs.overlayFiles.addMore", "Add more PDFs...")
: t("overlay-pdfs.overlayFiles.placeholder", "Choose PDF(s)...");
return (
<Stack gap="md">
<Stack gap="xs">
@@ -183,17 +201,30 @@ export default function OverlayPdfsSettings({
<Text size="sm" fw={500}>
{t("overlay-pdfs.overlayFiles.label", "Overlay Files")}
</Text>
<Button
size="sm"
onClick={handleOpenOverlayFilesModal}
disabled={disabled}
leftSection={<LocalIcon icon="add" width="14" height="14" />}
fullWidth
>
{parameters.overlayFiles?.length > 0
? t("overlay-pdfs.overlayFiles.addMore", "Add more PDFs...")
: t("overlay-pdfs.overlayFiles.placeholder", "Choose PDF(s)...")}
</Button>
{filesModal ? (
<Button
size="sm"
onClick={handleOpenOverlayFilesModal}
disabled={disabled}
leftSection={<LocalIcon icon="add" width="14" height="14" />}
fullWidth
>
{overlayFilesButtonLabel}
</Button>
) : (
<FilePicker
multiple
accept="application/pdf"
onChange={appendOverlayFiles}
resetRef={resetOverlayPicker}
size="sm"
disabled={disabled}
leftSection={<LocalIcon icon="add" width="14" height="14" />}
fullWidth
>
{overlayFilesButtonLabel}
</FilePicker>
)}
{parameters.overlayFiles?.length > 0 &&
(() => {
@@ -41,7 +41,9 @@ interface FilesModalContextType {
setOnModalClose: (callback: () => void) => void;
}
const FilesModalContext = createContext<FilesModalContextType | null>(null);
export const FilesModalContext = createContext<FilesModalContextType | null>(
null,
);
export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({
children,

Some files were not shown because too many files have changed in this diff Show More