mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Merge branch 'main' into add_contributing_agreement
This commit is contained in:
+18
-19
@@ -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/**
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
+19
-38
@@ -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.
|
||||
|
||||
+9
-2
@@ -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);
|
||||
|
||||
/**
|
||||
|
||||
+69
-22
@@ -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() {
|
||||
|
||||
+6
-3
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+26
-4
@@ -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);
|
||||
}
|
||||
|
||||
+15
-5
@@ -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() {
|
||||
|
||||
+70
-42
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+2
-5
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+100
-16
@@ -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"));
|
||||
|
||||
+32
-6
@@ -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");
|
||||
}
|
||||
|
||||
+6
-6
@@ -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");
|
||||
}
|
||||
|
||||
+19
-4
@@ -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);
|
||||
}
|
||||
|
||||
+264
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-15
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
+142
-10
@@ -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));
|
||||
}
|
||||
|
||||
+1
@@ -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:}
|
||||
|
||||
+129
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
Generated
+4
-4
@@ -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\//,
|
||||
|
||||
Generated
+3
-3
@@ -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]);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+94
-59
@@ -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
Reference in New Issue
Block a user