chore: import upstream snapshot with attribution
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:08:55 +08:00
commit 7a0da7932b
2985 changed files with 1049377 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
# GitHub Actions Workflows
This directory contains GitHub Actions workflows for automated development tasks.
## Update NPM Dependencies Workflow
**File**: `update-npm-dependencies.yml`
### Purpose
Automatically updates NPM dependencies across all package.json files in the project and fixes security vulnerabilities.
### Triggers
- **Scheduled**: Every Thursday at 08:00 UTC (day after PDM updates)
- **Manual**: Can be triggered manually via GitHub Actions UI
- **Workflow Call**: Can be called by other workflows
### What it does
1. **Security Audit**: Runs `npm audit` to identify security vulnerabilities
2. **Security Fixes**: Automatically fixes moderate+ severity vulnerabilities with `npm audit fix`
3. **Dependency Updates**: Updates all dependencies to latest compatible versions with `npm update`
4. **Testing**: Runs relevant tests to ensure updates don't break functionality
5. **Pull Request**: Creates automated PR with all changes
### Directories Managed
- `/` - Main web dependencies (Vite, Bootstrap, etc.)
- `/tests/ui_tests` - UI test dependencies (Puppeteer)
### Branch Strategy
- Creates branch: `update-npm-dependencies-{run_number}`
- Targets: `main` branch
- Labels: `maintenance`
- Reviewers: `djpetti,HashedViking,LearningCircuit`
### Security Focus
- Only auto-fixes moderate+ severity vulnerabilities
- Preserves compatible version updates (no major version bumps)
- Runs security audit before and after updates
- Requires tests to pass before creating PR
### Manual Usage
You can manually trigger this workflow:
1. Go to Actions tab in GitHub
2. Select "Update NPM dependencies"
3. Click "Run workflow"
4. Optionally specify custom npm arguments
### Troubleshooting
- If tests fail, the PR won't be created
- Check the workflow logs for specific error messages
- Security issues that can't be auto-fixed will need manual intervention
@@ -0,0 +1,58 @@
name: Advanced-search change reminder
on:
# pull_request (not pull_request_target) — fork PRs get a read-only
# token, so the label call 403s; the script catches that and stays
# green (the original comment claimed the 403 was silent, but nothing
# caught it, so fork PRs got a red check — e.g. #4872). Intentional
# no-op on forks: the advisory label is only meaningful for
# internal-branch PRs anyway.
pull_request:
types: [opened, synchronize, reopened]
paths:
- 'src/local_deep_research/advanced_search_system/**'
# No concurrency group — intentionally omitted.
# Previous attempt (#3554, reverted #3599) used cancel-in-progress which
# killed in-progress PR runs before they produced useful results.
# Future iteration could safely add concurrency for scheduled/push-only
# triggers (where head_ref is empty and runs get unique groups).
permissions: {} # Minimal top-level for OSSF Scorecard Token-Permissions
jobs:
label:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Apply benchmark-needed label
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
// Advisory only — asks reviewers to consider running
// src/local_deep_research/benchmarks/compare_configurations()
// before merging changes that can affect research quality.
// The label's description (set on the label itself in repo
// settings) carries the reminder text so reviewers see it
// on hover without needing an inline comment.
try {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['benchmark-needed']
});
} catch (err) {
// Fork PRs get a read-only token — the call 403s ("Resource
// not accessible by integration"). Advisory label only, so
// log and stay green (same pattern as pr-triage.yml).
if (err.status !== 403) throw err;
console.log('Label call returned 403 (read-only fork token). Skipping.');
}
+291
View File
@@ -0,0 +1,291 @@
name: AI Code Reviewer
# Reviews are opt-in per PR: add the ai_code_review label to request one
# (the label is auto-removed after the run so re-adding it re-triggers).
# The automatic opened/synchronize/ready_for_review triggers were removed —
# reviewing every push burned reviewer-API spend on PRs nobody asked to have
# reviewed, and a billing outage (HTTP 402) then showed a red check on every
# PR in the repo.
#
# Residual risk accepted: the diff text goes into the LLM prompt, so a
# hostile diff can try to steer the posted comment or labels (prompt
# injection). The maintainer-applied label gate plus the advisory-only
# decision (FAIL_ON_REQUESTED_CHANGES defaults to false) bound that
# blast radius, and it exists for internal PRs too.
on:
# zizmor: ignore[dangerous-triggers] — pull_request_target is required so
# the review also works on fork PRs: fork-triggered pull_request runs get
# no secrets (OPENROUTER_API_KEY) and a read-only token, so the reviewer
# could neither call the model nor post its comment. This is safe under
# two invariants — KEEP BOTH when editing this file:
# 1. Only the ai_code_review label triggers a run, and only users with
# triage permission can add labels: every run on fork code is an
# explicit maintainer action, never attacker-initiated.
# 2. Nothing from the PR head is ever executed. The checkout is the
# trusted base branch (pull_request_target default), so
# .github/scripts/combine-ai-reviews.sh runs from base; the PR head
# is fetched as *data only* to compute the diff.
pull_request_target:
types: [labeled]
# Per-PR concurrency with cancel-in-progress: re-adding the label while a
# review is still running cancels the now-stale run. Keyed on the PR number
# so runs for *different* PRs never cancel each other — that repo-wide
# cancellation was the problem in the earlier attempt (#3554, reverted
# #3599), not per-PR cancellation.
#
# The labeled trigger fires for *every* label added to the PR, but the job's
# `if` only proceeds when the label is ai_code_review. Without the run_id
# suffix below, a skip-only labeled event would still enter this group,
# cancel a real in-flight review, and then skip itself — leaving the PR with
# no review at all. Routing those into a unique group keyed on run_id means
# they cancel nothing; the `if` discards them quietly.
concurrency:
group: >-
ai-code-review-${{ github.event.pull_request.number }}${{
(github.event.action == 'labeled' && github.event.label.name != 'ai_code_review')
&& format('-skip-{0}', github.run_id) || ''
}}
cancel-in-progress: true
permissions: {} # Minimal top-level for OSSF Scorecard Token-Permissions
jobs:
comprehensive-review:
name: AI Code Review
runs-on: ubuntu-latest
timeout-minutes: 30
environment: ci
# Only the ai_code_review label triggers a review (works on drafts too);
# every other labeled event is discarded here.
if: github.event.label.name == 'ai_code_review'
permissions:
contents: read
pull-requests: write
issues: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
# SECURITY: this checks out the trusted BASE branch (the
# pull_request_target default), which is what makes running
# .github/scripts/combine-ai-reviews.sh below safe on fork PRs.
# Never add `ref: ${{ github.event.pull_request.head.sha }}` here —
# that would execute fork-controlled scripts with secrets in env.
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y jq
- name: Get PR diff
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
# The PR head — possibly fork code — is fetched as *data only*
# through the base repo's refs/pull/N/head mirror (present for
# fork and same-repo PRs alike); it is never checked out and
# nothing from it is executed. Env vars prevent template
# injection from malicious branch names.
git fetch origin "$BASE_REF" "+refs/pull/$PR_NUMBER/head:refs/remotes/origin/pr-head"
git diff "origin/$BASE_REF...origin/pr-head" --no-color > diff.txt
- name: Download AI reviewer script
run: |
curl -fsSL https://raw.githubusercontent.com/LearningCircuit/Friendly-AI-Reviewer/main/ai-reviewer.sh -o ai-reviewer.sh
chmod +x ai-reviewer.sh
- name: AI Code Review
id: ai-review
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
# Comma-separated list of models. Each model produces one independent
# review, presented as "Reviewer 1", "Reviewer 2", ... in a single
# combined comment. Falls back to the shared AI_MODEL (also used by
# release.yml, which must stay a single model) when unset, so a
# one-entry list reproduces the original single-review behavior.
AI_REVIEW_MODELS: ${{ vars.AI_REVIEW_MODELS || vars.AI_MODEL || 'moonshotai/kimi-k2-thinking' }}
AI_TEMPERATURE: ${{ vars.AI_TEMPERATURE || '0.1' }}
AI_MAX_TOKENS: ${{ vars.AI_MAX_TOKENS || '64000' }}
MAX_DIFF_SIZE: ${{ vars.MAX_DIFF_SIZE || '800000' }}
EXCLUDE_FILE_PATTERNS: ${{ vars.EXCLUDE_FILE_PATTERNS || '*.lock,*.min.js,*.min.css,package-lock.json,yarn.lock' }}
STRUCTURED_OUTPUT: ${{ vars.STRUCTURED_OUTPUT || 'true' }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO_FULL_NAME: ${{ github.repository }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
FAIL_ON_REQUESTED_CHANGES: ${{ vars.FAIL_ON_REQUESTED_CHANGES || 'false' }}
DEBUG_MODE: ${{ vars.DEBUG_MODE || 'false' }}
run: |
echo "Running AI code review..."
# Parse the comma-separated model list. Each entry becomes one
# independent reviewer ("Reviewer 1", "Reviewer 2", ...). A single
# entry reproduces the original one-review behavior.
# `read` returns non-zero at EOF; the here-string always appends a
# trailing newline so it returns 0 here, but `|| true` keeps a future
# edge case from tripping `set -e`. An empty/whitespace AI_REVIEW_MODELS
# yields only empty entries, which the filter below drops, correctly
# falling through to the "No models configured" error.
IFS=',' read -ra RAW_MODELS <<< "$AI_REVIEW_MODELS" || true
MODELS=()
for m in "${RAW_MODELS[@]}"; do
# Trim surrounding whitespace with parameter expansion. (xargs would
# choke on a model id containing a quote and word-collapse internal
# spaces — model ids shouldn't have either, but this is robust.)
m="${m#"${m%%[![:space:]]*}"}" # strip leading whitespace
m="${m%"${m##*[![:space:]]}"}" # strip trailing whitespace
[ -n "$m" ] && MODELS+=("$m")
done
if [ "${#MODELS[@]}" -eq 0 ]; then
echo "❌ No models configured in AI_REVIEW_MODELS"
exit 1
fi
echo "Configured ${#MODELS[@]} reviewer model(s)"
# Launch every model concurrently so the job's wall-clock is the
# slowest single review, not the sum of all of them. Each reviewer
# writes its JSON response and its diagnostics to its own index-keyed
# files, so the "Reviewer N" numbering stays stable and the parallel
# output never interleaves. AI_MODEL is overridden per call; all other
# settings (temperature, token limits, diff size, context fetching)
# are shared.
pids=()
for i in "${!MODELS[@]}"; do
AI_MODEL="${MODELS[i]}" bash ai-reviewer.sh < diff.txt \
> "resp_$i.json" 2> "err_$i.log" &
pids[i]=$!
done
# Record each reviewer's exit code to code_<i> (a failing model must
# not abort the others, so disable errexit around wait).
for i in "${!MODELS[@]}"; do
set +e
wait "${pids[i]}"
echo $? > "code_$i"
set -e
done
# Assemble the combined comment with the extracted, unit-tested helper
# (.github/scripts/combine-ai-reviews.sh). It reads resp_<i>.json /
# code_<i> from the working dir and writes the comment body, label set,
# decision, and success count back out. Keeping the assembly in a
# script is what lets tests/ci/test_combine_ai_reviews.py cover the
# parsing, footer-stripping, label union, and pass/fail logic without
# any network or GitHub API access.
bash .github/scripts/combine-ai-reviews.sh "$PWD" "${MODELS[@]}"
COMMENT_BODY="$(cat comment_body.md)"
LABELS="$(cat labels.txt)"
DECISION="$(cat decision.txt)"
SUCCESS_COUNT="$(cat success_count.txt)"
# Post (or update) the reviews as a single sticky PR comment. On each
# push we edit the same comment in place instead of stacking a new one
# per push. The hidden marker identifies our comment specifically, so
# we never touch comments from humans or other bots. (Review comments
# made before this change lack the marker and are left as-is.)
# A commit-sha line lets reviewers tell which push the (edited-in-place)
# comment reflects, since GitHub only shows a vague "edited" marker.
# This literal MUST match the marker the helper script prepends to the
# comment body.
STICKY_MARKER="<!-- ai-code-review:sticky -->"
# STICKY_MARKER is interpolated into the jq program below, so it must
# stay free of `"` and `\`. gh api's --jq takes no --arg, so we can't
# pass it as a jq variable; keeping the marker a literal constant is
# what keeps this safe. The filter yields a single id (we maintain
# exactly one sticky comment), so no post-filtering is needed.
EXISTING_COMMENT_ID=$(gh api "repos/$REPO_FULL_NAME/issues/$PR_NUMBER/comments" \
--paginate \
--jq "[.[] | select(.body | contains(\"$STICKY_MARKER\"))] | last | .id // empty")
if [ -n "$EXISTING_COMMENT_ID" ]; then
echo "Updating existing AI review comment ($EXISTING_COMMENT_ID)"
printf '%s' "$COMMENT_BODY" | gh api \
"repos/$REPO_FULL_NAME/issues/comments/$EXISTING_COMMENT_ID" \
--method PATCH -F body=@- >/dev/null
else
echo "Posting new AI review comment"
printf '%s' "$COMMENT_BODY" | gh pr comment "$PR_NUMBER" --repo "$REPO_FULL_NAME" -F -
fi
# Add labels to PR. Iterate line-by-line (labels.txt is one label per
# line) rather than word-splitting, so a multi-word label such as
# "good first issue" is treated as a single label, not three.
echo "Labels to add: $LABELS"
while IFS= read -r label; do
[ -n "$label" ] || continue
echo "Adding label: $label"
if gh label create "$label" --color "0366d6" --description "Auto-created by AI reviewer" 2>/dev/null; then
echo "Created new label: $label"
else
echo "Label already exists: $label"
fi
if gh issue edit "$PR_NUMBER" --add-label "$label" 2>/dev/null; then
echo "Successfully added label: $label"
else
echo "Failed to add label: $label"
fi
done <<< "$LABELS"
# Handle workflow decision (after posting review and labels)
echo "AI decision: $DECISION"
# Store the decision as a step output for the downstream
# "Fail Workflow if Requested" step. $GITHUB_OUTPUT is the modern
# mechanism — values are scoped to steps.<id>.outputs.<name> and
# cannot re-enter the runner's process environment, eliminating the
# $GITHUB_ENV injection class (zizmor github-env audit #8017).
#
# Defense-in-depth: an allowlist before the write blocks newline
# injection. If a manipulated model response put a newline in
# $DECISION, the bare echo above could write an additional output
# key. Outputs can't reach the process environment so impact is
# minimal, but the case statement closes the door cheaply and
# replaces the lost-on-rework intent of #4985.
case "$DECISION" in pass|fail) ;; *) DECISION="fail" ;; esac
echo "DECISION=$DECISION" >> "$GITHUB_OUTPUT"
# Remove the ai_code_review label so re-adding it re-triggers a review.
echo "Removing ai_code_review label to allow easy re-triggering"
if gh issue edit "$PR_NUMBER" --remove-label "ai_code_review" 2>/dev/null; then
echo "Successfully removed ai_code_review label"
else
echo "Failed to remove ai_code_review label (may have been already removed)"
fi
# If every reviewer failed, the posted comment carries only failure
# notes — surface that as a red workflow run rather than a silent pass,
# matching the original single-review behavior on an unusable response.
if [ "$SUCCESS_COUNT" -eq 0 ]; then
echo "❌ All ${#MODELS[@]} reviewer(s) failed to produce a usable review."
exit 1
fi
echo "✅ AI review completed successfully ($SUCCESS_COUNT/${#MODELS[@]} reviewers)"
- name: Fail Workflow if Requested
if: env.FAIL_ON_REQUESTED_CHANGES == 'true' && steps.ai-review.outputs.DECISION == 'fail'
run: |
echo "❌ AI requested changes and FAIL_ON_REQUESTED_CHANGES is enabled. Failing workflow."
echo "The review has been posted above. Please address the requested changes."
exit 1
- name: Cleanup
if: always()
run: |
rm -f diff.txt
# Per-reviewer response/log/exit-code files from the parallel fan-out,
# plus the assembly helper's output files.
rm -f resp_*.json err_*.log code_* comment_body.md labels.txt decision.txt success_count.txt
# Only remove ai_response.txt if it exists (only created in debug mode)
if [ -f "ai_response.txt" ]; then
rm -f ai_response.txt
fi
@@ -0,0 +1,264 @@
name: Backwards Compatibility
on:
workflow_call: # Allows this workflow to be called by other workflows
pull_request:
types: [opened, synchronize, reopened]
branches: [main]
paths:
# Core encryption and database management
- 'src/local_deep_research/database/encrypted_db.py'
- 'src/local_deep_research/database/sqlcipher_*.py'
- 'src/local_deep_research/database/auth_db.py'
- 'src/local_deep_research/database/session_*.py'
# Database initialization and migrations
- 'src/local_deep_research/database/initialize.py'
- 'src/local_deep_research/database/library_init.py'
- 'src/local_deep_research/database/thread_local_session.py'
# Database models (schema changes)
- 'src/local_deep_research/database/models/*.py'
# Encryption settings configuration
- 'src/local_deep_research/settings/env_definitions/db_config.py'
# Dependencies
- 'pyproject.toml'
- 'pdm.lock'
# Alembic migration infrastructure
- 'src/local_deep_research/database/alembic_runner.py'
- 'src/local_deep_research/database/migrations/**'
# Test files (ensure tests themselves are valid)
- 'tests/performance/database/test_backwards_compatibility.py'
- 'tests/database/test_encryption_constants.py'
- 'tests/database/test_alembic_migrations.py'
- 'tests/database/test_migration_0003_indexes.py'
- 'tests/database/test_migration_0004_app_settings.py'
- 'tests/performance/database/scripts/create_compat_db.py'
push:
branches: [main]
release:
types: [created]
schedule:
# Weekly on Sundays at 2am UTC - catch dependency drift
- cron: '0 2 * * 0'
workflow_dispatch:
# No concurrency group — intentionally omitted.
# This workflow triggers on both pull_request and workflow_call (from
# ci-gate.yml / release-gate.yml). A shared concurrency key would cause
# direct PR runs and workflow_call runs to cancel each other mid-flight.
# See #3554 (reverted in #3599) for context.
# Top-level permissions set to minimum (OSSF Scorecard Token-Permissions)
permissions: {}
jobs:
# Fast test - runs on every trigger including PRs.
# Provides quick feedback on encryption constant stability.
encryption-constants:
runs-on: ubuntu-latest
timeout-minutes: 10
name: Verify Encryption Constants
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up PDM
uses: pdm-project/setup-pdm@973541a5febeafcfdadf8a51211435be6ecfd90f # v4.5
with:
python-version: '3.12'
- name: Free up disk space
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
sudo docker image prune --all --force
- name: Install dependencies
run: pdm install --dev
- name: Run encryption constants tests
run: |
pdm run pytest tests/database/test_encryption_constants.py -v --tb=short
# Slow test - full PyPI version compatibility.
# Skipped on PRs because it installs previous PyPI versions which is slow
# and the encryption-constants job already provides fast PR feedback.
pypi-compatibility:
runs-on: ubuntu-latest
timeout-minutes: 30
name: PyPI Version Compatibility
permissions:
contents: read
# Run on main push, releases, schedule, manual dispatch, and workflow_call (release gate)
if: |
github.event_name == 'push' ||
github.event_name == 'release' ||
github.event_name == 'schedule' ||
github.event_name == 'workflow_dispatch' ||
github.event_name == 'workflow_call'
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up PDM
uses: pdm-project/setup-pdm@973541a5febeafcfdadf8a51211435be6ecfd90f # v4.5
with:
python-version: '3.12'
- name: Free up disk space
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
sudo docker image prune --all --force
- name: Install test dependencies
run: |
python -m pip install --upgrade pip
pip install pytest
- name: Get previous PyPI version
id: prev-version
run: |
# Get list of available versions from PyPI
VERSIONS_OUTPUT=$(pip index versions local-deep-research 2>&1) || true
if echo "$VERSIONS_OUTPUT" | grep -q "Available versions:"; then
VERSIONS=$(echo "$VERSIONS_OUTPUT" | grep "Available versions:" | cut -d: -f2 | tr ',' '\n' | tr -d ' ')
else
echo "::warning::Could not fetch versions from PyPI: ${VERSIONS_OUTPUT}"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ -z "$VERSIONS" ]; then
echo "::warning::No versions found on PyPI"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Get current and previous versions
CURRENT=$(echo "$VERSIONS" | head -1)
PREVIOUS=$(echo "$VERSIONS" | head -2 | tail -1)
if [ "$CURRENT" = "$PREVIOUS" ] || [ -z "$PREVIOUS" ]; then
echo "No previous version available"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
{
echo "current=$CURRENT"
echo "previous=$PREVIOUS"
echo "skip=false"
} >> "$GITHUB_OUTPUT"
echo "Previous version: $PREVIOUS, Current: $CURRENT"
- name: Create database with previous version
id: create-db
if: steps.prev-version.outputs.skip != 'true'
run: |
# Create isolated venv for previous version
python -m venv prev_venv
source prev_venv/bin/activate
# Install previous version — may fail if the published package
# has broken dependency metadata (e.g. requests version conflict).
# Upgrade pip for reliable dependency resolution in fresh venvs.
python -m pip install --upgrade pip
if ! pip install "local-deep-research==${{ steps.prev-version.outputs.previous }}" 2>&1; then
echo "::warning::Previous version ${{ steps.prev-version.outputs.previous }} has unresolvable dependencies — skipping compat test"
echo "install_failed=true" >> "$GITHUB_OUTPUT"
deactivate
exit 0
fi
echo "install_failed=false" >> "$GITHUB_OUTPUT"
# Create test database
mkdir -p test_db
python tests/performance/database/scripts/create_compat_db.py test_db compat_user "TestPass123!"
deactivate
echo "Database created with version ${{ steps.prev-version.outputs.previous }}"
ls -la test_db/
- name: Install current version
if: steps.prev-version.outputs.skip != 'true' && steps.create-db.outputs.install_failed != 'true'
run: pdm install --dev
- name: Test opening database with current version
if: steps.prev-version.outputs.skip != 'true' && steps.create-db.outputs.install_failed != 'true'
run: |
# Run the backwards compatibility test with RUN_SLOW_TESTS enabled
RUN_SLOW_TESTS=true pdm run pytest tests/performance/database/test_backwards_compatibility.py -v --tb=short
- name: Report skipped
if: steps.prev-version.outputs.skip == 'true' || steps.create-db.outputs.install_failed == 'true'
run: |
if [ "${{ steps.create-db.outputs.install_failed }}" = "true" ]; then
echo "::notice::Backwards compatibility test skipped - previous version has unresolvable dependency conflicts"
else
echo "::notice::Backwards compatibility test skipped - no previous PyPI version available"
fi
# Database migration tests - validates Alembic migration infrastructure.
# Runs on any trigger — including PRs that touch migration-relevant paths
# (gated by the top-level `pull_request: paths:` filter).
migration-tests:
runs-on: ubuntu-latest
timeout-minutes: 20
name: Database Migration Tests
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up PDM
uses: pdm-project/setup-pdm@973541a5febeafcfdadf8a51211435be6ecfd90f # v4.5
with:
python-version: '3.12'
- name: Free up disk space
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
sudo docker image prune --all --force
- name: Install dependencies
run: pdm install --dev
- name: Run Alembic migration tests
run: |
pdm run pytest tests/database/test_alembic_migrations.py -v --tb=short
- name: Run migration version tests
run: |
pdm run pytest tests/database/test_migration_0003_indexes.py tests/database/test_migration_0004_app_settings.py -v --tb=short
- name: Run database initialization tests
run: |
pdm run pytest tests/database/test_initialize_functions.py tests/test_database_initialization.py -v --tb=short
- name: Run encrypted DB integration tests
run: |
pdm run pytest tests/auth_tests/test_encrypted_db.py -v --tb=short
+117
View File
@@ -0,0 +1,117 @@
name: Bearer Security Scan
on:
workflow_dispatch:
workflow_call: # Called by release-gate.yml
schedule:
# Run security scan daily at 4 AM UTC
- cron: '0 4 * * *'
permissions: {} # Minimal top-level for OSSF Scorecard Token-Permissions
jobs:
bearer-scan:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
security-events: write
actions: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Run Bearer SAST Scanner
uses: bearer/bearer-action@828eeb928ce2f4a7ca5ed57fb8b59508cb8c79bc # v2
with:
scanner: sast,secrets
config-file: bearer.yml
format: sarif
output: bearer-results.sarif
# DO NOT change to exit-code: 1 — findings are enforced via SARIF alerts
# in the release gate (check-code-scanning-alerts job in release-gate.yml).
# Failing here would break CI without adding security value.
exit-code: 0
# Fail loudly if Bearer produced no SARIF — never fabricate an empty one.
# An empty-results SARIF uploaded under the bearer-security category would
# make GitHub mark every previously-open Bearer alert as fixed, silently
# clearing real findings. Mirror the Grype/Trivy jobs: error + exit 1, skip
# upload. bearer-action writes a SARIF even with zero findings, so a missing
# file means a real scan failure, not a clean run.
- name: Ensure SARIF file exists
id: check-sarif
if: always()
run: |
if [ -f bearer-results.sarif ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "::error::Bearer did not produce a SARIF file — scan needs to be rerun"
exit 1
fi
- name: Upload Bearer results to GitHub Security tab
uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
if: always() && steps.check-sarif.outputs.exists == 'true'
with:
sarif_file: bearer-results.sarif
category: bearer-security
- name: Upload Bearer results as artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always() && steps.check-sarif.outputs.exists == 'true'
with:
name: bearer-scan-results
path: bearer-results.sarif
retention-days: 7
- name: Display Bearer summary
if: always()
run: |
{
echo "## Bearer Security Scan Summary"
echo ""
} >> "$GITHUB_STEP_SUMMARY"
if [ -f bearer-results.sarif ]; then
# Count findings by level
ERRORS=$(python3 -c "import json; data=json.load(open('bearer-results.sarif')); results=data.get('runs',[{}])[0].get('results',[]); print(len([r for r in results if r.get('level')=='error']))" 2>/dev/null || echo "0")
WARNINGS=$(python3 -c "import json; data=json.load(open('bearer-results.sarif')); results=data.get('runs',[{}])[0].get('results',[]); print(len([r for r in results if r.get('level')=='warning']))" 2>/dev/null || echo "0")
NOTES=$(python3 -c "import json; data=json.load(open('bearer-results.sarif')); results=data.get('runs',[{}])[0].get('results',[]); print(len([r for r in results if r.get('level')=='note']))" 2>/dev/null || echo "0")
TOTAL=$(python3 -c "import json; data=json.load(open('bearer-results.sarif')); print(len(data.get('runs',[{}])[0].get('results',[])))" 2>/dev/null || echo "0")
{
echo "📊 **Scan Results:**"
echo "- **Errors:** $ERRORS"
echo "- **Warnings:** $WARNINGS"
echo "- **Notes:** $NOTES"
echo "- **Total:** $TOTAL"
echo ""
} >> "$GITHUB_STEP_SUMMARY"
if [ "$ERRORS" -gt 0 ]; then
echo "⚠️ **Action Required:** Error-level issues found" >> "$GITHUB_STEP_SUMMARY"
elif [ "$WARNINGS" -gt 0 ]; then
echo "⚠️ **Review Recommended:** Warning-level issues found" >> "$GITHUB_STEP_SUMMARY"
else
echo "✅ **No Error or Warning level issues found**" >> "$GITHUB_STEP_SUMMARY"
fi
{
echo ""
echo "📋 **Details:**"
echo "- Bearer scans for sensitive data flow and secrets exposure"
echo "- Results uploaded to GitHub Security tab"
echo "- SARIF report available in artifacts"
} >> "$GITHUB_STEP_SUMMARY"
else
echo "❌ Bearer scan failed or no results generated" >> "$GITHUB_STEP_SUMMARY"
fi
+32
View File
@@ -0,0 +1,32 @@
name: Check Configuration Docs
# Manual-only check. Config docs are auto-regenerated by the version bump
# workflow (version_check.yml) on every push to main, so a blocking PR
# check is unnecessary and creates manual work for contributors.
on:
workflow_dispatch:
permissions:
contents: read
jobs:
check-config-docs:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.12'
- name: Check configuration docs are up to date
run: python scripts/generate_config_docs.py --check
+43
View File
@@ -0,0 +1,43 @@
name: Check Environment Variables
on:
pull_request:
workflow_call: # Called by ci-gate.yml for release pipeline
workflow_dispatch:
# No concurrency group — intentionally omitted.
# This workflow triggers on both pull_request and workflow_call (from
# ci-gate.yml / release-gate.yml). A shared concurrency key would cause
# direct PR runs and workflow_call runs to cancel each other mid-flight.
# See #3554 (reverted in #3599) for context.
permissions:
contents: read
jobs:
check-env-vars:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.12'
- name: Install dependencies
run: |
pip install loguru==0.7.3 sqlalchemy==2.0.36 sqlalchemy-utc==0.14.0 platformdirs==4.3.6 pydantic==2.10.4
- name: Run environment variable validation
run: |
python tests/settings/env_vars/test_env_var_usage.py
@@ -0,0 +1,52 @@
name: Check Workflow Status Dashboard
# Fails when a workflow file is added/renamed without a corresponding row
# in docs/ci/workflow-status.md. Pure structural check — no GitHub API
# calls, no live data — so it runs fast and doesn't need any auth.
#
# To fix a failure: regenerate the dashboard with
# `pdm run python scripts/generate_workflow_status.py`
# This requires `gh` authenticated against the repo. If you can't run it
# locally, ping a maintainer to regenerate, or add a temporary placeholder
# `\`<your-new-workflow>.yml\`` mention in the file's manual-edit region
# to unblock the PR.
on:
pull_request:
paths:
- '.github/workflows/**'
- 'docs/ci/workflow-status.md'
- 'scripts/generate_workflow_status.py'
workflow_dispatch:
permissions:
contents: read
jobs:
check-structure:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.12'
- name: Install PyYAML
# Pinned to match pdm.lock; the rest of the repo uses exact
# `==` pins for ad-hoc workflow installs (see e.g.
# validate-image-pinning.yml). Floating `~=` ranges can pick up
# yanked / replaced patch versions silently.
run: pip install pyyaml==6.0.3
- name: Verify dashboard structure
run: python scripts/generate_workflow_status.py --check-structure
+100
View File
@@ -0,0 +1,100 @@
name: Checkov IaC Security Scan
on:
workflow_dispatch:
workflow_call: # Called by release-gate.yml
permissions: {} # Minimal top-level for OSSF Scorecard Token-Permissions
jobs:
checkov:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
security-events: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.12'
- name: Install Checkov
run: pip install checkov==3.2.499
# Reverted to CLI approach due to known bug in bridgecrewio/checkov-action
# when running multiple consecutive action calls (heredoc delimiter overflow)
# See: https://github.com/bridgecrewio/checkov-action/issues/170
# See: https://github.com/bridgecrewio/checkov/issues/5866
- name: Run Checkov on Dockerfile
run: |
checkov -f Dockerfile \
--framework dockerfile \
--skip-check CKV_DOCKER_3 \
-o cli -o sarif \
--output-file-path console,checkov-docker.sarif
- name: Run Checkov on docker-compose files
run: |
checkov \
-f docker-compose.yml \
-f docker-compose.gpu.override.yml \
-f docker-compose.tts.yml \
-f docker-compose.unraid.yml \
--framework yaml \
-o cli -o sarif \
--output-file-path console,checkov-compose.sarif
- name: Run Checkov on GitHub Actions workflows
run: |
checkov -d .github/workflows \
--framework github_actions \
--skip-check CKV_GHA_7 \
-o cli -o sarif \
--output-file-path console,checkov-workflows.sarif
- name: Check SARIF files exist
id: check-sarif
if: always()
run: |
[ -f checkov-docker.sarif ] && echo "docker=true" >> "$GITHUB_OUTPUT"
[ -f checkov-compose.sarif ] && echo "compose=true" >> "$GITHUB_OUTPUT"
[ -f checkov-workflows.sarif ] && echo "workflows=true" >> "$GITHUB_OUTPUT"
- name: Upload Dockerfile SARIF results
if: always() && steps.check-sarif.outputs.docker == 'true'
uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
with:
sarif_file: checkov-docker.sarif
category: checkov-dockerfile
- name: Upload docker-compose SARIF results
if: always() && steps.check-sarif.outputs.compose == 'true'
uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
with:
sarif_file: checkov-compose.sarif
category: checkov-compose
- name: Upload GitHub Actions SARIF results
if: always() && steps.check-sarif.outputs.workflows == 'true'
uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
with:
sarif_file: checkov-workflows.sarif
category: checkov-workflows
- name: Summary
if: always()
run: |
echo "=== Checkov IaC Security Scan Summary ==="
echo "SARIF results uploaded to GitHub Security tab"
echo "Scan completed (strict mode - failures block CI)"
+259
View File
@@ -0,0 +1,259 @@
name: CI Gate
# CI quality gate for the release pipeline.
#
# Ensures all PR-quality checks (linting, type checking, tests, validation)
# run and pass before any release proceeds. Complements the security-focused
# release-gate.yml with code quality and correctness checks.
#
# Architecture:
# release.yml → ci-gate.yml → {pre-commit, mypy, docker-tests, ...}
# Nesting depth: release.yml(1) → ci-gate.yml(2) → workflow(3) — safe limit.
on:
workflow_call: # Called by release.yml
secrets:
OPENROUTER_API_KEY:
required: false
workflow_dispatch: # Manual trigger
permissions: {} # Minimal top-level for OSSF Scorecard
jobs:
# ============================================
# Code Quality
# ============================================
pre-commit:
uses: ./.github/workflows/pre-commit.yml
permissions:
contents: read
mypy-type-check:
uses: ./.github/workflows/mypy-type-check.yml
permissions:
contents: read
# ============================================
# Test Suites
# ============================================
docker-tests:
uses: ./.github/workflows/docker-tests.yml
with:
strict-mode: true
permissions:
contents: write # Needed by pytest-tests for gh-pages deployment
pull-requests: write # Needed by pytest-tests for PR comments (no-ops in release context)
secrets:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
# ============================================
# Validation
# ============================================
validate-image-pinning:
uses: ./.github/workflows/validate-image-pinning.yml
permissions:
contents: read
file-whitelist-check:
uses: ./.github/workflows/file-whitelist-check.yml
with:
check-all-files: true
permissions:
contents: read
check-env-vars:
uses: ./.github/workflows/check-env-vars.yml
permissions:
contents: read
security-file-write-check:
uses: ./.github/workflows/security-file-write-check.yml
permissions:
contents: read
# ============================================
# Summary job that reports overall status
# ============================================
ci-gate-summary:
name: CI Gate Summary
runs-on: ubuntu-latest
needs:
# Code Quality
- pre-commit
- mypy-type-check
# Test Suites
- docker-tests
# Validation
- validate-image-pinning
- file-whitelist-check
- check-env-vars
- security-file-write-check
if: always()
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Check CI scan results
env:
PRE_COMMIT_RESULT: ${{ needs.pre-commit.result }}
MYPY_RESULT: ${{ needs.mypy-type-check.result }}
DOCKER_TESTS_RESULT: ${{ needs.docker-tests.result }}
IMAGE_PINNING_RESULT: ${{ needs.validate-image-pinning.result }}
FILE_WHITELIST_RESULT: ${{ needs.file-whitelist-check.result }}
ENV_VARS_RESULT: ${{ needs.check-env-vars.result }}
FILE_WRITE_RESULT: ${{ needs.security-file-write-check.result }}
run: |
# Redirect all output to GITHUB_STEP_SUMMARY (fixes SC2129)
exec >> "$GITHUB_STEP_SUMMARY"
# Count results first
FAILED=""
PASS_COUNT=0
FAIL_COUNT=0
check_result() {
local result="$1"
if [ "$result" = "success" ]; then
PASS_COUNT=$((PASS_COUNT + 1))
return 0
else
FAIL_COUNT=$((FAIL_COUNT + 1))
FAILED="true"
return 1
fi
}
# Check all results silently first
check_result "$PRE_COMMIT_RESULT" || true
check_result "$MYPY_RESULT" || true
check_result "$DOCKER_TESTS_RESULT" || true
check_result "$IMAGE_PINNING_RESULT" || true
check_result "$FILE_WHITELIST_RESULT" || true
check_result "$ENV_VARS_RESULT" || true
check_result "$FILE_WRITE_RESULT" || true
TOTAL=$((PASS_COUNT + FAIL_COUNT))
# ============================================
# BIG STATUS BANNER
# ============================================
if [ -z "$FAILED" ]; then
echo "# :white_check_mark: CI GATE: PASSED"
echo ""
echo "> **All $TOTAL CI checks passed successfully.**"
echo "> This release is approved from a code quality perspective."
else
echo "# :x: CI GATE: FAILED"
echo ""
echo "> **$FAIL_COUNT of $TOTAL checks failed.** Release is blocked."
echo "> Review the failures below and fix before releasing."
fi
echo ""
echo "---"
echo ""
echo "## Detailed Results"
echo ""
# Reset for detailed output
FAILED=""
# ============================================
# Code Quality
# ============================================
echo "### Code Quality"
if [ "$PRE_COMMIT_RESULT" = "success" ]; then
echo ":white_check_mark: **Pre-commit (linting, formatting)**: Passed"
else
echo ":x: **Pre-commit (linting, formatting)**: $PRE_COMMIT_RESULT"
FAILED="true"
fi
if [ "$MYPY_RESULT" = "success" ]; then
echo ":white_check_mark: **Mypy Type Check**: Passed"
else
echo ":x: **Mypy Type Check**: $MYPY_RESULT"
FAILED="true"
fi
# ============================================
# Test Suites
# ============================================
echo ""
echo "### Test Suites"
if [ "$DOCKER_TESTS_RESULT" = "success" ]; then
echo ":white_check_mark: **Docker Tests (pytest + UI + LLM + infra + smoke)**: Passed"
else
echo ":x: **Docker Tests (pytest + UI + LLM + infra + smoke)**: $DOCKER_TESTS_RESULT"
FAILED="true"
fi
# ============================================
# Validation
# ============================================
echo ""
echo "### Validation"
if [ "$IMAGE_PINNING_RESULT" = "success" ]; then
echo ":white_check_mark: **Docker Image Pinning**: Passed"
else
echo ":x: **Docker Image Pinning**: $IMAGE_PINNING_RESULT"
FAILED="true"
fi
if [ "$FILE_WHITELIST_RESULT" = "success" ]; then
echo ":white_check_mark: **File Whitelist Security**: Passed"
else
echo ":x: **File Whitelist Security**: $FILE_WHITELIST_RESULT"
FAILED="true"
fi
if [ "$ENV_VARS_RESULT" = "success" ]; then
echo ":white_check_mark: **Environment Variables**: Passed"
else
echo ":x: **Environment Variables**: $ENV_VARS_RESULT"
FAILED="true"
fi
if [ "$FILE_WRITE_RESULT" = "success" ]; then
echo ":white_check_mark: **Security File Writes**: Passed"
else
echo ":x: **Security File Writes**: $FILE_WRITE_RESULT"
FAILED="true"
fi
# ============================================
# Final result with prominent summary
# ============================================
echo ""
echo "---"
echo ""
if [ -n "$FAILED" ]; then
echo "## :rotating_light: Action Required"
echo ""
echo "| Status | Result |"
echo "|--------|--------|"
echo "| **Gate** | :x: **BLOCKED** |"
echo "| **Passed** | $PASS_COUNT |"
echo "| **Failed** | $FAIL_COUNT |"
echo ""
echo "_Fix the failing checks above before releasing._"
exit 1
else
echo "## :tada: Ready for Release"
echo ""
echo "| Status | Result |"
echo "|--------|--------|"
echo "| **Gate** | :white_check_mark: **APPROVED** |"
echo "| **Passed** | $PASS_COUNT / $TOTAL |"
echo ""
echo "_All CI checks passed. Security scans run as separate gate in release pipeline._"
fi
+57
View File
@@ -0,0 +1,57 @@
name: Claude Code Review
on:
pull_request:
types: [labeled]
# No concurrency group — intentionally omitted.
# Previous attempt (#3554, reverted #3599) used cancel-in-progress which
# killed in-progress PR runs before they produced useful results.
# Future iteration could safely add concurrency for scheduled/push-only
# triggers (where head_ref is empty and runs get unique groups).
permissions: {} # Minimal top-level for OSSF Scorecard Token-Permissions
jobs:
claude-review:
# Only run when 'claude-review' label is added (opt-in)
if: github.event.label.name == 'claude-review'
runs-on: ubuntu-latest
timeout-minutes: 30
environment: ci
permissions:
contents: read
pull-requests: write
issues: write
id-token: write # Required for OIDC authentication with claude-code-action
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@558b1d6cab4085c7753fe402c10bef0fbb92ac7a # v1.0.165
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# CLI arguments for Claude (v1.0+ format)
claude_args: |
--model claude-opus-4-5-20251101
# Prompt for automated review (no @claude mention needed)
prompt: |
Please review this pull request and provide feedback on:
- Code quality and best practices
- Potential bugs or issues
- Performance considerations
- Security concerns
- Test coverage
Be constructive and helpful in your feedback.
+114
View File
@@ -0,0 +1,114 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL Advanced"
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '45 5 * * 0'
workflow_call: # Called by release-gate.yml
workflow_dispatch:
# No concurrency group — intentionally omitted.
# This workflow triggers on both pull_request and workflow_call (from
# ci-gate.yml / release-gate.yml). A shared concurrency key would cause
# direct PR runs and workflow_call runs to cancel each other mid-flight.
# See #3554 (reverted in #3599) for context.
permissions: {} # Minimal top-level for OSSF Scorecard Token-Permissions
jobs:
analyze:
name: Analyze (${{ matrix.language }})
# Runner size impacts CodeQL analysis time. To learn more, please see:
# - https://gh.io/recommended-hardware-resources-for-running-codeql
# - https://gh.io/supported-runners-and-hardware-resources
# - https://gh.io/using-larger-runners (GitHub.com only)
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
timeout-minutes: 30
permissions:
contents: read
security-events: write
packages: read
actions: read
strategy:
fail-fast: false
matrix:
include:
- language: javascript-typescript
build-mode: none
- language: python
build-mode: none
# CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
# Use `c-cpp` to analyze code written in C, C++ or both
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node`
# or others). This is typically only required for manual builds.
# - name: Setup runtime (example)
# uses: actions/setup-example@v1
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
config-file: ./.github/codeql/codeql-config.yml
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# If the analyze step fails for one of the languages you are analyzing with
# "We were unable to automatically build your code", modify the matrix above
# to set the build mode to "manual" for that language. Then modify this step
# to build your code.
# ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- if: matrix.build-mode == 'manual'
shell: bash
run: |
echo 'If you are using a "manual" build mode for one or more of the' \
'languages you are analyzing, replace this with the commands to build' \
'your code, for example:'
echo ' make bootstrap'
echo ' make release'
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
category: "/language:${{matrix.language}}"
@@ -0,0 +1,195 @@
name: Compose Integration Test
# Brings up the bundled docker-compose.yml end-to-end in CI and verifies the
# whole stack (searxng, ollama, local-deep-research) reaches a healthy state.
# This is the test that would have caught #3874 (broken cap_drop on searxng)
# before users hit it. See #3886 for the rationale.
#
# Cost: ~3-6 min per run depending on cache state. Runs only via the release
# pipeline (`release.yml` includes it as `compose-integration-gate`) and on
# manual dispatch. NOT inside `release-gate.yml`, because release-gate runs
# daily and the failure modes here (compose/image changes) are tied to
# actual release events, not time. NOT on pull_request — too expensive for
# per-PR feedback latency.
#
# Do not move this into release-gate's daily cron. Ongoing compose drift
# between releases is already covered by `compose-published-smoke.yml`,
# which runs weekly against main's compose.yml + the *published* Docker Hub
# image — the more meaningful drift to catch, since users pull the
# published image, not main's build override. PR #3962 tried adding this
# to the daily cron and was closed for that reason.
#
# Tests the docker-compose.yml as users actually run it — no test-only
# overrides. The model pre-pull was removed from the compose itself in this
# same PR so the stack starts in seconds rather than waiting on a multi-GB
# download that the test doesn't need.
on:
workflow_call:
workflow_dispatch:
permissions:
contents: read
jobs:
compose-up:
name: docker compose up + healthcheck
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
# Build the LDR image from the working tree and tag it as the name the
# bundled compose references. Compose then uses the local image instead
# of pulling the published one — so we test the current code path, not
# whatever's on Docker Hub.
- name: Build LDR image with the published tag
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
# Same target as docker-tests.yml's `ldr-prod` build, so its cache
# layers (scope=ldr-prod, populated by ci-gate during the same
# release pipeline) are reusable here. Explicit target guards
# against future Dockerfile reorderings making the default stage
# something other than `ldr`.
target: ldr
load: true
tags: localdeepresearch/local-deep-research:latest
# Read from both our own scope and the shared `ldr-prod` scope —
# whichever has the layers wins. Falls back to a fresh build if
# neither does (e.g. on a brand-new branch). Only write to our
# own scope so we don't poison the cross-workflow cache.
cache-from: |
type=gha,scope=compose-integration
type=gha,scope=ldr-prod
cache-to: type=gha,mode=max,scope=compose-integration
- name: Bring up the stack
# `docker compose up -d` pulls any image it doesn't have locally
# (default pull_policy: missing), so ollama and searxng are pulled
# inline. The LDR image is the locally-built one from the previous
# step — compose sees it's already present and uses it as-is.
# `--no-build` defends against a future docker-compose.yml change
# adding a `build:` directive — we want the image we tagged above,
# not a fresh build that bypasses the cache strategy.
run: docker compose up -d --no-build
- name: Wait for the stack to be healthy and serving
# Budget: 6 min covers cold daemon startup + LDR migrations + flask
# boot + slow CI runners. Without the model pull this completes in
# ~1-2 min on a warm runner.
#
# Container resolution: always via `docker compose ps -q <service>`
# so we don't couple to compose's `container_name:` values. If those
# drift, this still works.
run: |
set -euo pipefail
deadline=$(( $(date +%s) + 360 ))
cid_for() { docker compose ps -q "$1" 2>/dev/null || true; }
status() {
local cid=$1
[ -n "$cid" ] || { echo missing; return; }
docker inspect -f "{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}" "$cid" 2>/dev/null || echo missing
}
while :; do
now=$(date +%s)
if [ "$now" -ge "$deadline" ]; then
echo "::error::Timed out after 6 min waiting for stack to be healthy"
docker compose ps
exit 1
fi
ollama_id=$(cid_for ollama)
searxng_id=$(cid_for searxng)
ldr_id=$(cid_for local-deep-research)
ollama_h=$(status "$ollama_id")
searxng_h=$(status "$searxng_id")
ldr_h=$(status "$ldr_id")
echo "[$(date -u +%H:%M:%S)] ollama=$ollama_h searxng=$searxng_h ldr=$ldr_h"
# All three services have healthchecks: ollama and searxng via
# docker-compose.yml, LDR via the Dockerfile (HEALTHCHECK at
# Dockerfile:306, probing /api/v1/health). status() returns the
# health status when one is defined, so require "healthy" for
# all three — strictly stronger signal than "running".
if [ "$ollama_h" = "healthy" ] && [ "$searxng_h" = "healthy" ] && [ "$ldr_h" = "healthy" ]; then
echo "All services healthy."
break
fi
# Fail fast on any container exiting non-zero rather than burning
# the whole 6 min budget.
for svc in ollama searxng local-deep-research; do
cid=$(cid_for "$svc")
[ -n "$cid" ] || continue
s=$(docker inspect -f '{{.State.Status}}' "$cid" 2>/dev/null || true)
if [ "$s" = "exited" ] || [ "$s" = "dead" ]; then
echo "::error::Container for service $svc has exited"
exit 1
fi
done
sleep 10
done
- name: Probe LDR HTTP endpoint
# Avoids `curl ... | grep` so we don't need pipefail to surface curl
# failures — the HTTP code is captured directly via -w and checked
# with a case statement. `|| echo "000"` is the sentinel for true
# network failures (connection refused, DNS, etc.) so we can log
# on retry.
#
# Deliberately no `-f`: with `-f`, curl exits non-zero on HTTP
# 4xx/5xx AND suppresses -w output — which would collapse "404",
# "503", and network-error all into "000" and erase the most
# interesting failure signal (LDR up but serving an error page).
# Without -f, every HTTP response gives us its real code; only
# true network failures fall through to "000".
run: |
set -euo pipefail
for i in $(seq 1 30); do
code=$(curl -sS -o /dev/null -w "%{http_code}" http://localhost:5000/ 2>/dev/null || echo "000")
case "$code" in
200|301|302|303|307|308)
echo "LDR is serving on :5000 (HTTP $code)"
exit 0
;;
esac
echo "Waiting for LDR HTTP (attempt $i/30, last code: $code)..."
sleep 5
done
echo "::error::LDR HTTP probe failed after ~150s"
exit 1
- name: Dump compose state and logs
if: always()
run: |
echo "::group::docker compose ps"
docker compose ps || true
echo "::endgroup::"
for svc in ollama searxng local-deep-research; do
echo "::group::$svc logs"
docker compose logs --no-color --tail=500 "$svc" || true
echo "::endgroup::"
done
- name: Tear down
if: always()
run: docker compose down -v --remove-orphans
@@ -0,0 +1,257 @@
name: Compose Published-Image Smoke
# Tests "main's docker-compose.yml + the currently-published Docker Hub image"
# end-to-end. Catches drift between compose changes that have landed on main
# and the image artefact that users actually pull when they follow the README
# quickstart:
#
# curl -O .../docker-compose.yml && docker compose up -d
#
# This complements the release-pipeline test in compose-integration-test.yml,
# which builds the image from the working tree (validating "this code's
# compose with this code's image") and runs only at release time + manual
# dispatch. That test cannot catch the drift case — the published image
# always lags main between releases.
#
# Triggers: weekly schedule + manual dispatch only. No pull_request trigger
# because PRs don't change the published image — by definition this test
# can only fail on drift that's already landed on main.
#
# On failure, opens a tracking issue (or comments on the existing one) so a
# scheduled-job failure isn't lost in the noise.
on:
workflow_dispatch:
schedule:
# 05:00 UTC every Monday — offset from release-gate's 02:00 daily cron
# to avoid runner contention. Weekly cadence is enough since the failure
# modes are slow-moving (compose-vs-published-image drift) and the test
# itself burns CI minutes.
- cron: '0 5 * * 1'
permissions:
contents: read
jobs:
compose-up:
name: docker compose up + healthcheck (published image)
runs-on: ubuntu-latest
timeout-minutes: 25
permissions:
contents: read
issues: write # for the failure-issue step
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Checkout (for docker-compose.yml on default branch)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# Pull all three services from their registries, including the LDR
# image. This is the whole point of this workflow — exercise the
# exact artefact users get from `docker compose up -d`.
- name: Pull all images
run: docker compose pull
- name: Bring up the stack
# `--no-build` defends against a future docker-compose.yml change
# adding a `build:` directive — this workflow specifically tests the
# *published* image artifact, not a fresh build from source.
run: docker compose up -d --no-build
- name: Wait for the stack to be healthy and serving
# Same wait logic as compose-integration-test.yml. 6 min budget
# covers cold daemon startup + LDR migrations + flask boot.
# Container resolution: always via `docker compose ps -q <service>`
# so we don't couple to compose's `container_name:` values.
run: |
set -euo pipefail
deadline=$(( $(date +%s) + 360 ))
cid_for() { docker compose ps -q "$1" 2>/dev/null || true; }
status() {
local cid=$1
[ -n "$cid" ] || { echo missing; return; }
docker inspect -f "{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}" "$cid" 2>/dev/null || echo missing
}
while :; do
now=$(date +%s)
if [ "$now" -ge "$deadline" ]; then
echo "::error::Timed out after 6 min waiting for stack to be healthy"
docker compose ps
exit 1
fi
ollama_id=$(cid_for ollama)
searxng_id=$(cid_for searxng)
ldr_id=$(cid_for local-deep-research)
ollama_h=$(status "$ollama_id")
searxng_h=$(status "$searxng_id")
ldr_h=$(status "$ldr_id")
echo "[$(date -u +%H:%M:%S)] ollama=$ollama_h searxng=$searxng_h ldr=$ldr_h"
# All three services have healthchecks: ollama and searxng via
# docker-compose.yml, LDR via the Dockerfile (HEALTHCHECK at
# Dockerfile:306, probing /api/v1/health). status() returns the
# health status when one is defined, so require "healthy" for
# all three.
if [ "$ollama_h" = "healthy" ] && [ "$searxng_h" = "healthy" ] && [ "$ldr_h" = "healthy" ]; then
echo "All services healthy."
break
fi
for svc in ollama searxng local-deep-research; do
cid=$(cid_for "$svc")
[ -n "$cid" ] || continue
s=$(docker inspect -f '{{.State.Status}}' "$cid" 2>/dev/null || true)
if [ "$s" = "exited" ] || [ "$s" = "dead" ]; then
echo "::error::Container for service $svc has exited"
exit 1
fi
done
sleep 10
done
- name: Probe LDR HTTP endpoint
# Avoids `curl ... | grep` so we don't need pipefail to surface curl
# failures — the HTTP code is captured directly via -w and checked
# with a case statement. `|| echo "000"` is the sentinel for true
# network failures (connection refused, DNS, etc.) so we can log
# on retry.
#
# Deliberately no `-f`: with `-f`, curl exits non-zero on HTTP
# 4xx/5xx AND suppresses -w output — which would collapse "404",
# "503", and network-error all into "000" and erase the most
# interesting failure signal (LDR up but serving an error page).
# Without -f, every HTTP response gives us its real code; only
# true network failures fall through to "000".
run: |
set -euo pipefail
for i in $(seq 1 30); do
code=$(curl -sS -o /dev/null -w "%{http_code}" http://localhost:5000/ 2>/dev/null || echo "000")
case "$code" in
200|301|302|303|307|308)
echo "LDR is serving on :5000 (HTTP $code)"
exit 0
;;
esac
echo "Waiting for LDR HTTP (attempt $i/30, last code: $code)..."
sleep 5
done
echo "::error::LDR HTTP probe failed after ~150s"
exit 1
- name: Dump compose state and logs
if: always()
run: |
echo "::group::docker compose ps"
docker compose ps || true
echo "::endgroup::"
for svc in ollama searxng local-deep-research; do
echo "::group::$svc logs"
docker compose logs --no-color --tail=500 "$svc" || true
echo "::endgroup::"
done
# Capture image digests for both the workflow log (every run, via
# the cat below) and the auto-failure-issue body (failure runs
# only, via the next step). Always-logging gives us audit and
# bisection signal — "what was the published image SHA when this
# passed on date X?" — without needing to re-run the workflow.
# Resolve every container by service name through `docker compose
# ps -q` so we don't depend on `container_name:` values.
cid_for() { docker compose ps -q "$1" 2>/dev/null || true; }
{
echo "## Image digests"
for svc in ollama searxng local-deep-research; do
cid=$(cid_for "$svc")
if [ -n "$cid" ]; then
img=$(docker inspect -f '{{.Image}}' "$cid" 2>/dev/null || echo missing)
echo "- $svc: image=$img container=$cid"
else
echo "- $svc: container=missing"
fi
done
} > /tmp/digests.md
echo "::group::Image digests"
cat /tmp/digests.md
echo "::endgroup::"
- name: Open or update failure-tracking issue
if: failure() && github.event_name == 'schedule'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
# Stable title prefix so we dedup across weeks. If an issue with
# this title is already open, comment on it; otherwise create a
# fresh one. We don't auto-close on success — let a human triage.
TITLE="[compose-published-smoke] published-image drift detected"
DATE=$(date -u +%Y-%m-%d)
DIGESTS=$(cat /tmp/digests.md 2>/dev/null || echo "(digests not captured)")
BODY=$(cat <<MARKDOWN
The weekly **published-image smoke test** failed on $DATE.
This usually means \`main\`'s \`docker-compose.yml\` and the currently-published
\`localdeepresearch/local-deep-research:latest\` on Docker Hub have drifted —
either a compose change landed without an image republish, or the
published image (or one of its dependencies) regressed.
**Workflow run:** $RUN_URL
**What to check first:**
- Compose changes since the last release: \`git log --oneline <last-release-tag>..main -- docker-compose.yml\`
- Whether the LDR image needs a republish (cut a release, or the image was repointed)
- Upstream image regressions: ollama, searxng image digests below
$DIGESTS
*This issue is auto-managed by \`.github/workflows/compose-published-smoke.yml\`.
Subsequent failures will add comments here. Close it when the underlying drift is fixed
and a successful run rolls in.*
MARKDOWN
)
# Find existing open issue with the same title
NUM=$(gh issue list -R "$REPO" \
--state open \
--search "in:title \"[compose-published-smoke] published-image drift detected\"" \
--json number,title \
--jq '.[0].number // empty' \
--limit 1)
if [ -n "$NUM" ]; then
echo "Commenting on existing issue #$NUM"
# `--body=` (= form) prevents gh from interpreting body content
# as flags if it ever starts with `-`. Same below.
gh issue comment "$NUM" -R "$REPO" --body="$BODY"
else
echo "Creating new issue"
gh issue create -R "$REPO" \
--title="$TITLE" \
--label="bug,docker,ci-cd" \
--body="$BODY"
fi
- name: Tear down
# `|| true` so a teardown flake (daemon hiccup, hung container) can't
# flip the job to failure() after the smoke test itself passed —
# which would falsely trigger the auto-issue step. CI runners are
# ephemeral, so any leftover containers/volumes vanish with the
# runner regardless.
if: always()
run: docker compose down -v --remove-orphans || true
+166
View File
@@ -0,0 +1,166 @@
name: Container Security
on:
workflow_call: # Called by release-gate.yml
workflow_dispatch:
permissions: {} # Minimal top-level for OSSF Scorecard Token-Permissions
jobs:
trivy-scan:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
security-events: write
actions: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Free up disk space
run: |
# Remove unnecessary files to free up disk space for Docker image scanning
sudo rm -rf /usr/share/dotnet
sudo rm -rf /usr/local/lib/android
sudo rm -rf /opt/ghc
sudo rm -rf /opt/hostedtoolcache/CodeQL
sudo docker image prune -af
df -h
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Build Docker image for scanning
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
push: false
load: true
tags: local-deep-research:scan
cache-from: type=gha,scope=trivy-scan
cache-to: type=gha,mode=max,scope=trivy-scan
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: local-deep-research:scan
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH,MEDIUM'
ignore-unfixed: true
scan-type: 'image'
trivyignores: '.trivyignore'
version: 'v0.69.2'
- name: Check if SARIF file exists
id: check-sarif
if: always()
run: |
if [ -f trivy-results.sarif ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "::error::Trivy did not produce trivy-results.sarif — scan needs to be rerun"
exit 1
fi
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
if: always() && steps.check-sarif.outputs.exists == 'true'
with:
sarif_file: 'trivy-results.sarif'
category: container-security
- name: Upload Trivy scan results as artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always() && steps.check-sarif.outputs.exists == 'true'
with:
name: trivy-scan-results
path: trivy-results.sarif
retention-days: 7 # Reduced for security
- name: Display Trivy summary
if: always()
run: |
{
echo "## Container Security Scan Summary"
echo ""
if [ -f trivy-results.sarif ]; then
echo "✅ Trivy scan completed - Results available in Security tab"
echo "📊 Scan results uploaded as artifact"
else
echo "❌ Trivy scan failed"
fi
} >> "$GITHUB_STEP_SUMMARY"
# Additional job for Dockerfile security analysis
dockerfile-scan:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
security-events: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Run Trivy config scan on Dockerfile
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: 'config'
scan-ref: '.'
format: 'sarif'
output: 'trivy-config-results.sarif'
hide-progress: true
version: 'v0.69.2'
- name: Check if config SARIF file exists
id: check-config-sarif
if: always()
run: |
if [ -f trivy-config-results.sarif ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "::error::Trivy did not produce trivy-config-results.sarif — scan needs to be rerun"
exit 1
fi
- name: Upload Trivy config scan results
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always() && steps.check-config-sarif.outputs.exists == 'true'
with:
name: trivy-config-results
path: trivy-config-results.sarif
retention-days: 7 # Reduced for security
- name: Display config scan summary
if: always()
run: |
{
echo "## Docker Configuration Security Analysis"
echo ""
if [ -f trivy-config-results.sarif ]; then
echo "✅ Docker configuration analysis completed"
echo "📋 Check for Docker best practices and security misconfigurations"
else
echo "❌ Docker configuration scan failed"
fi
} >> "$GITHUB_STEP_SUMMARY"
+224
View File
@@ -0,0 +1,224 @@
name: 🚨 Danger Zone Alert
on:
pull_request:
types: [opened, synchronize]
paths:
# Explicit paths only — no keyword globs. A filename containing
# "decrypt" or "password" is not enough to imply security impact.
#
# ⚠️ KEEP IN SYNC with two other lists below:
# 1. EXPLICIT_WATCHED array in the "Validate watchlist" step
# (drops glob entries; literal paths only — used for drift detection).
# 2. `case` patterns in the "Classify critical changes" step
# (routes each path to a label bucket).
# A path here that's missing from (2) silently fails to label;
# a path in (2) that's missing here never triggers the workflow.
#
# Encryption / at-rest crypto / credential storage
- 'src/local_deep_research/database/encrypted_db.py'
- 'src/local_deep_research/database/sqlcipher_*.py'
- 'src/local_deep_research/database/credential_store_base.py'
- 'src/local_deep_research/database/backup/**'
# Authentication / sessions / passwords / auth DB / tenant isolation
- 'src/local_deep_research/web/auth/**'
- 'src/local_deep_research/database/auth_db.py'
- 'src/local_deep_research/database/session_passwords.py'
- 'src/local_deep_research/database/temp_auth.py'
- 'src/local_deep_research/database/session_context.py'
# Web hardening — CSP, SSRF, CSRF, rate-limit, headers, validators
- 'src/local_deep_research/security/**'
- 'src/local_deep_research/settings/env_definitions/security.py'
# No concurrency group — intentionally omitted.
# Previous attempt (#3554, reverted #3599) used cancel-in-progress which
# killed in-progress PR runs before they produced useful results.
# Future iteration could safely add concurrency for scheduled/push-only
# triggers (where head_ref is empty and runs get unique groups).
permissions: {} # Minimal top-level for OSSF Scorecard Token-Permissions
jobs:
danger-alert:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
checks: write
issues: write # needed for createLabel (auto-create on first run)
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0
- name: Validate watchlist against repo tree
# Drift-guard: every explicit (non-glob) path below must exist on the
# branch head. Fails the workflow if a watched file has been renamed
# or deleted without updating this list. Glob patterns are excluded
# — they self-heal on rename and would false-positive this check.
# (Tradeoff: if all files matching a glob like `sqlcipher_*.py` are
# deleted, the trigger path silently becomes a no-op. Acceptable —
# disappearing an entire subsystem will be caught in code review.)
#
# ⚠️ KEEP IN SYNC with `on.pull_request.paths` above and the `case`
# patterns in the "Classify critical changes" step below.
run: |
EXPLICIT_WATCHED=(
"src/local_deep_research/database/encrypted_db.py"
"src/local_deep_research/database/credential_store_base.py"
"src/local_deep_research/database/auth_db.py"
"src/local_deep_research/database/session_passwords.py"
"src/local_deep_research/database/temp_auth.py"
"src/local_deep_research/database/session_context.py"
"src/local_deep_research/settings/env_definitions/security.py"
)
missing=0
for p in "${EXPLICIT_WATCHED[@]}"; do
if [ ! -e "$p" ]; then
echo "::error::danger-zone-alert watchlist drift: $p does not exist"
missing=1
fi
done
[ $missing -eq 0 ]
- name: Classify critical changes
id: analyze
env:
BASE_REF: ${{ github.base_ref }}
run: |
# Use env var to prevent template injection from malicious branch names
CHANGED=$(git diff --name-only "origin/$BASE_REF...HEAD")
ENCRYPTION_MODIFIED=false
AUTH_MODIFIED=false
HARDENING_MODIFIED=false
# Shell `case` `*` matches `/` (unlike file globs), so `web/auth/*`
# and `backup/*` cover nested subdirs — equivalent to the `**` in
# GitHub Actions path globs above. Do NOT "fix" these to `**` —
# bash `case` treats `**` as two literal `*`s with no special
# meaning and it would stop matching anything with a slash.
#
# ⚠️ KEEP IN SYNC with `on.pull_request.paths` above and the
# EXPLICIT_WATCHED array in the "Validate watchlist" step above.
for file in $CHANGED; do
case "$file" in
src/local_deep_research/database/encrypted_db.py|\
src/local_deep_research/database/sqlcipher_*.py|\
src/local_deep_research/database/credential_store_base.py|\
src/local_deep_research/database/backup/*)
ENCRYPTION_MODIFIED=true ;;
src/local_deep_research/web/auth/*|\
src/local_deep_research/database/auth_db.py|\
src/local_deep_research/database/session_passwords.py|\
src/local_deep_research/database/temp_auth.py|\
src/local_deep_research/database/session_context.py)
AUTH_MODIFIED=true ;;
src/local_deep_research/security/*|\
src/local_deep_research/settings/env_definitions/security.py)
HARDENING_MODIFIED=true ;;
esac
done
HAS_CRITICAL=false
if [ "$ENCRYPTION_MODIFIED" = "true" ] || [ "$AUTH_MODIFIED" = "true" ] || [ "$HARDENING_MODIFIED" = "true" ]; then
HAS_CRITICAL=true
fi
{
echo "has_critical=$HAS_CRITICAL"
echo "encryption_modified=$ENCRYPTION_MODIFIED"
echo "auth_modified=$AUTH_MODIFIED"
echo "hardening_modified=$HARDENING_MODIFIED"
} >> "$GITHUB_OUTPUT"
- name: Create status check
if: steps.analyze.outputs.has_critical == 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
// Neutral check — advisory, not blocking. Real enforcement, if
// desired, belongs in branch-protection rules, not in a status
// title that misleads reviewers.
try {
await github.rest.checks.create({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'Security Review Required',
head_sha: context.sha,
status: 'completed',
conclusion: 'neutral',
output: {
title: 'Security-sensitive paths modified',
summary: 'This PR modifies critical security code (encryption, authentication, or web hardening). Please review carefully.'
}
});
} catch (err) {
// Fork PRs get a read-only token — checks.create 403s. Log
// and stay green (same pattern as pr-triage.yml); reviewers
// still get the signal from CODEOWNERS on these paths.
if (err.status !== 403) throw err;
console.log('checks.create returned 403 (read-only fork token). Skipping.');
}
- name: Add labels
if: steps.analyze.outputs.has_critical == 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
ENCRYPTION_MODIFIED: ${{ steps.analyze.outputs.encryption_modified }}
AUTH_MODIFIED: ${{ steps.analyze.outputs.auth_modified }}
HARDENING_MODIFIED: ${{ steps.analyze.outputs.hardening_modified }}
with:
script: |
const wanted = [];
if (process.env.ENCRYPTION_MODIFIED === 'true') {
wanted.push({ name: 'touches-encryption', color: 'b60205', description: 'Modifies at-rest crypto, credential storage, or backup encryption' });
}
if (process.env.AUTH_MODIFIED === 'true') {
wanted.push({ name: 'touches-authentication', color: 'd93f0b', description: 'Modifies login, sessions, passwords, auth DB, or tenant isolation' });
}
if (process.env.HARDENING_MODIFIED === 'true') {
wanted.push({ name: 'touches-web-hardening', color: 'fbca04', description: 'Modifies CSP, SSRF, CSRF, rate-limit, headers, validators, or hardening config' });
}
if (wanted.length === 0) return;
// Ensure each label exists before applying (addLabels 422s on unknown labels).
for (const l of wanted) {
try {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: l.name,
color: l.color,
description: l.description,
});
} catch (e) {
// 422 = already exists. 403 = read-only fork token (the
// addLabels call below degrades the same way).
if (e.status !== 422 && e.status !== 403) throw e;
}
}
try {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: wanted.map(l => l.name),
});
} catch (err) {
// Fork PRs get a read-only token — the call 403s. Log and
// stay green (same pattern as pr-triage.yml); a maintainer
// can apply the labels manually on fork PRs.
if (err.status !== 403) throw err;
console.log('Label call returned 403 (read-only fork token). Skipping.');
}
+76
View File
@@ -0,0 +1,76 @@
name: Dependency Review
on:
# NOTE: dependency-review requires PR context to compare changes.
# It remains on PRs only and is NOT included in release gate.
# The osv-scanner and npm-audit in release gate cover dependency vulnerabilities.
pull_request:
branches: [main, develop]
paths:
- 'pyproject.toml'
- 'pdm.lock'
- 'requirements*.txt'
- 'package.json'
- 'package-lock.json'
- 'tests/ui_tests/package.json'
- 'tests/ui_tests/package-lock.json'
workflow_dispatch:
permissions: {} # Minimal top-level for OSSF Scorecard Token-Permissions
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
dependency-review:
name: Dependency Review
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Checkout Repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Dependency Review
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
with:
# Fail on high and critical severity vulnerabilities
fail-on-severity: high
# Allow only known permissive + weak-copyleft licenses (ASF Category A + B)
# All licenses below are commercially usable
# LGPL is excluded per ASF Category X policy
allow-licenses: >-
MIT,
Apache-2.0,
BSD-2-Clause,
BSD-3-Clause,
ISC,
MPL-2.0,
Unlicense,
CC0-1.0,
CC-BY-3.0,
CC-BY-4.0,
Python-2.0,
PSF-2.0,
0BSD,
OFL-1.1,
Zlib,
BlueOak-1.0.0
# Allow specific packages with complex license expressions
# dompurify is dual-licensed (Apache-2.0 OR MPL-2.0) but npm reports complex SPDX
allow-dependencies-licenses: >-
pkg:npm/dompurify
# Comment on PR with review summary
comment-summary-in-pr: on-failure
+152
View File
@@ -0,0 +1,152 @@
name: DevSkim Security Linter
on:
workflow_dispatch:
workflow_call: # Called by release-gate.yml
schedule:
# Run security linter daily at 10 AM UTC
- cron: '0 10 * * *'
permissions: {} # Minimal top-level for OSSF Scorecard Token-Permissions
jobs:
devskim-scan:
name: DevSkim Security Linter
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
security-events: write
actions: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Run DevSkim security linter
uses: microsoft/DevSkim-Action@4b5047945a44163b94642a1cecc0d93a3f428cc6 # v1.0.16
with:
# Scan all source code
directory-to-scan: '.'
# Output SARIF for GitHub Security tab
output-filename: 'devskim-results.sarif'
# Ignore test fixtures and documentation
# - tests/: All test code uses mock data and localhost URLs
# - examples/: Example code with placeholder values
# - docs/: Documentation with example URLs
# - node_modules/: Third-party code
ignore-globs: 'tests/**,examples/**,docs/**,node_modules/**,**/node_modules/**'
# Exclude rules that produce false positives in this codebase:
#
# DS176209 - "Suspicious comment" (TODO/FIXME/HACK)
# These are standard development annotations, not security issues.
# Every codebase has TODO comments for tracking technical debt.
#
# DS162092 - "Hardcoded URL"
# This research tool integrates with legitimate external APIs
# (ArXiv, PubMed, Semantic Scholar, etc.). All URLs are intentional
# service endpoints, not security vulnerabilities.
#
# DS137138 - "Hardcoded credentials"
# Analysis showed 100% of alerts are test fixtures (api_key="test_key",
# password="testpass", etc.). Zero real credentials found. Gitleaks
# handles actual secret detection separately.
#
# DS148264 - "Use cryptographic random"
# Flags ALL Python `random` module usage without context. This codebase
# correctly uses `secrets` for security (session tokens, auth tokens,
# Flask keys) and `random` only for non-security purposes: ML fairness
# shuffling in search strategies, distributed systems jitter in scheduler,
# and session cleanup probability. None involve cryptographic operations.
#
# DS172411 - "setTimeout code injection"
# Flags ALL JavaScript setTimeout() calls. The dangerous pattern is
# setTimeout("string", delay) which can execute arbitrary code. However,
# 100% of usages in this codebase (80+) pass safe function references:
# setTimeout(() => {...}, delay) or setTimeout(functionName, delay).
# No string-based setTimeout usage exists.
#
# DS126858 - "Weak/Broken Hash Algorithm"
# Flags any occurrence of the literal string "sha1". In this codebase
# the only matches are the JSON key name `"sha1"` inside SLSA provenance
# attestations (.github/workflows/prerelease-docker.yml) — that key name
# is *required* by the SLSA in-toto schema for git-commit digests, and
# Git itself uses SHA-1 for commit identifiers. It is not a cryptographic
# choice we make. Legitimate SHA-1 references elsewhere (PBKDF2_HMAC_SHA1,
# HMAC_SHA1 in db_config.py for SQLCipher backwards compatibility) carry
# their own inline `# DevSkim: ignore DS126858` annotations explaining the
# rationale. See .github/SECURITY_ALERTS.md for the full assessment.
#
exclude-rules: 'DS176209,DS162092,DS137138,DS148264,DS172411,DS126858'
- name: Upload DevSkim results to GitHub Security
uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
if: always()
with:
sarif_file: 'devskim-results.sarif'
category: devskim
- name: Upload DevSkim results as artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: devskim-results
path: devskim-results.sarif
retention-days: 7 # Reduced for security
- name: Generate summary
run: |
{
echo "## DevSkim Security Linter Summary"
echo ""
echo "✅ DevSkim security scan completed"
echo ""
echo "### What was scanned:"
echo "- 🔍 All source code in the repository"
echo "- 🛡️ Common security issues and anti-patterns"
echo "- 📋 Insecure coding practices"
echo "- 🔑 Hardcoded secrets and credentials"
echo ""
echo "### Security checks include:"
echo "- **Hardcoded Credentials**: Passwords, API keys, tokens"
echo "- **Insecure Functions**: Dangerous API usage"
echo "- **Buffer Overflows**: Memory safety issues"
echo "- **Crypto Issues**: Weak encryption practices"
echo "- **Network Security**: Insecure protocols and configurations"
echo "- **File System**: Insecure file operations"
echo "- **Input Validation**: Missing input sanitization"
echo ""
echo "### Language Support:"
echo "- 🐍 Python (.py files)"
echo "- 📜 JavaScript/TypeScript (.js, .ts files)"
echo "- 🖥️ C/C++ (.c, .cpp, .h files)"
echo "- 🔷 C# (.cs files)"
echo "- 📝 More languages supported"
echo ""
echo "### Why DevSkim?"
echo "- **Microsoft Maintained**: Backed by Microsoft security team"
echo "- **Truly Free**: No registration, no API keys, no upsells"
echo "- **Fast & Lightweight**: Quick scans with minimal overhead"
echo "- **Comprehensive Rules**: 200+ security rules"
echo "- **Open Source**: Community-driven rule improvements"
echo ""
echo "📊 **Results uploaded to:**"
echo "- GitHub Security tab (if issues found)"
echo "- Workflow artifacts (SARIF files)"
echo ""
echo "🔗 **Learn more about DevSkim:**"
echo "- [DevSkim GitHub](https://github.com/microsoft/DevSkim)"
echo "- [DevSkim Documentation](https://microsoft.github.io/DevSkim/)"
echo "- [DevSkim Rules](https://github.com/microsoft/DevSkim/tree/main/rules)"
} >> "$GITHUB_STEP_SUMMARY"
+156
View File
@@ -0,0 +1,156 @@
name: Test Multi-Architecture Docker Build
on:
workflow_call:
workflow_dispatch:
permissions:
contents: read
jobs:
test-multiarch-build:
name: Test Multi-Arch Docker Build
strategy:
matrix:
include:
- platform: linux/amd64
runs-on: ubuntu-latest
platform_slug: linux-amd64
- platform: linux/arm64
runs-on: ubuntu-24.04-arm
platform_slug: linux-arm64
runs-on: ${{ matrix.runs-on }}
timeout-minutes: 60
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Check out the repo
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Build Docker image for ${{ matrix.platform }}
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
platforms: ${{ matrix.platform }}
push: false
load: false
cache-from: type=gha,scope=${{ matrix.platform_slug }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform_slug }}
tags: ldr-test:${{ matrix.platform_slug }}-${{ github.sha }}
outputs: type=docker,dest=/tmp/image-${{ matrix.platform_slug }}.tar
- name: Inspect built image
run: |
# Load and inspect the image
docker load < "/tmp/image-${{ matrix.platform_slug }}.tar"
docker image inspect ldr-test:${{ matrix.platform_slug }}-${{ github.sha }}
# Get image size
IMAGE_SIZE=$(docker image inspect ldr-test:${{ matrix.platform_slug }}-${{ github.sha }} --format='{{.Size}}' | numfmt --to=iec)
echo "Image size for ${{ matrix.platform }}: $IMAGE_SIZE"
- name: Test image startup
run: |
docker load < "/tmp/image-${{ matrix.platform_slug }}.tar"
# Test that the container can start and Python works
docker run --rm --platform ${{ matrix.platform }} \
ldr-test:${{ matrix.platform_slug }}-${{ github.sha }} \
python -c "import sys; print(f'Python {sys.version} on {sys.platform}')"
# Test that the LDR package is installed
docker run --rm --platform ${{ matrix.platform }} \
ldr-test:${{ matrix.platform_slug }}-${{ github.sha }} \
python -c "import local_deep_research; print('LDR package imported successfully')"
- name: Test web server startup
run: |
docker load < "/tmp/image-${{ matrix.platform_slug }}.tar"
# Start the container in background
docker run -d --name ldr-test \
--platform ${{ matrix.platform }} \
-p 5000:5000 \
-e LDR_DB_CONFIG_KDF_ITERATIONS=1000 \
-e LDR_TEST_MODE=1 \
-e LDR_NEWS_SCHEDULER_ENABLED=false \
ldr-test:${{ matrix.platform_slug }}-${{ github.sha }}
# Wait for server to start
TIMEOUT_SECONDS=60
echo "Waiting for server to start (timeout: ${TIMEOUT_SECONDS}s)..."
for i in $(seq 1 ${TIMEOUT_SECONDS}); do
if curl -fsS --connect-timeout 2 --max-time 5 http://localhost:5000/api/v1/health 2>/dev/null; then
echo "✅ Server started successfully after ${i} seconds on ${{ matrix.platform }}!"
break
fi
# Show progress every 10 seconds
if [ $((i % 10)) -eq 0 ]; then
echo "Still waiting... (${i}s elapsed)"
fi
if [ "$i" -eq "${TIMEOUT_SECONDS}" ]; then
echo "❌ Server failed to start within ${TIMEOUT_SECONDS} seconds"
docker logs ldr-test
exit 1
fi
sleep 1
done
# Check server response
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:5000)
echo "Server response code: $RESPONSE"
# Clean up
docker stop ldr-test
docker rm ldr-test
summary:
name: Build Summary
needs: test-multiarch-build
runs-on: ubuntu-latest
timeout-minutes: 5
if: always()
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Summary
env:
BUILD_RESULT: ${{ needs.test-multiarch-build.result }}
run: |
{
echo "## Multi-Architecture Build Test Results"
echo ""
} >> "$GITHUB_STEP_SUMMARY"
if [ "$BUILD_RESULT" == "success" ]; then
{
echo "✅ **All platforms built and tested successfully!**"
echo ""
echo "### Tested Platforms:"
echo "- linux/amd64 ✅"
echo "- linux/arm64 ✅"
echo ""
echo "The Docker image is ready for multi-architecture deployment."
} >> "$GITHUB_STEP_SUMMARY"
else
{
echo "❌ **Build or tests failed**"
echo ""
echo "Please check the logs above for details."
} >> "$GITHUB_STEP_SUMMARY"
fi
+334
View File
@@ -0,0 +1,334 @@
name: Publish Docker image
# SECURITY: Only invoked as a reusable workflow (`workflow_call`) from
# release.yml after the release security gate AND the `release` environment
# approval pass. We intentionally do NOT support workflow_dispatch — that
# would bypass gates. We also intentionally do NOT support
# repository_dispatch any more: with the atomicity refactor, this workflow
# runs as a job inside release.yml's run so its result is visible to
# downstream jobs (create-release, cleanup-on-rejection) — a property that
# repository_dispatch fanout broke.
#
# This workflow is a thin RETAG step in the build-once-promote pipeline.
# The actual build happens in prerelease-docker.yml; the multi-arch manifest
# is signed and attested there. Here we only:
# - Verify the source manifest digest matches what prerelease produced
# - Retag the prerelease manifest to release tags (:1.6.9, :1.6, :latest)
# - Verify the digest is preserved (defends against imagetools re-encoding)
# - Re-run Trivy against the digest (catches CVE-database updates between
# prerelease build and release promote)
# - Verify cosign signature transitivity from the original digest
# - Clean up the prerelease tags
#
# To re-publish, trigger a new release through release.yml.
on:
workflow_call:
inputs:
tag:
description: "Release tag, e.g. 'v1.6.9' (with leading 'v')"
type: string
required: true
source_tag:
description: "Prerelease manifest tag to retag, e.g. 'prerelease-v1.6.9-abc1234'"
type: string
required: true
expected_digest:
description: "sha256:... digest of the prerelease manifest, captured by prerelease-docker.yml. Used to verify retag preserves the digest end-to-end."
type: string
required: true
secrets:
DOCKER_USERNAME:
required: true
description: "Docker Hub username (env-scoped to `release`)"
DOCKER_PASSWORD:
required: true
description: "Docker Hub PAT with Read+Write+Delete scopes (env-scoped to `release`)"
permissions: {} # Minimal top-level for OSSF Scorecard Token-Permissions
# NOTE: no workflow-level `concurrency:` block. As a reusable workflow
# called from release.yml, this workflow runs as part of the caller's run,
# and release.yml's caller-level concurrency (keyed on github.workflow +
# github.ref) already serialises release runs for the same tag. Adding a
# callee-level block would be a no-op for the documented threat model
# (two simultaneous releases for the same ref) and would not be reachable
# anyway because there is no longer any standalone invocation path.
jobs:
promote:
name: Retag prerelease manifest as release
runs-on: ubuntu-latest
environment: release
permissions:
# cosign verify is read-only against public Rekor/Fulcio — does not
# mint a GitHub OIDC token, so id-token: write is not required here.
# Signing (which does need id-token: write) happens in prerelease-docker.yml.
contents: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Check out the repo at the triggering commit
# Pin the checkout to the EXACT commit that the prerelease was
# built/scanned from, so .trivyignore (and any other repo-state-
# dependent file the promote step reads) matches that commit.
# We use github.sha, NOT inputs.tag — the v* git tag is created
# by create-release LATER in this run (after publish-docker
# completes), so it does not exist yet when this checkout runs
# on a push-to-main trigger. github.sha is the triggering commit
# for every event type (push to main, tag push, workflow_dispatch)
# and is the same SHA the build/prerelease-docker jobs used.
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.sha }}
persist-credentials: false
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log in to Docker Hub
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
# Pin to cosign v2.x to match the version that signed the artifact
# in prerelease-docker.yml. Mismatched versions across sign/verify
# work today but new-bundle-format (cosign v3 default) would only
# produce/consume on v3.
cosign-release: 'v2.6.3'
- name: Determine release tags
id: version
env:
DISPATCH_TAG: ${{ inputs.tag }}
SOURCE_TAG: ${{ inputs.source_tag }}
EXPECTED_DIGEST: ${{ inputs.expected_digest }}
run: |
set -euo pipefail
if [[ -z "$DISPATCH_TAG" || -z "$SOURCE_TAG" || -z "$EXPECTED_DIGEST" ]]; then
echo "::error::Missing required workflow_call input. Got tag='${DISPATCH_TAG}' source_tag='${SOURCE_TAG}' expected_digest='${EXPECTED_DIGEST}'"
exit 1
fi
if [[ "$EXPECTED_DIGEST" != sha256:* ]]; then
echo "::error::expected_digest must be of the form sha256:... — got '${EXPECTED_DIGEST}'"
exit 1
fi
VERSION="${DISPATCH_TAG#v}"
MAJOR_MINOR=$(echo "$VERSION" | cut -d. -f1,2)
# Group writes into one redirect (shellcheck SC2129).
{
echo "tag=${DISPATCH_TAG}"
echo "version=${VERSION}"
echo "major_minor=${MAJOR_MINOR}"
echo "source_tag=${SOURCE_TAG}"
echo "expected_digest=${EXPECTED_DIGEST}"
} >> "$GITHUB_OUTPUT"
- name: Verify source digest matches expected
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
SOURCE_TAG: ${{ steps.version.outputs.source_tag }}
EXPECTED_DIGEST: ${{ steps.version.outputs.expected_digest }}
run: |
set -euo pipefail
# Defends against the prerelease tag being swapped between
# prerelease-docker's signing and this promote step.
SOURCE="${DOCKER_USERNAME}/local-deep-research:${SOURCE_TAG}"
ACTUAL=$(docker buildx imagetools inspect "$SOURCE" --format '{{json .Manifest.Digest}}' | tr -d '"')
if [[ "$ACTUAL" != "$EXPECTED_DIGEST" ]]; then
echo "::error::Source digest mismatch — possible tag tampering between prerelease and promote"
echo " expected: $EXPECTED_DIGEST"
echo " actual: $ACTUAL"
exit 1
fi
echo "Source digest verified: $ACTUAL"
- name: Promote (retag) prerelease manifest to release tags
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
SOURCE_TAG: ${{ steps.version.outputs.source_tag }}
VERSION: ${{ steps.version.outputs.version }}
MAJOR_MINOR: ${{ steps.version.outputs.major_minor }}
run: |
set -euo pipefail
SOURCE="${DOCKER_USERNAME}/local-deep-research:${SOURCE_TAG}"
# Single imagetools create with multiple -t — registry-side
# metadata-only operation, takes seconds, preserves digest.
docker buildx imagetools create \
-t "${DOCKER_USERNAME}/local-deep-research:${VERSION}" \
-t "${DOCKER_USERNAME}/local-deep-research:${MAJOR_MINOR}" \
-t "${DOCKER_USERNAME}/local-deep-research:latest" \
"$SOURCE"
echo "Promoted ${SOURCE} to :${VERSION}, :${MAJOR_MINOR}, :latest"
- name: Verify promoted tags share the source digest
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
VERSION: ${{ steps.version.outputs.version }}
MAJOR_MINOR: ${{ steps.version.outputs.major_minor }}
EXPECTED_DIGEST: ${{ steps.version.outputs.expected_digest }}
run: |
set -euo pipefail
# Defends against `imagetools create` re-encoding the manifest.
# If digests diverge, signatures and attestations (keyed by the
# original digest) won't be discoverable from the new tags.
for TAG in "${VERSION}" "${MAJOR_MINOR}" "latest"; do
REF="${DOCKER_USERNAME}/local-deep-research:${TAG}"
ACTUAL=$(docker buildx imagetools inspect "$REF" --format '{{json .Manifest.Digest}}' | tr -d '"')
if [[ "$ACTUAL" != "$EXPECTED_DIGEST" ]]; then
echo "::error::Digest mismatch on ${TAG} — imagetools create may have re-encoded the manifest"
echo " expected: $EXPECTED_DIGEST"
echo " actual: $ACTUAL"
exit 1
fi
echo "${TAG} -> ${ACTUAL} ✓"
done
# Catches CVE-database updates that landed between the prerelease
# build and this promote step. Use the SHA-pinned action wrapper
# (same pin as prerelease-docker.yml's security-scan) with an
# explicit binary version pin — the prior `apt-get install -y trivy`
# approach was unpinned and exposed the release path to the Trivy
# apt-repo supply chain. The pinned action downloads the v0.69.2
# binary from GitHub releases by exact tag, which is the same
# binary the prerelease scan validated, keeping the two scans
# consistent.
#
# Unlike prerelease-docker.yml's security-scan (which scans a
# locally-loaded image, no registry pull), this step scans by
# registry digest — Trivy must pull the manifest + layers from
# Docker Hub. TRIVY_USERNAME/TRIVY_PASSWORD is the action's
# documented auth path; the `docker/login-action` above also
# writes ~/.docker/config.json which Trivy reads as a fallback,
# but the explicit env vars are more reliable (Trivy has
# documented docker.io credential-helper quirks — aquasecurity/
# trivy#432, aquasecurity/trivy#8385) and the image we scan IS on
# Docker Hub so this is the path most likely to keep working.
- name: Re-scan release digest with Trivy
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
env:
TRIVY_USERNAME: ${{ secrets.DOCKER_USERNAME }}
TRIVY_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
with:
image-ref: ${{ secrets.DOCKER_USERNAME }}/local-deep-research@${{ steps.version.outputs.expected_digest }}
severity: 'CRITICAL,HIGH'
ignore-unfixed: true
trivyignores: '.trivyignore'
exit-code: '1'
version: 'v0.69.2'
- name: Verify cosign signature on promoted digest
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
EXPECTED_DIGEST: ${{ steps.version.outputs.expected_digest }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
# The cert was issued to the prerelease-docker.yml workflow when
# signing happened there, so the identity regex must match that
# workflow's path. Fulcio's SAN is built from job_workflow_ref,
# which for reusable workflows is the CALLEE.
#
# Verify by IMMUTABLE digest (not the :VERSION tag) so this step
# is invariant under any retag race between the verify-promoted-
# tags step above and this one. Trivy's re-scan above also uses
# @${EXPECTED_DIGEST}; keeping cosign on the same reference is
# consistent and avoids a tag-resolution TOCTOU window.
IMAGE_REF="${DOCKER_USERNAME}/local-deep-research@${EXPECTED_DIGEST}"
echo "Verifying signature for: $IMAGE_REF"
cosign verify \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
--certificate-identity-regexp "^https://github.com/${REPO}/\.github/workflows/prerelease-docker\.yml@refs/(heads|tags)/" \
--certificate-github-workflow-repository "${REPO}" \
"$IMAGE_REF"
echo "Signature transitivity verified ✓"
- name: Clean up prerelease tags
continue-on-error: true
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
RELEASE_VERSION: ${{ steps.version.outputs.version }}
run: |
set +e # Best-effort: never fail the workflow
# Scope deletion to prereleases of THIS version only, so concurrent
# prereleases for other versions (and any unrelated prerelease-* tags)
# are left untouched.
PREFIX="prerelease-v${RELEASE_VERSION}-"
echo "Cleaning up ${PREFIX}* tags from Docker Hub..."
# Authenticate with Docker Hub API (password-based JWT)
TOKEN=$(curl -s -X POST "https://hub.docker.com/v2/users/login/" \
-H "Content-Type: application/json" \
-d "{\"username\": \"${DOCKER_USERNAME}\", \"password\": \"${DOCKER_PASSWORD}\"}" | jq -r '.token')
if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
echo "WARNING: Failed to authenticate with Docker Hub API. Skipping cleanup."
exit 0
fi
REPO="${DOCKER_USERNAME}/local-deep-research"
PAGE=1
DELETED=0
while true; do
RESPONSE=$(curl -s -H "Authorization: JWT ${TOKEN}" \
"https://hub.docker.com/v2/repositories/${REPO}/tags/?page=${PAGE}&page_size=100")
RESULTS=$(echo "$RESPONSE" | jq -r '.results[]?.name // empty')
if [ -z "$RESULTS" ]; then
break
fi
while IFS= read -r tag; do
if [[ "$tag" == "${PREFIX}"* ]]; then
echo "Deleting tag: ${tag}"
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \
-H "Authorization: JWT ${TOKEN}" \
"https://hub.docker.com/v2/repositories/${REPO}/tags/${tag}/")
if [ "$HTTP_CODE" -eq 204 ] || [ "$HTTP_CODE" -eq 200 ]; then
echo " Deleted: ${tag}"
DELETED=$((DELETED + 1))
else
echo " WARNING: Failed to delete ${tag} (HTTP ${HTTP_CODE})"
fi
fi
done <<< "$RESULTS"
# Check if there are more pages
NEXT=$(echo "$RESPONSE" | jq -r '.next // empty')
if [ -z "$NEXT" ]; then
break
fi
PAGE=$((PAGE + 1))
done
echo "Prerelease tag cleanup complete. Deleted ${DELETED} tag(s) matching ${PREFIX}*."
- name: Summary
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
VERSION: ${{ steps.version.outputs.version }}
MAJOR_MINOR: ${{ steps.version.outputs.major_minor }}
EXPECTED_DIGEST: ${{ steps.version.outputs.expected_digest }}
run: |
{
echo "## Docker Release Promoted"
echo ""
echo "**Digest:** \`${EXPECTED_DIGEST}\`"
echo ""
echo "**Tags:** \`${VERSION}\`, \`${MAJOR_MINOR}\`, \`latest\` — all share the same digest as the prerelease manifest, so cosign signatures, SBOM, and SLSA provenance from the prerelease step are transitively valid."
echo ""
echo '```'
echo "docker pull ${DOCKER_USERNAME}/local-deep-research:${VERSION}"
echo "docker pull ${DOCKER_USERNAME}/local-deep-research:latest"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
File diff suppressed because it is too large Load Diff
+211
View File
@@ -0,0 +1,211 @@
name: Dockle Container Security Linting
on:
workflow_dispatch:
workflow_call: # Called by release-gate.yml
schedule:
# Run weekly on Tuesday at 10 AM UTC (staggered with other container scans)
- cron: '0 10 * * 2'
permissions: {} # Minimal top-level for OSSF Scorecard Token-Permissions
jobs:
dockle:
name: Dockle Container Image Security
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
security-events: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Build Docker image for scanning
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
push: false
load: true
tags: local-deep-research:dockle-scan
cache-from: type=gha,scope=dockle-scan
cache-to: type=gha,mode=max,scope=dockle-scan
- name: Run Dockle security scan
id: dockle
run: |
echo "=== Running Dockle Container Image Security Scan ==="
# Run Dockle with SARIF output for GitHub Security tab
# Mount current directory to /output so SARIF file is written to host filesystem
# Exit code 1 means issues found, but we don't fail the workflow
#
# Ignored checks (false positives or intentional design):
# - CIS-DI-0001: Create a user - image uses setpriv entrypoint pattern for privilege drop
# (see scripts/ldr_entrypoint.sh which uses setpriv to switch to ldruser)
# - CIS-DI-0005: Enable Content trust for Docker - runtime env var (DOCKER_CONTENT_TRUST),
# not a Dockerfile concern; enforced at deployment time
# - CIS-DI-0008: setuid/setgid files - unix_chkpwd is standard Debian base utility
# required for PAM authentication, inherited from python:3.14-slim base image
# - DKL-DI-0005: Clear apt-get caches - already done in Dockerfile (rm -rf /var/lib/apt/lists/*),
# but base image (python:3.14-slim) layers trigger this check
#
# Accepted files (false positives):
# - unix_chkpwd: PAM utility from Debian base image
# - settings.py: Any file named settings.py (config files, not credentials)
# Without --exit-code flag, Dockle always exits 0 regardless of findings.
# Findings are reported via SARIF and enforced by the release gate
# (check-code-scanning-alerts job in release-gate.yml).
# Any non-zero exit code here means Docker itself failed (image pull error,
# socket unavailable, etc.) — a broken scanner that MUST fail the step,
# otherwise no SARIF is produced and the release gate has nothing to check.
set +e
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$(pwd):/output" \
goodwithtech/dockle:v0.4.14 \
--format sarif \
--output /output/dockle-results.sarif \
--ignore CIS-DI-0001 \
--ignore CIS-DI-0005 \
--ignore CIS-DI-0008 \
--ignore DKL-DI-0005 \
--accept-file "usr/sbin/unix_chkpwd" \
--accept-file "settings.py" \
local-deep-research:dockle-scan
DOCKLE_EXIT_CODE=$?
set -e
if [ "$DOCKLE_EXIT_CODE" -ne 0 ]; then
echo "::error::Dockle/Docker failed with exit code $DOCKLE_EXIT_CODE"
exit 1
fi
# Check if SARIF file was created (for use in subsequent steps)
if [ -f "dockle-results.sarif" ]; then
echo "sarif_exists=true" >> "$GITHUB_OUTPUT"
# Check if SARIF contains any findings (used by summary step)
RESULT_COUNT=$(python3 -c "import json; print(len(json.load(open('dockle-results.sarif')).get('runs',[{}])[0].get('results',[])))" 2>/dev/null || echo "0")
if [ "$RESULT_COUNT" -gt 0 ]; then
echo "DOCKLE_FOUND_ISSUES=true" >> "$GITHUB_ENV"
fi
fi
- name: Run Dockle with human-readable output
if: always()
run: |
echo "=== Dockle Scan Results ==="
# Use same ignore flags as SARIF scan for consistent output
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
goodwithtech/dockle:v0.4.14 \
--format list \
--ignore CIS-DI-0001 \
--ignore CIS-DI-0005 \
--ignore CIS-DI-0008 \
--ignore DKL-DI-0005 \
--accept-file "usr/sbin/unix_chkpwd" \
--accept-file "settings.py" \
local-deep-research:dockle-scan || true
- name: Upload Dockle results to GitHub Security tab
uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
# Note: hashFiles() is evaluated at workflow parse time, not runtime.
# Use step output instead to check if SARIF file was created during the run.
if: always() && steps.dockle.outputs.sarif_exists == 'true'
with:
sarif_file: dockle-results.sarif
category: dockle
- name: Upload Dockle results as artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always() && steps.dockle.outputs.sarif_exists == 'true'
with:
name: dockle-results
path: dockle-results.sarif
retention-days: 7 # Reduced for security
- name: Display Dockle summary
if: always()
run: |
{
echo "## 🐋 Dockle Container Image Security Summary"
echo ""
echo "### What is Dockle?"
echo "Dockle is a container image linting tool that checks for:"
echo ""
echo "#### 🎯 Security Checks:"
echo "- **Sensitive Files**: Keys, certificates, passwords in image"
echo "- **Root User**: Warning if running as root"
echo "- **Clear-text Passwords**: Detects plaintext secrets"
echo "- **CIS Benchmarks**: Container security best practices"
echo "- **Outdated Packages**: Vulnerable base images"
echo ""
echo "#### 📊 Dockle vs Hadolint:"
echo "- **Hadolint**: Checks Dockerfile source code (best practices)"
echo "- **Dockle**: Scans built container image (runtime security)"
echo "- **Both Complement**: Check different stages of container lifecycle"
echo ""
echo "#### 🔍 Scan Results:"
} >> "$GITHUB_STEP_SUMMARY"
if [ "$DOCKLE_FOUND_ISSUES" = "true" ]; then
{
echo "⚠️ **Issues Found**"
echo ""
echo "Dockle detected security or best practice issues:"
echo ""
echo "**Common Issues:**"
echo "- Running as root user (consider USER directive)"
echo "- Sensitive files potentially included in image"
echo "- Missing security labels or metadata"
echo "- Outdated base image packages"
echo ""
echo "📋 **Detailed Results:**"
echo "- GitHub Security tab: SARIF results uploaded"
echo "- Artifacts: Full SARIF report available"
echo "- Check output above for human-readable format"
} >> "$GITHUB_STEP_SUMMARY"
else
{
echo "✅ **No Issues Found!**"
echo ""
echo "Container image passed all Dockle security checks."
} >> "$GITHUB_STEP_SUMMARY"
fi
{
echo ""
echo "#### 🔗 Resources:"
echo "- [Dockle GitHub](https://github.com/goodwithtech/dockle)"
echo "- [CIS Docker Benchmark](https://www.cisecurity.org/benchmark/docker)"
echo "- [Security Tab](https://github.com/${{ github.repository }}/security/code-scanning)"
echo ""
echo "#### 💡 Best Practices:"
echo "- Use multi-stage builds to reduce image size"
echo "- Add USER directive to run as non-root"
echo "- Don't include secrets or keys in image"
echo "- Keep base images updated"
echo "- Scan images with both Dockle (runtime) and Hadolint (build)"
echo ""
echo "#### 🔇 Ignored Checks (false positives or intentional design):"
echo "- **CIS-DI-0001** (Create a user): Uses setpriv entrypoint pattern for privilege drop"
echo "- **CIS-DI-0005** (Content trust): Runtime env var, not Dockerfile concern"
echo "- **CIS-DI-0008** (setuid/setgid): unix_chkpwd from Debian base, required for PAM"
echo "- **DKL-DI-0005** (apt-get cache): Dockerfile clears caches, base image triggers this"
echo "- **settings.py**: Any file named settings.py accepted (config files, not credentials)"
} >> "$GITHUB_STEP_SUMMARY"
- name: Clean up Docker image
if: always()
run: |
docker rmi local-deep-research:dockle-scan || true
+201
View File
@@ -0,0 +1,201 @@
name: E2E Research Test
on:
pull_request:
types: [labeled]
# No concurrency group — intentionally omitted.
# Previous attempt (#3554, reverted #3599) used cancel-in-progress which
# killed in-progress PR runs before they produced useful results.
# Future iteration could safely add concurrency for scheduled/push-only
# triggers (where head_ref is empty and runs get unique groups).
permissions: {} # Minimal top-level for OSSF Scorecard
jobs:
build-query:
name: Assemble query
if: github.event.label.name == 'ldr_research' || github.event.label.name == 'ldr_research_static'
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
query: ${{ steps.assemble.outputs.query }}
header: ${{ steps.headers.outputs.header }}
subheader: ${{ steps.headers.outputs.subheader }}
env:
LABEL_NAME: ${{ github.event.label.name }}
MAX_DIFF_SIZE: ${{ vars.MAX_DIFF_SIZE || '8000' }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0
- name: Build PR-research query
id: assemble
run: |
if [ "$LABEL_NAME" = "ldr_research_static" ]; then
cat > query.txt <<'STATIC_EOF'
What is Local Deep Research and how does it work?
STATIC_EOF
else
git fetch origin "$BASE_REF"
git diff "origin/$BASE_REF...HEAD" --no-color > diff.txt
DIFF_BYTES=$(wc -c < diff.txt)
echo "Diff size: $DIFF_BYTES bytes (cap: $MAX_DIFF_SIZE)"
head -c "$MAX_DIFF_SIZE" diff.txt > diff-trunc.txt
if [ "$DIFF_BYTES" -gt "$MAX_DIFF_SIZE" ]; then
printf '\n... (truncated)\n' >> diff-trunc.txt
fi
{
cat <<'PROMPT_HEAD_EOF'
Based on these code changes, research relevant documentation,
best practices, and potential issues. Focus on any libraries, APIs, or patterns used.
Code changes:
PROMPT_HEAD_EOF
cat diff-trunc.txt
cat <<'PROMPT_TAIL_EOF'
Research topics to cover:
1. Documentation for any libraries or APIs being used/modified
2. Best practices for the patterns shown
3. Known issues or gotchas related to these changes
4. Security considerations if applicable
PROMPT_TAIL_EOF
} > query.txt
fi
# Multi-line GH Actions output via heredoc-style delimiter.
# Use a randomized delimiter so a query containing the literal
# delimiter on its own line cannot prematurely terminate the
# heredoc.
DELIM="LDR_QUERY_EOF_$$_${RANDOM}_$(date +%N)"
{
echo "query<<$DELIM"
cat query.txt
echo "$DELIM"
} >> "$GITHUB_OUTPUT"
- name: Compute headers
id: headers
run: |
if [ "$LABEL_NAME" = "ldr_research_static" ]; then
echo "header=## 🧪 LDR Static Query Test Results" >> "$GITHUB_OUTPUT"
echo "subheader=**Query:** What is Local Deep Research and how does it work?" >> "$GITHUB_OUTPUT"
else
echo "header=## 🔬 LDR Research Results" >> "$GITHUB_OUTPUT"
echo "subheader=_Analysis of PR code changes_" >> "$GITHUB_OUTPUT"
fi
research:
name: Run LDR research
needs: build-query
# Grant the perms the reusable's job needs:
# - contents: read for actions/checkout
# - actions: write for actions/upload-artifact@v5+
# Without this, the reusable's `permissions: contents: read` exceeds
# the inherited empty permissions and GitHub rejects the workflow at
# load time (startup_failure with zero jobs).
permissions:
contents: read
actions: write
uses: ./.github/workflows/ldr-research-reusable.yml
with:
query: ${{ needs.build-query.outputs.query }}
model: ${{ github.event.label.name == 'ldr_research_static' && (vars.LDR_RESEARCH_CHEAP_MODEL || 'google/gemini-2.0-flash-001') || (vars.LDR_RESEARCH_MODEL || 'google/gemini-2.0-flash-001') }}
provider: ${{ vars.LDR_PROVIDER || 'openrouter' }}
search-tool: ${{ vars.LDR_SEARCH_TOOL || 'serper' }}
strategy: ${{ vars.LDR_STRATEGY || 'langgraph-agent' }}
comment-header: ${{ needs.build-query.outputs.header }}
comment-subheader: ${{ needs.build-query.outputs.subheader }}
secrets:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
SERPER_API_KEY: ${{ secrets.SERPER_API_KEY }}
post-comment:
name: Post PR comment
needs: research
# always() so the label-removal step runs even if research was skipped
# (e.g. build-query failed). The download/post steps are guarded by
# needs.research.outputs.success == 'true' so they self-skip in that
# case.
if: always()
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
issues: write
env:
LABEL_NAME: ${{ github.event.label.name }}
PR_NUMBER: ${{ github.event.pull_request.number }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Download research artifact
if: needs.research.outputs.success == 'true'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ needs.research.outputs.comment-artifact-name }}
- name: Post or update comment
if: needs.research.outputs.success == 'true'
continue-on-error: true # a GitHub API hiccup shouldn't fail the job
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
retries: 3
script: |
const fs = require('fs');
const issue_number = Number(process.env.PR_NUMBER);
// Key the marker by label so re-running the same label edits its
// comment in place, while the other label keeps a separate comment.
const marker = `<!-- ldr-research-results:${process.env.LABEL_NAME} -->`;
const body = marker + '\n' + fs.readFileSync('comment.md', 'utf8');
// Paginate — on a long-lived PR the existing comment may be past the
// first page (listComments defaults to 30), which would otherwise
// create a new comment every run instead of editing in place.
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number,
per_page: 100,
});
const existing = comments.find(
(c) => c.user.type === 'Bot' && c.body.includes(marker)
);
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number,
body,
});
}
- name: Remove label for re-triggering
if: always()
run: |
gh issue edit "$PR_NUMBER" --remove-label "$LABEL_NAME" 2>/dev/null || true
@@ -0,0 +1,47 @@
name: File Whitelist Security Check
# Enhanced security checks with comprehensive file type detection
on:
pull_request:
branches: [ main ]
workflow_call: # Called by ci-gate.yml for release pipeline
inputs:
check-all-files:
description: 'Check ALL tracked files (not just changed files)'
required: false
type: boolean
default: true
workflow_dispatch:
# No concurrency group — intentionally omitted.
# This workflow triggers on both pull_request and workflow_call (from
# ci-gate.yml / release-gate.yml). A shared concurrency key would cause
# direct PR runs and workflow_call runs to cancel each other mid-flight.
# See #3554 (reverted in #3599) for context.
permissions:
contents: read
jobs:
whitelist-check:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0
- name: Security checks for files
env:
GITHUB_EVENT_NAME: ${{ github.event_name }}
GITHUB_BASE_REF: ${{ github.base_ref }}
CHECK_ALL_FILES: ${{ inputs.check-all-files }}
run: |
.github/scripts/file-whitelist-check.sh
+82
View File
@@ -0,0 +1,82 @@
name: Fuzzing Tests
on:
schedule:
- cron: '0 0 * * 0' # Weekly on Sunday at midnight
workflow_dispatch: # Allow manual triggering
pull_request:
paths:
- 'tests/fuzz/**'
- 'src/local_deep_research/security/**'
- 'src/local_deep_research/utilities/**'
# No concurrency group — intentionally omitted.
# Previous attempt (#3554, reverted #3599) used cancel-in-progress which
# killed in-progress PR runs before they produced useful results.
# Future iteration could safely add concurrency for scheduled/push-only
# triggers (where head_ref is empty and runs get unique groups).
permissions: {}
jobs:
fuzz-tests:
name: Hypothesis Fuzz Tests
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.12'
- name: Cache PDM dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cache/pdm
.venv
key: pdm-${{ runner.os }}-${{ hashFiles('pdm.lock') }}
restore-keys: |
pdm-${{ runner.os }}-
- name: Install dependencies
run: |
python -m pip install --upgrade pip==25.0
pip install pdm==2.26.2
# Retry pdm install up to 3 times with backoff
for i in 1 2 3; do
pdm install --dev --no-editable && break
echo "Attempt $i failed, retrying in $((i * 10)) seconds..."
sleep $((i * 10))
done || exit 1
- name: Run fuzz tests
run: |
pdm run pytest tests/fuzz/ -v --tb=short \
--hypothesis-show-statistics \
--hypothesis-seed=0
env:
HYPOTHESIS_PROFILE: ci
- name: Run extended fuzz tests (scheduled only)
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
run: |
# Run with more examples for scheduled/manual runs
pdm run pytest tests/fuzz/ -v --tb=short \
--hypothesis-show-statistics \
-x # Stop on first failure for investigation
env:
HYPOTHESIS_PROFILE: extended
+55
View File
@@ -0,0 +1,55 @@
name: Gitleaks Main Branch Scan
# This workflow is specifically for the security release gate.
# It only scans the main branch history to avoid false positives
# from feature branches with intentional test data.
on:
workflow_call: # Called by release-gate.yml
workflow_dispatch:
permissions: {} # Minimal top-level for OSSF Scorecard Token-Permissions
jobs:
gitleaks-scan:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
security-events: write
actions: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0
ref: main # Always checkout main branch
- name: Install Gitleaks
run: |
curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz | tar -xz
sudo mv gitleaks /usr/local/bin/
- name: Run Gitleaks on main branch only
run: |
gitleaks detect \
--config=.gitleaks.toml \
--gitleaks-ignore-path=.gitleaksignore \
--log-opts="origin/main" \
--report-format=sarif \
--report-path=gitleaks-results.sarif \
--verbose
id: gitleaks
- name: Upload SARIF report
if: always()
uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
with:
sarif_file: gitleaks-results.sarif
+46
View File
@@ -0,0 +1,46 @@
name: Gitleaks Secret Detection
on:
pull_request:
branches: [ main ]
workflow_dispatch:
schedule:
# Run secret scan daily at 3 AM UTC
- cron: '0 3 * * *'
# No concurrency group — intentionally omitted.
# Previous attempt (#3554, reverted #3599) used cancel-in-progress which
# killed in-progress PR runs before they produced useful results.
# Future iteration could safely add concurrency for scheduled/push-only
# triggers (where head_ref is empty and runs get unique groups).
permissions: {} # Minimal top-level for OSSF Scorecard Token-Permissions
jobs:
gitleaks:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
actions: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0 # Fetch full history for comprehensive secret scanning
- name: Run Gitleaks Secret Scanner
uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_USERNAME: ${{ github.actor }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITLEAKS_CONFIG: .gitleaks.toml
GITLEAKS_BASELINE_PATH: .gitleaksignore
+158
View File
@@ -0,0 +1,158 @@
name: Grype Vulnerability Scan
# Second dependency/container vulnerability scanner (complements Trivy).
# Grype uses Anchore's curated vulnerability DB (NVD, GitHub Advisories,
# Alpine SecDB, etc.) — a different data source from Trivy's own aggregation.
# Running both is standard practice: they catch different CVEs.
on:
workflow_call: # Called by release-gate.yml
workflow_dispatch:
permissions: {} # Minimal top-level for OSSF Scorecard Token-Permissions
jobs:
grype-filesystem:
name: Grype Filesystem Scan
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
security-events: write
actions: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Run Grype filesystem scan
uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 # v7.4.0
id: grype-fs
with:
path: '.'
# DO NOT change to fail-build: true — findings are enforced via SARIF alerts
# in the release gate (check-code-scanning-alerts job in release-gate.yml).
# Failing here would break CI without adding security value.
fail-build: false
output-format: sarif
severity-cutoff: medium
# Fail loudly if Grype produced no SARIF — never fabricate an empty one.
# An empty-results SARIF uploaded under the grype-filesystem category would
# make GitHub mark every previously-open Grype alert as fixed, silently
# clearing real findings. Mirror the Trivy job: error + exit 1, skip upload.
- name: Ensure SARIF file exists
id: check-fs-sarif
if: always()
run: |
SARIF_FILE="${{ steps.grype-fs.outputs.sarif }}"
if [ -f "$SARIF_FILE" ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "::error::Grype filesystem scan did not produce a SARIF file — scan needs to be rerun"
exit 1
fi
- name: Upload Grype filesystem SARIF to GitHub Security tab
uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
if: always() && steps.check-fs-sarif.outputs.exists == 'true'
with:
sarif_file: ${{ steps.grype-fs.outputs.sarif }}
category: grype-filesystem
grype-container:
name: Grype Container Scan
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
security-events: write
actions: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Free up disk space
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /usr/local/lib/android
sudo rm -rf /opt/ghc
sudo rm -rf /opt/hostedtoolcache/CodeQL
sudo docker image prune -af
df -h
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Build Docker image for scanning
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
push: false
load: true
tags: local-deep-research:grype-scan
cache-from: type=gha,scope=grype-scan
cache-to: type=gha,mode=max,scope=grype-scan
- name: Run Grype container scan
uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 # v7.4.0
id: grype-container
with:
image: 'local-deep-research:grype-scan'
# DO NOT change to fail-build: true — findings are enforced via SARIF alerts
# in the release gate (check-code-scanning-alerts job in release-gate.yml).
# Failing here would break CI without adding security value.
fail-build: false
output-format: sarif
severity-cutoff: medium
# Fail loudly if Grype produced no SARIF — never fabricate an empty one.
# An empty-results SARIF uploaded under the grype-container category would
# make GitHub mark every previously-open Grype alert as fixed, silently
# clearing real findings. Mirror the Trivy job: error + exit 1, skip upload.
- name: Ensure SARIF file exists
id: check-container-sarif
if: always()
run: |
SARIF_FILE="${{ steps.grype-container.outputs.sarif }}"
if [ -f "$SARIF_FILE" ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "::error::Grype container scan did not produce a SARIF file — scan needs to be rerun"
exit 1
fi
- name: Upload Grype container SARIF to GitHub Security tab
uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
if: always() && steps.check-container-sarif.outputs.exists == 'true'
with:
sarif_file: ${{ steps.grype-container.outputs.sarif }}
category: grype-container
- name: Display Grype summary
if: always()
run: |
{
echo "## Grype Container Vulnerability Scan"
echo ""
if [ "${{ steps.check-container-sarif.outputs.exists }}" = "true" ]; then
echo "✅ Grype scan completed - Results available in Security tab"
else
echo "❌ Grype scan failed - no SARIF produced (see error above); rerun required"
fi
} >> "$GITHUB_STEP_SUMMARY"
+111
View File
@@ -0,0 +1,111 @@
name: Hadolint Dockerfile Linting
on:
pull_request:
paths:
- 'Dockerfile'
- '.hadolint.yaml'
workflow_dispatch:
workflow_call: # Called by release-gate.yml
schedule:
# Run weekly on Tuesday at 9 AM UTC
- cron: '0 9 * * 2'
# No concurrency group — intentionally omitted.
# This workflow triggers on both pull_request and workflow_call (from
# ci-gate.yml / release-gate.yml). A shared concurrency key would cause
# direct PR runs and workflow_call runs to cancel each other mid-flight.
# See #3554 (reverted in #3599) for context.
permissions: {} # Minimal top-level for OSSF Scorecard Token-Permissions
jobs:
hadolint:
name: Hadolint Dockerfile Analysis
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Run Hadolint on Dockerfile
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0
with:
dockerfile: Dockerfile
format: sarif
output-file: hadolint.sarif
no-fail: false
verbose: true
- name: Upload Hadolint results to GitHub Security
uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
if: always()
with:
sarif_file: hadolint.sarif
category: hadolint
- name: Upload Hadolint results as artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: hadolint-results
path: hadolint.sarif
retention-days: 7 # Reduced for security
- name: Run Hadolint with detailed output
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0
if: always()
with:
dockerfile: Dockerfile
format: tty
no-fail: false
continue-on-error: false
- name: Display Hadolint summary
if: always()
run: |
{
echo "## Hadolint Dockerfile Analysis Summary"
echo ""
echo "✅ Hadolint analysis completed"
echo ""
echo "### What is Hadolint?"
echo "Hadolint is a Dockerfile linter that checks for best practices and common mistakes:"
echo ""
echo "#### Security Checks"
echo "- 🔒 Running as root user"
echo "- 🔑 Using latest tag"
echo "- 📦 Missing version pinning"
echo "- ⚠️ Exposed secrets in build args"
echo ""
echo "#### Best Practices"
echo "- 🎯 Layer optimization"
echo "- 🗑️ Cleaning package manager cache"
echo "- 📝 Using COPY instead of ADD"
echo "- 🔧 Proper WORKDIR usage"
echo "- 💾 Efficient layer caching"
echo ""
echo "#### Build Efficiency"
echo "- 🚀 Multi-stage build opportunities"
echo "- 📦 Unnecessary packages"
echo "- 🔄 Combining RUN commands"
echo ""
echo "📊 **Results:**"
echo "- Detailed results uploaded to GitHub Security tab"
echo "- SARIF file available in artifacts"
echo ""
echo "🔗 **Links:**"
echo "- [Security Tab](https://github.com/${{ github.repository }}/security/code-scanning)"
echo "- [Hadolint Rules](https://github.com/hadolint/hadolint#rules)"
} >> "$GITHUB_STEP_SUMMARY"
+139
View File
@@ -0,0 +1,139 @@
name: Issue Research
# When a maintainer applies the `ldr_research` label to an issue,
# run LDR research on the issue title + body and post a comment
# with two sections: a brief, cautious response for the reporter,
# and adjacent context for maintainer triage.
#
# The same `ldr_research` label is reused from the PR workflow.
# GitHub gates by event type (`on: issues:` here vs
# `on: pull_request:` in e2e-research-test.yml), so they don't
# conflict.
on:
issues:
types: [labeled]
permissions: {} # Minimal top-level for OSSF Scorecard
jobs:
build-query:
name: Assemble query
if: github.event.label.name == 'ldr_research'
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
query: ${{ steps.assemble.outputs.query }}
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Sanitize and assemble query
id: assemble
run: |
# Strip control chars (defence-in-depth against weird issue
# bodies) and truncate to 4000 chars before embedding in the
# prompt. The issue body is semi-trusted user input — never
# eval'd, never shelled out, only passed to the LLM as text.
SAFE_TITLE=$(printf '%s' "$ISSUE_TITLE" | tr -d '\000-\010\013-\037\177' | head -c 500)
SAFE_BODY=$(printf '%s' "$ISSUE_BODY" | tr -d '\000-\010\013-\037\177' | head -c 4000)
{
cat <<'PROMPT_HEAD_EOF'
A user filed a GitHub issue against the Local Deep Research project. Produce a comment with two distinct sections:
(1) "**For the reporter**" — a brief, cautious, 2-4 sentence summary of likely diagnostic directions, framed as suggestions and not authoritative diagnosis. If you don't have enough context to be useful, say so plainly.
(2) "**For maintainers**" — adjacent external context: similar reports in other projects, relevant upstream library documentation, known issues with the components mentioned, and related discussions. Treat the maintainer as the primary audience for the substantive research.
Issue title:
PROMPT_HEAD_EOF
printf '%s\n' "$SAFE_TITLE"
cat <<'PROMPT_MID_EOF'
Issue body:
<<<ISSUE_BODY
PROMPT_MID_EOF
printf '%s\n' "$SAFE_BODY"
cat <<'PROMPT_TAIL_EOF'
ISSUE_BODY>>>
PROMPT_TAIL_EOF
} > query.txt
# Randomized delimiter so a query containing the literal delimiter
# on its own line cannot prematurely terminate the heredoc.
DELIM="LDR_QUERY_EOF_$$_${RANDOM}_$(date +%N)"
{
echo "query<<$DELIM"
cat query.txt
echo "$DELIM"
} >> "$GITHUB_OUTPUT"
research:
name: Run LDR research
needs: build-query
# Grant the perms the reusable's job needs:
# - contents: read for actions/checkout
# - actions: write for actions/upload-artifact@v5+
# Without this, the reusable's `permissions: contents: read` exceeds
# the inherited empty permissions and GitHub rejects the workflow at
# load time (startup_failure with zero jobs).
permissions:
contents: read
actions: write
uses: ./.github/workflows/ldr-research-reusable.yml
with:
query: ${{ needs.build-query.outputs.query }}
model: ${{ vars.LDR_RESEARCH_MODEL || 'google/gemini-2.0-flash-001' }}
provider: ${{ vars.LDR_PROVIDER || 'openrouter' }}
search-tool: ${{ vars.LDR_SEARCH_TOOL || 'serper' }}
strategy: ${{ vars.LDR_STRATEGY || 'langgraph-agent' }}
comment-header: '## 🤖 LDR Research'
comment-subheader: '_Auto-generated context for maintainer triage and the reporter — see disclaimer below._'
secrets:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
SERPER_API_KEY: ${{ secrets.SERPER_API_KEY }}
post-comment:
name: Post issue comment
needs: research
# always() so the label-removal step runs even if research was skipped
# (e.g. build-query failed). The download/post steps are guarded by
# needs.research.outputs.success == 'true' so they self-skip in that
# case.
if: always()
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
env:
ISSUE_NUMBER: ${{ github.event.issue.number }}
LABEL_NAME: ${{ github.event.label.name }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Download research artifact
if: needs.research.outputs.success == 'true'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ needs.research.outputs.comment-artifact-name }}
- name: Post comment
if: needs.research.outputs.success == 'true'
run: |
gh issue comment "$ISSUE_NUMBER" --repo "${{ github.repository }}" -F comment.md
- name: Remove label for re-triggering
if: always()
run: |
gh issue edit "$ISSUE_NUMBER" --remove-label "$LABEL_NAME" 2>/dev/null || true
@@ -0,0 +1,79 @@
name: Journal Quality Data Integration
# Validates that the five external journal-quality data sources can
# still be downloaded and the read-only reference DB can be built from
# them end to end. Catches upstream schema breaks (OpenAlex renaming a
# field, DOAJ changing their dump format, etc.) BEFORE we cut a release.
#
# This is intentionally a separate workflow rather than a job inside the
# main test suite — it pulls ~25 MB from third-party APIs and takes
# ~3060 seconds, so it shouldn't run on every PR. It runs:
# - Weekly (Mondays 4 AM UTC) — catches drift between releases
# - On the release-gate workflow_call — blocks publishing if upstreams
# have broken our build
# - Manually via workflow_dispatch — for ad-hoc validation
on:
workflow_call: # called by release-gate.yml
workflow_dispatch:
schedule:
# Mondays 4 AM UTC — staggered after release-gate (2 AM) so the two
# don't collide if upstream rate-limits are touchy.
- cron: '0 4 * * 1'
permissions: {} # minimal top-level for OSSF Scorecard
jobs:
download-and-build:
name: Download external sources + build reference DB
runs-on: ubuntu-latest
# 45 min job timeout: parallel downloads are bounded by the slowest
# source (OpenAlex Institutions, ~10 min for ~110K rows via cursor
# pagination). Plus build_db (~15s) + setup overhead. Generous
# headroom so transient API slowness doesn't false-fail the gate.
timeout-minutes: 45
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.11'
- name: Install system dependencies for SQLCipher
run: |
sudo apt-get update
sudo apt-get install -y libsqlcipher-dev
- name: Set up PDM
uses: pdm-project/setup-pdm@973541a5febeafcfdadf8a51211435be6ecfd90f # v4.5
with:
python-version: '3.11'
- name: Install dependencies
run: pdm install --dev
- name: Run journal-quality release-gate integration test
# The test is marked `integration and slow` so it's skipped by
# the regular suite. We opt in here. The session fixture
# downloads all 5 sources in PARALLEL via ThreadPoolExecutor;
# wall-clock is bounded by the slowest source (institutions,
# ~10 min). Per-test pytest-timeout=2400s (40 min) overrides
# the global 60s default for these slow tests.
run: |
pdm run pytest tests/integration/test_journal_quality_release_gate.py \
-m "integration and slow" \
-v --tb=short \
--timeout=2400
timeout-minutes: 42
+46
View File
@@ -0,0 +1,46 @@
name: Sync repo labels
# Declaratively syncs labels listed in .github/labels.yml.
# Additive only: labels not listed here are left untouched (delete-other-labels: false).
# Lifecycle labels (needs-codeowner-review, awaiting-author, awaiting-codeowner) are
# created by this workflow but toggled per-PR by .github/workflows/pr-triage.yml.
on:
push:
branches: [main]
paths:
- '.github/labels.yml'
- '.github/workflows/labels-sync.yml'
workflow_dispatch:
# No concurrency group — intentionally omitted, matching .github/workflows/label-fixed-in-dev.yml.
# Previous attempts (#3554, reverted #3599) showed that cancel-in-progress on label workflows
# kills useful in-flight runs. Sync is idempotent so concurrent runs are safe.
permissions: {} # Minimal top-level for OSSF Scorecard Token-Permissions
jobs:
sync-labels:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
issues: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
sparse-checkout: .github/labels.yml
sparse-checkout-cone-mode: false
- name: Sync labels
uses: EndBug/label-sync@52074158190acb45f3077f9099fea818aa43f97a # v2.3.3
with:
config-file: .github/labels.yml
delete-other-labels: false
+300
View File
@@ -0,0 +1,300 @@
name: LDR Research (reusable)
# Reusable workflow that runs the LDR research script on a caller-supplied
# query and produces a comment-ready markdown blob (uploaded as an
# artifact). Callers download the artifact and post it to their target
# (PR comment, issue comment, Reddit, etc.). This workflow does NOT post
# anywhere — that's caller-specific.
on:
workflow_call:
inputs:
query:
description: 'Fully-assembled research prompt. The caller does all prompt engineering.'
type: string
required: true
model:
description: 'OpenRouter model slug. Empty falls back to vars.LDR_RESEARCH_MODEL then a hard default.'
type: string
default: ''
provider:
description: 'LLM provider.'
type: string
default: 'openrouter'
search-tool:
description: 'Search tool.'
type: string
default: 'serper'
strategy:
description: 'LDR search strategy.'
type: string
default: 'langgraph-agent'
iterations:
description: 'Override the strategy iteration cap. 0 keeps the strategy default.'
type: number
default: 0
max-query-length:
description: 'Backstop truncation on the query string before invoking the script.'
type: number
default: 12000
max-sources:
description: 'Cap sources rendered in the markdown.'
type: number
default: 10
comment-header:
description: 'Top header line of the formatted markdown.'
type: string
default: '## LDR Research Results'
comment-subheader:
description: 'Optional second-line subheader.'
type: string
default: ''
comment-footer:
description: 'Footer line. Reddit will swap in a bot disclaimer.'
type: string
default: '_Generated by [Local Deep Research](https://github.com/LearningCircuit/local-deep-research) E2E test_'
include-sources-section:
description: 'Render a dedicated sources section. Reddit will set false to fit length caps.'
type: boolean
default: true
output-truncate-chars:
description: 'Truncate the rendered markdown to this many chars. 0 disables truncation.'
type: number
default: 0
runner:
description: 'Runner label.'
type: string
default: 'ubuntu-latest'
artifact-suffix:
description: 'Optional disambiguator appended to the artifact name. Use a unique value per matrix entry when calling this workflow multiple times in one caller run.'
type: string
default: ''
secrets:
OPENROUTER_API_KEY:
required: true
SERPER_API_KEY:
required: true
outputs:
comment-artifact-name:
description: 'Name of the artifact containing comment.md and response.json.'
value: ${{ jobs.research.outputs.comment-artifact-name }}
success:
description: "'true' if LDR returned valid non-error JSON."
value: ${{ jobs.research.outputs.success }}
permissions: {}
jobs:
research:
name: Run LDR research
runs-on: ${{ inputs.runner }}
permissions:
contents: read
outputs:
comment-artifact-name: ${{ steps.artifact-name.outputs.name }}
success: ${{ steps.run-script.outputs.success }}
env:
QUERY: ${{ inputs.query }}
MAX_QUERY_LENGTH: ${{ inputs.max-query-length }}
MAX_SOURCES: ${{ inputs.max-sources }}
COMMENT_HEADER: ${{ inputs.comment-header }}
COMMENT_SUBHEADER: ${{ inputs.comment-subheader }}
COMMENT_FOOTER: ${{ inputs.comment-footer }}
INCLUDE_SOURCES: ${{ inputs.include-sources-section }}
OUTPUT_TRUNCATE_CHARS: ${{ inputs.output-truncate-chars }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 1
- name: Install jq
run: sudo apt-get update && sudo apt-get install -y jq
- name: Compute artifact name
id: artifact-name
env:
SUFFIX: ${{ inputs.artifact-suffix }}
run: |
NAME="ldr-research-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}"
if [ -n "$SUFFIX" ]; then
# Sanitize: artifact names allow [A-Za-z0-9._-] only.
SAFE_SUFFIX=$(printf '%s' "$SUFFIX" | tr -c 'A-Za-z0-9._-' '_')
NAME="${NAME}-${SAFE_SUFFIX}"
fi
echo "name=$NAME" >> "$GITHUB_OUTPUT"
- name: Resolve model
id: resolve-model
env:
INPUT_MODEL: ${{ inputs.model }}
REPO_VAR_MODEL: ${{ vars.LDR_RESEARCH_MODEL }}
run: |
if [ -n "$INPUT_MODEL" ]; then
echo "model=$INPUT_MODEL" >> "$GITHUB_OUTPUT"
elif [ -n "$REPO_VAR_MODEL" ]; then
echo "model=$REPO_VAR_MODEL" >> "$GITHUB_OUTPUT"
else
echo "model=google/gemini-2.0-flash-001" >> "$GITHUB_OUTPUT"
fi
- name: Set up PDM
uses: pdm-project/setup-pdm@973541a5febeafcfdadf8a51211435be6ecfd90f # v4.5
with:
python-version: '3.12'
cache: true
- name: Install LDR
run: pdm install
- name: Write query to file (with backstop truncation)
run: |
# Truncate by total byte count, not per-line. Avoids putting the
# entire query on the command line where it could exceed ARG_MAX.
QUERY_BYTES=$(printf '%s' "$QUERY" | wc -c)
if [ "$QUERY_BYTES" -gt "$MAX_QUERY_LENGTH" ]; then
printf '%s' "$QUERY" | head -c "$MAX_QUERY_LENGTH" > query.txt
printf '\n... (truncated)\n' >> query.txt
echo "Query truncated: $QUERY_BYTES -> $MAX_QUERY_LENGTH bytes"
else
printf '%s' "$QUERY" > query.txt
fi
echo "Final query size: $(wc -c < query.txt) bytes"
- name: Run LDR Research
id: run-script
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
SERPER_API_KEY: ${{ secrets.SERPER_API_KEY }}
LDR_PROVIDER: ${{ inputs.provider }}
LDR_SEARCH_TOOL: ${{ inputs.search-tool }}
LDR_RESEARCH_MODEL: ${{ steps.resolve-model.outputs.model }}
LDR_STRATEGY: ${{ inputs.strategy }}
ITERATIONS: ${{ inputs.iterations }}
run: |
set +e
if [ "$ITERATIONS" -gt 0 ] 2>/dev/null; then
pdm run python scripts/ldr-research.py --iterations "$ITERATIONS" \
< query.txt 2> >(tee stderr.log >&2) > response.json
else
pdm run python scripts/ldr-research.py \
< query.txt 2> >(tee stderr.log >&2) > response.json
fi
LDR_EXIT_CODE=$?
set -e
echo "=== Response (first 2000 chars): ==="
head -c 2000 response.json
echo ""
echo "=== End response ==="
# Catches SIGABRT/native crashes where stdout never flushed; JSON check alone can't see this.
if [ "$LDR_EXIT_CODE" -ne 0 ]; then
LAST_ERR=$(tail -c 500 stderr.log 2>/dev/null || echo "")
echo "::error::ldr-research.py exited with code $LDR_EXIT_CODE: $LAST_ERR"
echo "success=false" >> "$GITHUB_OUTPUT"
exit 1
fi
# `jq .` exits 0 on a zero-byte file, so guard explicitly.
if [ ! -s response.json ]; then
echo "::error::response.json is empty"
echo "success=false" >> "$GITHUB_OUTPUT"
exit 1
fi
# Shape validation: must be a JSON object.
if ! jq -e 'type == "object"' response.json > /dev/null 2>&1; then
echo "::error::Response is not a JSON object"
echo "success=false" >> "$GITHUB_OUTPUT"
exit 1
fi
ERROR=$(jq -r '.error // empty' response.json)
if [ -n "$ERROR" ]; then
echo "::error::LDR error: $ERROR"
echo "success=false" >> "$GITHUB_OUTPUT"
exit 1
fi
# Empty .research would still produce a hollow downstream comment.
RESEARCH=$(jq -r '.research // empty' response.json)
if [ -z "$RESEARCH" ]; then
echo "::error::Response missing or empty .research field"
echo "success=false" >> "$GITHUB_OUTPUT"
exit 1
fi
echo "success=true" >> "$GITHUB_OUTPUT"
echo "✅ Research completed"
- name: Build comment markdown
run: |
jq -r '.research' response.json > result.md
# Optional sources section
if [ "$INCLUDE_SOURCES" = "true" ]; then
SOURCES=$(jq -r --argjson cap "$MAX_SOURCES" \
'.sources[:$cap][] | "- [\(.title // "Source")](\(.link // .url // ""))"' \
response.json 2>/dev/null || echo "")
else
SOURCES=""
fi
{
echo "$COMMENT_HEADER"
echo ""
if [ -n "$COMMENT_SUBHEADER" ]; then
echo "$COMMENT_SUBHEADER"
echo ""
fi
cat result.md
echo ""
if [ -n "$SOURCES" ]; then
echo "### 🔍 Search Sources"
echo ""
echo "$SOURCES"
echo ""
fi
echo "---"
echo "$COMMENT_FOOTER"
} > comment.md
# Optional truncation (Reddit will use this)
if [ "$OUTPUT_TRUNCATE_CHARS" -gt 0 ] 2>/dev/null; then
CURRENT=$(wc -c < comment.md)
if [ "$CURRENT" -gt "$OUTPUT_TRUNCATE_CHARS" ]; then
# Truncate at last paragraph break before the limit, with a budget
# for the trailing marker.
MARKER='…[truncated]'
BUDGET=$((OUTPUT_TRUNCATE_CHARS - ${#MARKER} - 1))
head -c "$BUDGET" comment.md > comment.md.tmp
# Trim trailing partial line so we end on a clean paragraph
awk 'BEGIN{RS=""} {gsub(/[[:space:]]+$/,""); print}' \
comment.md.tmp > comment.md
echo "" >> comment.md
echo "$MARKER" >> comment.md
rm -f comment.md.tmp
echo "Truncated comment.md to $OUTPUT_TRUNCATE_CHARS chars."
fi
fi
echo "Final comment.md size: $(wc -c < comment.md) bytes"
- name: Upload research artifact
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ steps.artifact-name.outputs.name }}
path: |
comment.md
response.json
stderr.log
if-no-files-found: ignore
retention-days: 7
+115
View File
@@ -0,0 +1,115 @@
name: MCP Server Tests
on:
push:
branches: [ main ]
paths:
- 'src/local_deep_research/mcp/**'
- 'tests/mcp/**'
- 'scripts/mcp_smoke_test.sh'
- '.github/workflows/mcp-tests.yml'
pull_request:
branches: [ main ]
paths:
- 'src/local_deep_research/mcp/**'
- 'tests/mcp/**'
- 'scripts/mcp_smoke_test.sh'
- '.github/workflows/mcp-tests.yml'
workflow_dispatch:
# No concurrency group — intentionally omitted.
# Previous attempt (#3554, reverted #3599) used cancel-in-progress which
# killed in-progress PR runs before they produced useful results.
# Future iteration could safely add concurrency for scheduled/push-only
# triggers (where head_ref is empty and runs get unique groups).
permissions:
contents: read
jobs:
mcp-tests:
name: MCP Server Tests
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.12'
- name: Install dependencies
run: |
python -m pip install pip==25.0
pip install pdm==2.26.2
pdm install --dev --no-editable -G mcp
- name: Check if MCP server module exists
id: check-mcp
run: |
if [ -f "src/local_deep_research/mcp/server.py" ]; then
echo "mcp_exists=true" >> "$GITHUB_OUTPUT"
echo "MCP server module found, will run tests"
else
echo "mcp_exists=false" >> "$GITHUB_OUTPUT"
echo "MCP server module not found (feature not yet merged), skipping tests"
fi
- name: Run MCP smoke tests
if: steps.check-mcp.outputs.mcp_exists == 'true'
run: pdm run bash scripts/mcp_smoke_test.sh
- name: Run MCP unit tests
if: steps.check-mcp.outputs.mcp_exists == 'true'
run: |
pdm run pytest tests/mcp/ -v --tb=short -n auto
env:
LDR_TESTING_WITH_MOCKS: "true"
- name: Skip notice
if: steps.check-mcp.outputs.mcp_exists != 'true'
run: |
echo "::notice::MCP server module not yet implemented. Tests will run once feature/mcp-server is merged."
- name: Generate test summary
if: always()
env:
MCP_EXISTS: ${{ steps.check-mcp.outputs.mcp_exists }}
run: |
{
echo "## MCP Server Test Summary"
echo ""
if [ "$MCP_EXISTS" != "true" ]; then
echo "### ⏭️ Tests Skipped"
echo ""
echo "MCP server module (\`src/local_deep_research/mcp/server.py\`) not yet implemented."
echo "Tests will run automatically once the MCP feature branch is merged."
else
echo "### What was tested:"
echo "- 🔌 MCP server module loading"
echo "- 🔧 Discovery tools (list_strategies, list_search_engines, get_configuration)"
echo "- 🧪 Unit tests for all MCP tools"
echo "- 🚀 Server startup verification"
echo ""
echo "### MCP Tools Tested:"
echo "| Tool | Description |"
echo "|------|-------------|"
echo "| \`quick_research\` | Fast research summary (1-5 min) |"
echo "| \`detailed_research\` | Comprehensive analysis (5-15 min) |"
echo "| \`generate_report\` | Full markdown report (10-30 min) |"
echo "| \`analyze_documents\` | Search local collections |"
echo "| \`list_search_engines\` | List available search engines |"
echo "| \`list_strategies\` | List research strategies |"
echo "| \`get_configuration\` | Get current config |"
fi
} >> "$GITHUB_STEP_SUMMARY"
+81
View File
@@ -0,0 +1,81 @@
name: Mypy Type Checking
on:
pull_request:
branches: [ main ]
workflow_call: # Called by ci-gate.yml for release pipeline
workflow_dispatch:
# No concurrency group — intentionally omitted.
# This workflow triggers on both pull_request and workflow_call (from
# ci-gate.yml / release-gate.yml). A shared concurrency key would cause
# direct PR runs and workflow_call runs to cancel each other mid-flight.
# See #3554 (reverted in #3599) for context.
permissions:
contents: read
jobs:
mypy-analysis:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.12'
- name: Install system dependencies for SQLCipher
run: |
sudo apt-get update
sudo apt-get install -y libsqlcipher-dev
- name: Install PDM
run: |
pip install pdm==2.26.2
- name: Cache PDM dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cache/pdm
.venv
key: pdm-${{ runner.os }}-${{ hashFiles('pdm.lock') }}
restore-keys: |
pdm-${{ runner.os }}-
- name: Install project dependencies
run: |
pdm sync -d
- name: Run mypy type checking
run: |
pdm run mypy src/ --config-file=pyproject.toml --show-error-codes --pretty
- name: Display mypy summary
if: always()
run: |
{
echo "## Mypy Type Checking Results"
echo ""
echo "Type checking analysis completed."
echo ""
echo "### Benefits"
echo "- Catches type errors before runtime"
echo "- Improves code quality and maintainability"
echo "- Provides better IDE autocomplete"
echo "- Detects uninitialized variables and type mismatches"
echo ""
echo "See full output above for any type errors found."
} >> "$GITHUB_STEP_SUMMARY"
+81
View File
@@ -0,0 +1,81 @@
name: npm Security Audit
on:
workflow_dispatch:
workflow_call: # Called by release-gate.yml
permissions:
contents: read
jobs:
npm-audit:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24'
- name: Run npm audit on all package.json locations
env:
# Advisories with NO upstream fix, accepted ONLY because they live in
# dev/test-only tooling that never ships to users. Keep tight + justified:
# GHSA-h67p-54hq-rp68 — js-yaml 3.x DoS, pulled transitively by
# @lhci/utils (accessibility_tests) & @istanbuljs/load-nyc-config
# (infrastructure_tests); both pin js-yaml ^3, no patched 3.x exists.
# Dev-only, parses trusted local config. (dismissed alerts #97/#7893)
AUDIT_ALLOWLIST: "GHSA-h67p-54hq-rp68"
run: |
FAILED=false
FILTER="${GITHUB_WORKSPACE}/.github/scripts/npm_audit_allowlist.py"
# Audit root and every test directory that contains a package.json
DIRS=(
.
tests
tests/ui_tests
tests/ui_tests/playwright
tests/accessibility_tests
tests/api_tests_with_login
tests/infrastructure_tests
tests/puppeteer
)
for dir in "${DIRS[@]}"; do
echo "=== Running npm audit on ${dir} ==="
if [ ! -f "${dir}/package.json" ]; then
echo "No package.json found in ${dir}"
continue
fi
# Require committed lockfile for reproducible security audits
if [ ! -f "${dir}/package-lock.json" ]; then
echo "::error::Missing ${dir}/package-lock.json. Please commit your lockfile for security audits."
FAILED=true
continue
fi
# Audit, then filter out allowlisted (no-fix, dev-only) advisories.
# `|| true` is on the assignment (not inside $()) so npm's non-zero
# exit is swallowed without the SC2015 `A && B || C` idiom; an
# empty/error capture still fails safe in the filter.
audit_json="$(cd "${dir}" && npm audit --audit-level=moderate --json 2>/dev/null)" || true
if ! printf '%s' "${audit_json}" | python3 "${FILTER}"; then
FAILED=true
fi
done
if [[ "$FAILED" == "true" ]]; then
echo "❌ npm audit found non-allowlisted moderate or higher severity vulnerabilities"
echo "Fix: bump the dep / 'npm audit fix'; or if no fix exists and it is dev-only, add the advisory to AUDIT_ALLOWLIST with justification."
exit 1
else
echo "✅ No non-allowlisted moderate or higher severity vulnerabilities found"
fi
+250
View File
@@ -0,0 +1,250 @@
name: Nuclei Vulnerability Scan
# Template-driven DAST scanner — complements ZAP with known-CVE checks,
# misconfiguration detection, exposed panel discovery, and default credential
# testing. Uses ProjectDiscovery's 6,500+ community-maintained templates.
#
# Authenticated scan: the runner pre-creates the standard CI test_admin user,
# logs in via the real /auth/login flow to capture a session cookie, and
# seeds Nuclei with the full Flask url_map so the scanner probes the entire
# authenticated app surface (settings, research, history, API, …) — not just
# the unauthenticated landing page.
on:
workflow_call: # Called by release-gate.yml
workflow_dispatch:
permissions: {}
jobs:
nuclei-scan:
name: Nuclei DAST Scan
runs-on: ubuntu-latest
timeout-minutes: 40
permissions:
contents: read
security-events: write
actions: read
env:
CI: true
TEST_ENV: true
FLASK_ENV: testing
LDR_DATA_DIR: ${{ github.workspace }}/data
LDR_DB_CONFIG_KDF_ITERATIONS: "1000"
# Honour the fast test KDF: without LDR_TEST_MODE the value is clamped
# to the production minimum (256000), making every SQLCipher open/backup
# ~256x slower — see #4430 and tests/shared/chrome_profile.js context.
LDR_TEST_MODE: "1"
LDR_DISABLE_RATE_LIMITING: "true"
SECRET_KEY: test-secret-key-for-ci
TEST_USERNAME: test_admin
TEST_PASSWORD: testpass123
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.12'
- name: Set up PDM
uses: pdm-project/setup-pdm@973541a5febeafcfdadf8a51211435be6ecfd90f # v4.5
with:
python-version: '3.12'
- name: Install system dependencies for SQLCipher
run: |
sudo apt-get update
sudo apt-get install -y libsqlcipher-dev
- name: Install dependencies
run: |
pdm sync -d
- name: Setup test data directory
run: |
mkdir -p "$LDR_DATA_DIR/encrypted_databases"
- name: Pre-create CI test user
# Avoids the slow registration path (KDF + 500 settings rows) and
# avoids hitting the registration rate limit during the scan window.
run: |
export PYTHONPATH=$PWD/src:$PYTHONPATH
pdm run python scripts/ci/init_test_database.py
- name: Start LDR server for testing
run: |
export PYTHONPATH=$PWD/src:$PYTHONPATH
pdm run python -m local_deep_research.web.app > server.log 2>&1 &
SERVER_PID=$!
echo "$SERVER_PID" > server.pid
# Wait for server to start.
# --connect-timeout/--max-time bound TCP and total request time so a
# hung connection fails fast instead of eating the job timeout.
for _ in {1..90}; do
if curl -fsS --connect-timeout 2 --max-time 5 http://127.0.0.1:5000/api/v1/health > /dev/null; then
echo "Server started successfully"
break
fi
sleep 1
done
# Verify server is running
if ! curl -fsS --connect-timeout 2 --max-time 5 http://127.0.0.1:5000/api/v1/health > /dev/null; then
echo "Server failed to start"
cat server.log
exit 1
fi
- name: Dump Flask url_map for URL seeding
# Without -list, Nuclei only probes the single -target URL. Seeding
# with the real url_map lets it exercise every blueprint route.
run: |
export PYTHONPATH=$PWD/src:$PYTHONPATH
pdm run python scripts/ci/dump_url_map.py http://127.0.0.1:5000 > urls.txt
echo "Seeded $(wc -l < urls.txt) URLs:"
head -20 urls.txt
echo "..."
- name: Authenticate and capture session cookie
id: login
run: |
# Step 1: GET /auth/login → establishes a Flask session cookie and
# returns the HTML form with the per-session CSRF token.
curl -sS -c cookies.txt -o login.html http://127.0.0.1:5000/auth/login
CSRF=$(grep -oE 'name="csrf_token"[[:space:]]+value="[^"]+"' login.html \
| head -n1 \
| sed -E 's/.*value="([^"]+)".*/\1/')
if [ -z "$CSRF" ]; then
echo "Failed to extract CSRF token from login page"
head -200 login.html
exit 1
fi
# Step 2: POST credentials with the CSRF token and the cookie jar.
# -L follows the post-login 302 to /. -o /dev/null discards body,
# -w prints the final HTTP code so a non-2xx fails the step.
HTTP_CODE=$(curl -sS -L \
-b cookies.txt -c cookies.txt \
-o /dev/null -w '%{http_code}' \
--data-urlencode "username=${TEST_USERNAME}" \
--data-urlencode "password=${TEST_PASSWORD}" \
--data-urlencode "csrf_token=${CSRF}" \
http://127.0.0.1:5000/auth/login)
if [ "$HTTP_CODE" != "200" ]; then
echo "Login failed with HTTP $HTTP_CODE"
cat server.log
exit 1
fi
# Verify the session is actually authenticated (not just that
# the login page rendered with a 200 from a re-display of errors).
AUTHED=$(curl -sS -b cookies.txt http://127.0.0.1:5000/auth/check)
echo "auth/check response: $AUTHED"
echo "$AUTHED" | grep -q '"authenticated":[[:space:]]*true' || {
echo "Authenticated check failed"
exit 1
}
# Extract the session cookie value from the Netscape cookie jar.
# Format: domain TAB tailmatch TAB path TAB secure TAB expires TAB name TAB value
SESSION=$(awk '$6 == "session" { print $7 }' cookies.txt | tail -n1)
if [ -z "$SESSION" ]; then
# Print every column EXCEPT the value ($7) so we can debug
# without leaking the cookie if the awk filter is broken.
echo "Could not find 'session' cookie in jar (values redacted):"
awk '{ print $1, $2, $3, $4, $5, $6 }' cookies.txt
exit 1
fi
# Mask the cookie in logs — it grants full app access for this run.
echo "::add-mask::$SESSION"
echo "session=$SESSION" >> "$GITHUB_OUTPUT"
# Give the post-login background thread a moment to finish
# the settings-migration / library-init pass it kicks off
# (see _perform_post_login_tasks in web/auth/routes.py).
# Otherwise the first authenticated probes can race those
# writes and 500 on settings-dependent routes.
sleep 2
- name: Create empty SARIF fallback
run: |
cat > nuclei.sarif << 'SARIF'
{
"version": "2.1.0",
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
"runs": [{
"tool": {
"driver": {
"name": "Nuclei",
"informationUri": "https://github.com/projectdiscovery/nuclei",
"rules": []
}
},
"results": []
}]
}
SARIF
- name: Run Nuclei scan
uses: projectdiscovery/nuclei-action@cc153d0541e1adf8a42bbe31c0a4fb2376147538 # v3.1.1
with:
# -list: probe every route in the Flask url_map (not just /).
# -H: attach the authenticated session cookie so probes hit the
# real app surface, not the login redirect.
# -severity: drop info-level noise (form-detection, options-method,
# intentional CSP/cookie choices). Real findings are >= low.
# -etags intrusive,dos,fuzz: with a live session, default templates
# can mutate state or DoS the runner. Exclude the standard
# destructive tag set for authenticated DAST.
# -eid http-missing-security-headers: HSTS is correctly omitted on
# plain HTTP localhost; X-XSS-Protection is intentionally
# omitted (deprecated, replaced by CSP). See
# security/security_headers.py.
args: >-
-list urls.txt
-H "Cookie: session=${{ steps.login.outputs.session }}"
-severity low,medium,high,critical
-etags intrusive,dos,fuzz
-eid http-missing-security-headers
-sarif-export nuclei.sarif
-output nuclei.log
- name: Upload Nuclei SARIF to GitHub Security tab
uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
if: always()
with:
sarif_file: nuclei.sarif
category: nuclei-dast
- name: Upload Nuclei scan artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: nuclei-scan-results
path: |
nuclei.log
nuclei.sarif
urls.txt
server.log
retention-days: 30
- name: Stop server
if: always()
run: |
if [ -f server.pid ]; then
kill "$(cat server.pid)" || true
rm server.pid
fi
+62
View File
@@ -0,0 +1,62 @@
name: OSSF Scorecard
on:
# Run on branches (for analysis purposes)
branch_protection_rule:
# Run on schedule
schedule:
# Run weekly on Monday at 8 AM UTC
- cron: '0 8 * * 1'
# Allow manual runs
workflow_dispatch:
# Run on push to default branch
push:
branches: [ main ]
# Declare default permissions as read only.
permissions: read-all
jobs:
analysis:
name: OSSF Security Scorecard Analysis
runs-on: ubuntu-latest
permissions:
# Needed to upload the results to code-scanning dashboard.
security-events: write
# Needed to publish results and get a badge
id-token: write
# Needed for private repositories
contents: read
actions: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Run OSSF Scorecard analysis
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
with:
results_file: results.sarif
results_format: sarif
# Publish results to enable scorecard badges
publish_results: true
- name: Upload OSSF Scorecard results to GitHub Security
uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
with:
sarif_file: results.sarif
category: ossf-scorecard
- name: Upload OSSF Scorecard results as artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ossf-scorecard-results
path: results.sarif
retention-days: 7 # Reduced for security
@@ -0,0 +1,35 @@
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# Scheduled OSV-Scanner scanning for vulnerabilities.
# Runs on push to main and weekly schedule.
# Split from osv-scanner.yml to prevent "skipped" noise on PRs.
#
# For more examples and options, including how to ignore specific vulnerabilities,
# see https://google.github.io/osv-scanner/github-action/
name: OSV-Scanner (Scheduled)
on:
push:
branches: [ "main" ] # Create baseline for Code Scanning comparison
schedule:
- cron: '41 21 * * 1' # Weekly on Monday at 21:41 UTC
workflow_dispatch:
permissions: {} # Minimal top-level for OSSF Scorecard Token-Permissions
jobs:
scan-scheduled:
permissions:
security-events: write
contents: read
actions: read
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
with:
scan-args: |-
-r
--skip-git
./
+48
View File
@@ -0,0 +1,48 @@
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# OSV-Scanner for detecting vulnerabilities in dependencies.
# Runs on PRs to catch vulnerable dependencies before merge (shift-left security).
#
# NOTE: This workflow is NOT included in release-gate.yml because
# GitHub Actions limits reusable workflow nesting to 2 levels. Since this
# workflow calls google/osv-scanner-reusable.yml, including it in the gate
# would create 4 levels of nesting and cause startup_failure:
# release.yml → release-gate.yml → osv-scanner.yml → google/osv-scanner-reusable.yml
#
# For more examples and options, including how to ignore specific vulnerabilities,
# see https://google.github.io/osv-scanner/github-action/
name: OSV-Scanner
on:
pull_request:
branches: [ "main" ]
merge_group:
branches: [ "main" ]
schedule:
- cron: '39 12 * * 1' # Weekly scan for newly disclosed CVEs
workflow_dispatch:
# No concurrency group — intentionally omitted.
# Previous attempt (#3554, reverted #3599) used cancel-in-progress which
# killed in-progress PR runs before they produced useful results.
# Future iteration could safely add concurrency for scheduled/push-only
# triggers (where head_ref is empty and runs get unique groups).
permissions: {} # Minimal top-level for OSSF Scorecard Token-Permissions
jobs:
scan:
permissions:
security-events: write
contents: read
actions: read
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
with:
scan-args: |-
-r
--skip-git
./
+212
View File
@@ -0,0 +1,212 @@
name: OWASP ZAP Security Scan
# Baseline and API scans - called by release-gate.yml
on:
workflow_call: # Called by release-gate.yml
workflow_dispatch:
# Restrict top-level permissions (jobs define their own)
permissions: {}
jobs:
zap-baseline-scan:
name: ZAP Baseline Scan
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
security-events: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.12'
- name: Set up PDM
uses: pdm-project/setup-pdm@973541a5febeafcfdadf8a51211435be6ecfd90f # v4.5
with:
python-version: '3.12'
- name: Install system dependencies for SQLCipher
run: |
sudo apt-get update
sudo apt-get install -y libsqlcipher-dev
- name: Install dependencies
run: |
pdm sync -d
- name: Start LDR server for testing
run: |
export CI=true
export LDR_DB_CONFIG_KDF_ITERATIONS=1000
# Without LDR_TEST_MODE the 1000 is clamped to the production
# minimum (256000), making SQLCipher ~256x slower — see #4430.
export LDR_TEST_MODE=1
export PYTHONPATH=$PWD/src:$PYTHONPATH
pdm run python -m local_deep_research.web.app > server.log 2>&1 &
SERVER_PID=$!
echo "$SERVER_PID" > server.pid
# Wait for server to start.
# --connect-timeout/--max-time bound TCP and total request time so a
# hung connection fails fast instead of eating the job timeout.
for _ in {1..90}; do
if curl -fsS --connect-timeout 2 --max-time 5 http://localhost:5000/api/v1/health > /dev/null; then
echo "Server started successfully"
break
fi
sleep 1
done
# Verify server is running
if ! curl -fsS --connect-timeout 2 --max-time 5 http://localhost:5000/api/v1/health > /dev/null; then
echo "Server failed to start"
cat server.log
exit 1
fi
- name: Run ZAP Baseline Scan
uses: zaproxy/action-baseline@de8ad967d3548d44ef623df22cf95c3b0baf8b25 # v0.15.0
with:
target: 'http://localhost:5000'
rules_file_name: '.zap/rules.tsv'
cmd_options: '-a -I' # -I: only fail on FAIL-level alerts per rules.tsv (ignore WARN/INFO)
allow_issue_writing: false
fail_action: true
artifact_name: zapbaseline
- name: Upload ZAP scan results
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: zap-baseline-scan-results
path: |
report_html.html
report_json.json
report_md.md
retention-days: 30
- name: Stop server
if: always()
run: |
if [ -f server.pid ]; then
kill "$(cat server.pid)" || true
rm server.pid
fi
zap-api-scan:
name: ZAP API Scan
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
security-events: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.12'
- name: Set up PDM
uses: pdm-project/setup-pdm@973541a5febeafcfdadf8a51211435be6ecfd90f # v4.5
with:
python-version: '3.12'
- name: Install system dependencies for SQLCipher
run: |
sudo apt-get update
sudo apt-get install -y libsqlcipher-dev
- name: Install dependencies
run: |
pdm sync -d
- name: Start LDR server for testing
run: |
export CI=true
export LDR_DB_CONFIG_KDF_ITERATIONS=1000
# Without LDR_TEST_MODE the 1000 is clamped to the production
# minimum (256000), making SQLCipher ~256x slower — see #4430.
export LDR_TEST_MODE=1
export PYTHONPATH=$PWD/src:$PYTHONPATH
pdm run python -m local_deep_research.web.app > server.log 2>&1 &
SERVER_PID=$!
echo "$SERVER_PID" > server.pid
# Wait for server to start.
# --connect-timeout/--max-time bound TCP and total request time so a
# hung connection fails fast instead of eating the job timeout.
for _ in {1..90}; do
if curl -fsS --connect-timeout 2 --max-time 5 http://localhost:5000/api/v1/health > /dev/null; then
echo "Server started successfully"
break
fi
sleep 1
done
# Verify server is running
if ! curl -fsS --connect-timeout 2 --max-time 5 http://localhost:5000/api/v1/health > /dev/null; then
echo "Server failed to start"
cat server.log
exit 1
fi
- name: Generate OpenAPI specification
run: |
# If you have an OpenAPI spec, use it. Otherwise, ZAP will discover endpoints
# For now, we'll let ZAP discover API endpoints automatically
echo "Using automatic API endpoint discovery"
- name: Run ZAP API Scan
uses: zaproxy/action-api-scan@5158fe4d9d8fcc75ea204db81317cce7f9e5453d # v0.10.0
with:
target: 'http://localhost:5000/api/v1'
format: 'openapi'
rules_file_name: '.zap/rules.tsv'
cmd_options: '-a -I' # -I: only fail on FAIL-level alerts per rules.tsv (ignore WARN/INFO)
allow_issue_writing: false
fail_action: true
artifact_name: zapapi
- name: Upload ZAP API scan results
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: zap-api-scan-results
path: |
report_html.html
report_json.json
report_md.md
retention-days: 30
- name: Stop server
if: always()
run: |
if [ -f server.pid ]; then
kill "$(cat server.pid)" || true
rm server.pid
fi
@@ -0,0 +1,351 @@
name: Playwright WebKit Tests
on:
workflow_call: # Called by release pipeline
workflow_dispatch: # Manual trigger
schedule:
- cron: '0 2 * * *' # Daily at 2 AM UTC
permissions: {} # Minimal top-level for OSSF Scorecard Token-Permissions
jobs:
# Shared setup: start server, register user, then run Desktop Safari tests
desktop-safari:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up PDM
uses: pdm-project/setup-pdm@973541a5febeafcfdadf8a51211435be6ecfd90f # v4.5
with:
python-version: '3.12'
version: 2.26.2
cache: true
- name: Install Python dependencies
run: |
pdm install -d
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: |
package-lock.json
tests/ui_tests/playwright/package-lock.json
- name: Build Vite frontend assets
run: |
npm ci
npm run build
- name: Install Playwright dependencies (npm)
working-directory: tests/ui_tests/playwright
run: npm ci
# ``playwright install --with-deps`` downloads ~150 MB of browser
# binaries from the playwright CDN per attempt. Single CDN
# hiccups have failed the whole release pipeline in the past
# (e.g. transient 5xx mid-download); a single retry costs little
# and removes the dominant transient-network failure mode for
# this workflow. Two attempts only — three attempts of a 150 MB
# download starts hiding real outages.
- name: Install Playwright browsers
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
with:
timeout_minutes: 10
max_attempts: 2
retry_on: error
shell: bash
# Install chromium for auth setup step + webkit for Safari tests
command: |
cd tests/ui_tests/playwright
npx playwright install chromium webkit --with-deps
- name: Setup test directories
run: |
mkdir -p data/encrypted_databases
echo "Created data directories for tests"
- name: Start LDR server
env:
CI: true
TEST_ENV: true
FLASK_ENV: testing
LDR_DATA_DIR: ${{ github.workspace }}/data
# The server MUST open test_admin's encrypted DB with the same KDF it
# was created with. init_test_database.py creates it at 1000 iterations
# (LDR_TEST_MODE relaxes the floor to allow it); without these two vars
# here the requested 1000 is below the production floor, so it falls
# back to the 256000 default, the server derives the wrong key, and
# every login fails with a 401 — see #4558, which set these on the init
# step but missed this server step.
LDR_DB_CONFIG_KDF_ITERATIONS: "1000"
LDR_TEST_MODE: "1"
LDR_DISABLE_RATE_LIMITING: true
SECRET_KEY: test-secret-key-for-ci
run: |
export PYTHONPATH="$PWD/src:$PYTHONPATH"
echo "Starting server with LDR_DATA_DIR=$LDR_DATA_DIR"
pdm run python -m local_deep_research.web.app &
SERVER_PID=$!
echo "SERVER_PID=$SERVER_PID" >> "$GITHUB_ENV"
# Wait for server to be ready.
# --connect-timeout/--max-time bound TCP and total request time so a
# hung connection fails fast instead of eating the job timeout.
for i in {1..30}; do
if curl -fsS --connect-timeout 2 --max-time 5 http://127.0.0.1:5000/api/v1/health > /dev/null; then
echo "Server is ready"
break
fi
echo "Waiting for server... ($i/30)"
sleep 2
done
# Verify server is running
if ! curl -fsS --connect-timeout 2 --max-time 5 http://127.0.0.1:5000/api/v1/health > /dev/null; then
echo "Server failed to start"
exit 1
fi
- name: Initialize test database
env:
TEST_ENV: true
LDR_DATA_DIR: ${{ github.workspace }}/data
LDR_DB_CONFIG_KDF_ITERATIONS: "1000"
# Without LDR_TEST_MODE the 1000 is clamped to the production
# minimum (256000), making SQLCipher ~256x slower — see #4430.
LDR_TEST_MODE: "1"
run: |
export PYTHONPATH="$PWD/src:$PYTHONPATH"
pdm run python scripts/ci/init_test_database.py
- name: Register CI test user
working-directory: tests/ui_tests
run: |
npm ci
node register_ci_user.js http://127.0.0.1:5000
- name: Run Desktop Safari Tests
working-directory: tests/ui_tests/playwright
env:
CI: true
TEST_BASE_URL: http://127.0.0.1:5000
TEST_USERNAME: test_admin
TEST_PASSWORD: testpass123
# Safari CI runs a critical subset for fast release gate:
# - all-pages-mobile: overflow, touch targets, nav clearance across all pages
# - auth-pages-mobile: login, register, change-password mobile layouts
# - desktop-layout: sidebar positioning and breakpoint testing
# - interactive-states: advanced options, search, help panels, mobile menus
# - settings-subpages-mobile: settings tab navigation and form layouts
# - mobile-ui-audit: comprehensive mobile UI quality checks
# - embedding-settings-dropdown: regression for #3863 (model dropdown reset)
# - settings-mobile-collapse: sections collapse by default on mobile (#4032),
# stay expanded on desktop, and search force-expands survivors
# Full suite: run locally via `cd tests/ui_tests/playwright && npm test`
# When adding Safari-critical tests, update this filter pattern.
run: |
npx playwright test \
--project="Desktop Safari" \
"all-pages-mobile|auth-pages-mobile|desktop-layout|interactive-states|settings-subpages-mobile|mobile-ui-audit|embedding-settings-dropdown|settings-mobile-collapse"
- name: Upload Playwright Report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-desktop-safari
path: |
tests/ui_tests/playwright/playwright-report/
tests/ui_tests/playwright/test-results/
- name: Stop server
if: always()
run: |
if [ -n "$SERVER_PID" ]; then
kill "$SERVER_PID" || true
fi
pkill -f "python -m local_deep_research.web.app" || true
# Mobile Safari tests run in parallel on a separate runner
mobile-safari:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up PDM
uses: pdm-project/setup-pdm@973541a5febeafcfdadf8a51211435be6ecfd90f # v4.5
with:
python-version: '3.12'
version: 2.26.2
cache: true
- name: Install Python dependencies
run: |
pdm install -d
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: |
package-lock.json
tests/ui_tests/playwright/package-lock.json
- name: Build Vite frontend assets
run: |
npm ci
npm run build
- name: Install Playwright dependencies (npm)
working-directory: tests/ui_tests/playwright
run: npm ci
# ``playwright install --with-deps`` downloads ~150 MB of browser
# binaries from the playwright CDN per attempt. Single CDN
# hiccups have failed the whole release pipeline in the past
# (e.g. transient 5xx mid-download); a single retry costs little
# and removes the dominant transient-network failure mode for
# this workflow. Two attempts only — three attempts of a 150 MB
# download starts hiding real outages.
- name: Install Playwright browsers
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
with:
timeout_minutes: 10
max_attempts: 2
retry_on: error
shell: bash
# Install chromium for auth setup step + webkit for Safari tests
command: |
cd tests/ui_tests/playwright
npx playwright install chromium webkit --with-deps
- name: Setup test directories
run: |
mkdir -p data/encrypted_databases
echo "Created data directories for tests"
- name: Start LDR server
env:
CI: true
TEST_ENV: true
FLASK_ENV: testing
LDR_DATA_DIR: ${{ github.workspace }}/data
# The server MUST open test_admin's encrypted DB with the same KDF it
# was created with. init_test_database.py creates it at 1000 iterations
# (LDR_TEST_MODE relaxes the floor to allow it); without these two vars
# here the requested 1000 is below the production floor, so it falls
# back to the 256000 default, the server derives the wrong key, and
# every login fails with a 401 — see #4558, which set these on the init
# step but missed this server step.
LDR_DB_CONFIG_KDF_ITERATIONS: "1000"
LDR_TEST_MODE: "1"
LDR_DISABLE_RATE_LIMITING: true
SECRET_KEY: test-secret-key-for-ci
run: |
export PYTHONPATH="$PWD/src:$PYTHONPATH"
echo "Starting server with LDR_DATA_DIR=$LDR_DATA_DIR"
pdm run python -m local_deep_research.web.app &
SERVER_PID=$!
echo "SERVER_PID=$SERVER_PID" >> "$GITHUB_ENV"
# Wait for server to be ready.
# --connect-timeout/--max-time bound TCP and total request time so a
# hung connection fails fast instead of eating the job timeout.
for i in {1..30}; do
if curl -fsS --connect-timeout 2 --max-time 5 http://127.0.0.1:5000/api/v1/health > /dev/null; then
echo "Server is ready"
break
fi
echo "Waiting for server... ($i/30)"
sleep 2
done
# Verify server is running
if ! curl -fsS --connect-timeout 2 --max-time 5 http://127.0.0.1:5000/api/v1/health > /dev/null; then
echo "Server failed to start"
exit 1
fi
- name: Initialize test database
env:
TEST_ENV: true
LDR_DATA_DIR: ${{ github.workspace }}/data
LDR_DB_CONFIG_KDF_ITERATIONS: "1000"
# Without LDR_TEST_MODE the 1000 is clamped to the production
# minimum (256000), making SQLCipher ~256x slower — see #4430.
LDR_TEST_MODE: "1"
run: |
export PYTHONPATH="$PWD/src:$PYTHONPATH"
pdm run python scripts/ci/init_test_database.py
- name: Register CI test user
working-directory: tests/ui_tests
run: |
npm ci
node register_ci_user.js http://127.0.0.1:5000
- name: Run Mobile Safari Tests
working-directory: tests/ui_tests/playwright
env:
CI: true
TEST_BASE_URL: http://127.0.0.1:5000
TEST_USERNAME: test_admin
TEST_PASSWORD: testpass123
# Safari CI runs a critical subset for fast release gate:
# - all-pages-mobile: overflow, touch targets, nav clearance across all pages
# - auth-pages-mobile: login, register, change-password mobile layouts
# - desktop-layout: sidebar positioning and breakpoint testing
# - interactive-states: advanced options, search, help panels, mobile menus
# - settings-subpages-mobile: settings tab navigation and form layouts
# - mobile-ui-audit: comprehensive mobile UI quality checks
# - settings-mobile-collapse: sections collapse by default on mobile (#4032);
# this is the viewport where the >16384px overflow regression bites
# Note: embedding-settings-dropdown is desktop-only (skips on isMobile),
# so it intentionally only appears in the Desktop Safari filter above.
# Full suite: run locally via `cd tests/ui_tests/playwright && npm test`
# When adding Safari-critical tests, update this filter pattern.
run: |
npx playwright test \
--project="Mobile Safari" \
"all-pages-mobile|auth-pages-mobile|desktop-layout|interactive-states|settings-subpages-mobile|mobile-ui-audit|settings-mobile-collapse"
- name: Upload Playwright Report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-mobile-safari
path: |
tests/ui_tests/playwright/playwright-report/
tests/ui_tests/playwright/test-results/
- name: Stop server
if: always()
run: |
if [ -n "$SERVER_PID" ]; then
kill "$SERVER_PID" || true
fi
pkill -f "python -m local_deep_research.web.app" || true
+219
View File
@@ -0,0 +1,219 @@
name: PR triage labels
# Auto-applies author-class labels at PR open and toggles lifecycle labels
# on review/synchronize events. Labels are defined in .github/labels.yml
# and synced by .github/workflows/labels-sync.yml.
#
# Also applies path-derived topic labels (documentation, tests, docker,
# github-actions, ...) that feed release-notes categorization. These were
# historically applied by the AI code reviewer, which became label-only
# opt-in (#4955), so triage now owns the mechanical subset. Topic labels
# are applied on opened and ready_for_review only, and are additive-only:
# a label a maintainer removed is not re-added on every push, and
# semantic labels (bugfix, feature, security, performance...) stay
# human/AI-applied.
#
# Uses pull_request (not pull_request_target) — fork PRs get a read-only
# token, so label calls return 403. The script catches 403 and continues
# so fork PRs don't show a failing check; maintainers can apply labels
# manually for fork PRs that need them. We accept the no-op behavior on
# forks vs the security cost of pull_request_target running with secrets
# on fork code.
#
# CODEOWNERS list below is mirrored in .github/CODEOWNERS global owners
# (line 6) — keep both in sync when the maintainer roster changes.
on:
pull_request:
types: [opened, synchronize, ready_for_review]
pull_request_review:
types: [submitted, dismissed]
# No concurrency group — intentionally omitted.
# Previous attempt (#3554, reverted #3599) used cancel-in-progress which
# killed in-progress PR runs before they produced useful results. Race
# between synchronize and pull_request_review can produce transient
# label flapping in rare cases; maintainers can correct manually.
permissions: {} # Minimal top-level for OSSF Scorecard Token-Permissions
jobs:
triage:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
issues: write # PR labels live on the underlying issue resource
pull-requests: read # listFiles for path-derived topic labels
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Apply triage labels
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
// Keep CODEOWNERS list in sync with .github/CODEOWNERS line 6.
const CODEOWNERS = ['LearningCircuit', 'hashedviking', 'djpetti'];
// Known AI-bot accounts that don't carry the [bot] suffix.
const KNOWN_BOTS = ['moltenbot000', 'mseep-ai', 'Nexus-Digital-Automations'];
const pr = context.payload.pull_request;
const issueNumber = pr.number;
const author = pr.user.login;
const association = pr.author_association;
const isBot = author.endsWith('[bot]') || KNOWN_BOTS.includes(author);
const isInternal = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(association);
// 403 on label calls is the expected outcome for fork PRs —
// pull_request gives forks a read-only token by design. We
// log and continue so the workflow run stays green and a
// maintainer can apply labels manually instead of seeing
// a red check on every fork contribution.
const isReadOnlyTokenError = (err) => {
if (err.status !== 403) return false;
console.log(`Label call returned 403 (read-only token, likely a fork PR). Skipping.`);
return true;
};
const addLabels = async (labels) => {
if (!labels.length) return;
try {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
labels,
});
} catch (err) {
if (!isReadOnlyTokenError(err)) throw err;
}
};
const removeLabel = async (name) => {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
name,
});
} catch (err) {
if (err.status === 404) return; // label wasn't applied
if (!isReadOnlyTokenError(err)) throw err;
}
};
// Path-derived topic labels for release-notes categorization.
// Mechanical mappings only — a rule belongs here iff the label
// follows from *which files* changed, never from what the
// change means. First-match-per-rule over all changed files.
const TOPIC_RULES = [
[/^\.github\/workflows\//, 'github-actions'],
[/^(\.github\/|\.pre-commit-hooks\/|\.pre-commit-config\.yaml$|scripts\/ci\/)/, 'ci-cd'],
[/^(Dockerfile|docker-compose|cookiecutter-docker\/|unraid-templates\/)/, 'docker'],
[/^(docs\/|[^/]+\.md$)/, 'documentation'],
[/^tests\//, 'tests'],
[/^src\/local_deep_research\/database\//, 'database'],
[/^src\/local_deep_research\/(defaults|settings)\//, 'configuration'],
[/^(pyproject\.toml|pdm\.lock|package\.json|package-lock\.json)$/, 'dependencies'],
[/^src\/local_deep_research\/(advanced_search_system|web_search_engines)\//, 'research'],
[/^src\/.*\.py$/, 'python'],
[/^src\/.*\.(js|mjs)$/, 'javascript'],
[/^src\/.*\.css$/, 'css'],
];
const topicLabels = async () => {
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: issueNumber,
per_page: 100,
});
const labels = new Set();
for (const f of files) {
for (const [pattern, label] of TOPIC_RULES) {
if (pattern.test(f.filename)) labels.add(label);
}
}
return [...labels];
};
const action = context.payload.action;
const eventName = context.eventName;
if (eventName === 'pull_request' && action === 'opened') {
const labels = [];
if (isBot) labels.push('bot');
if (!isInternal && !isBot) labels.push('external-contributor');
if (association === 'FIRST_TIME_CONTRIBUTOR') labels.push('first-time-contributor');
if (!isInternal) labels.push('needs-codeowner-review');
labels.push(...await topicLabels());
await addLabels(labels);
return;
}
if (eventName === 'pull_request' && (action === 'synchronize' || action === 'ready_for_review')) {
const current = pr.labels.map((l) => l.name);
if (current.includes('awaiting-author')) {
await removeLabel('awaiting-author');
await addLabels(['awaiting-codeowner']);
}
// Draft → ready is a deliberate "the diff is final" moment, so
// recompute topic labels once there. Deliberately NOT done on
// synchronize: that would re-add labels a maintainer removed,
// on every push.
if (action === 'ready_for_review') {
await addLabels(await topicLabels());
}
return;
}
if (eventName === 'pull_request_review' && action === 'submitted') {
const review = context.payload.review;
const reviewer = review.user.login;
// Strict CODEOWNERS-only check. The hardcoded list above must
// mirror .github/CODEOWNERS line 6. We deliberately do NOT
// accept any OWNER/MEMBER/COLLABORATOR association as a
// codeowner: branch protection here may later adopt
// require_code_owner_reviews=true, at which point clearing
// needs-codeowner-review on a non-codeowner MEMBER review
// would be a security-relevant mislabel.
if (!CODEOWNERS.includes(reviewer)) return;
if (review.state === 'approved') {
await removeLabel('needs-codeowner-review');
await removeLabel('awaiting-codeowner');
// Also clear awaiting-author: a codeowner can resolve
// their own changes_requested review by approving without
// an intervening synchronize (e.g., the author convinced
// them via comments).
await removeLabel('awaiting-author');
} else if (review.state === 'changes_requested') {
await removeLabel('needs-codeowner-review');
await removeLabel('awaiting-codeowner');
await addLabels(['awaiting-author']);
}
// 'commented' → no-op.
return;
}
if (eventName === 'pull_request_review' && action === 'dismissed') {
// GitHub sets review.state to "dismissed" on this event — the
// original state is not preserved (github/docs#20216). Use the
// awaiting-author label as the discriminator: it's only set by
// a codeowner's changes_requested review, so its presence is
// proof the dismissal is the one we care about. Dismissals of
// approval/comment reviews are naturally no-ops because
// awaiting-author won't be present.
const review = context.payload.review;
if (!CODEOWNERS.includes(review.user.login)) return;
const current = pr.labels.map((l) => l.name);
if (current.includes('awaiting-author')) {
await removeLabel('awaiting-author');
await addLabels(['needs-codeowner-review']);
}
return;
}
+121
View File
@@ -0,0 +1,121 @@
name: Pre-commit Checks
on:
pull_request:
branches: [ main ]
workflow_call: # Called by ci-gate.yml for release pipeline
workflow_dispatch:
# No concurrency group — intentionally omitted.
# This workflow triggers on both pull_request and workflow_call (from
# ci-gate.yml / release-gate.yml). A shared concurrency key would cause
# direct PR runs and workflow_call runs to cancel each other mid-flight.
# See #3554 (reverted in #3599) for context.
permissions:
contents: read
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# Full history so the author-identity hook can resolve the PR commit
# range (merge-base..head) from local objects WITHOUT fetching: a hook
# that mutates git state mid-run trips pre-commit's "files were
# modified by this hook".
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.12'
cache: 'pip'
- name: Set up PDM
uses: pdm-project/setup-pdm@973541a5febeafcfdadf8a51211435be6ecfd90f # v4.5
with:
python-version: '3.12'
cache: true
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24'
- name: Install Node.js dependencies
run: npm ci
- name: Install system dependencies for SQLCipher
run: |
sudo apt-get update
sudo apt-get install -y libsqlcipher-dev
# Replicate pre-commit/action@v3.0.1 manually so the network-flaky hook
# *download* can be retried WITHOUT retrying the actual lint check.
#
# pre-commit lazily downloads hook environments from external sources
# (PyPI for ruff, GitHub release binaries for shellcheck, etc.), and a
# single HTTP 5xx from any one of them fails the whole job. See run #2524
# where `Building wheel for shellcheck_py` hit HTTP 502 fetching the
# shellcheck binary during the wheel build. A second attempt benefits from
# the partially-populated cache and almost always succeeds; two attempts is
# enough (a hook env that fails to install twice in a row is not a
# transient outage).
#
# That download is done up-front by `pre-commit install-hooks`, so only
# THAT step is wrapped in nick-fields/retry. The lint check itself runs
# exactly once: an auto-fixing hook (ruff-format, …) that modifies a file
# makes `pre-commit run` exit non-zero, and that MUST fail the job.
# Retrying the run would mask it — the second pass sees the already-fixed
# tree and exits 0, so unformatted code would sail through with a green
# check.
- name: Compute pre-commit cache key
run: echo "PY=$(python -VV | sha256sum | cut -d' ' -f1)" >> "$GITHUB_ENV"
- name: Cache pre-commit hook environments
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/pre-commit
key: pre-commit|${{ env.PY }}|${{ hashFiles('.pre-commit-config.yaml') }}
- name: Install pre-commit
# Version-pinned (matches pyproject.toml's `pre-commit~=4.5`, lock at
# 4.6.0) for reproducibility, consistent with every other CI tool
# install (checkov, semgrep, pdm, towncrier, …) which all pin `==`.
# NOTE: this is *not* hash-pinned, so Scorecard's Pinned-Dependencies
# check still flags it ("pipCommand not pinned by hash"). That is an
# accepted risk — this is a read-only lint job (contents: read,
# persist-credentials: false), and --require-hashes would mean pinning
# all transitive deps and regenerating on every (automated) bump.
# Tracked as dismissed Scorecard alert #7777 ("won't fix").
run: python -m pip install pre-commit==4.6.0
# Retry ONLY the hook-environment download (the network-flaky step).
- name: Install pre-commit hook environments
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
with:
timeout_minutes: 15
max_attempts: 2
retry_on: error
shell: bash
command: pre-commit install-hooks
# Run the checks exactly once — NO retry. An auto-fixing hook that
# modifies a file must fail the job, not be masked by a clean re-run.
# timeout-minutes restores the 15-minute ceiling the retry wrapper used to
# put on this step (a hung hook is otherwise bounded only by GitHub's
# 6-hour job default); install-hooks keeps its own retry timeout above.
- name: Run pre-commit
timeout-minutes: 15
run: pre-commit run --show-diff-on-failure --color=always --all-files
+512
View File
@@ -0,0 +1,512 @@
name: Prerelease Docker Image
# Build the canonical Docker image for a release. In the build-once-promote
# pipeline, this workflow IS the build — docker-publish.yml only retags the
# manifest produced here. Cosign signing, SBOM attestation, and SLSA
# provenance are attached here once, keyed by manifest digest, so they're
# discoverable from any tag (including the release tags later created by
# imagetools create).
#
# Triggered exclusively via workflow_call from release.yml (after security
# gates pass). No workflow_dispatch — security and gate semantics are
# enforced by the caller. The build runs automatically; the only human
# approval in the release flow is the `release` env on this workflow's
# jobs + publish-docker + trigger-pypi + create-release in release.yml
# (gates the actual publish, not the canonical build).
on:
workflow_call:
inputs:
version:
description: "Bare semver, e.g. '1.6.9' (no leading 'v')"
type: string
required: true
short_sha:
description: "First 7 chars of commit SHA (used in the prerelease tag)"
type: string
required: true
secrets:
# Explicit secrets contract instead of `secrets: inherit` on the
# caller side — narrower blast radius if a future caller misuses
# this reusable workflow.
DOCKER_USERNAME:
required: true
description: "Docker Hub username for image push"
DOCKER_PASSWORD:
required: true
description: "Docker Hub PAT (Read+Write+Delete scopes)"
outputs:
manifest_digest:
description: "sha256:... digest of the multi-arch prerelease manifest. Used by docker-publish.yml to verify retag preserves the digest."
value: ${{ jobs.create-manifest.outputs.digest }}
permissions: {} # Minimal top-level for OSSF Scorecard Token-Permissions
# No approval gate at the build step — the build runs automatically once
# security gates and CI gates in release.yml pass. The only meaningful
# human decision in the release flow is "should this signed, attested,
# tested image become the official release?" — gated by the `release`
# environment on this workflow's jobs + `publish-docker` + `trigger-pypi`
# + `create-release` in release.yml. The maintainer can pull
# `:prerelease-v<ver>-<sha>` and smoke-test between build completion
# and approving the release env.
jobs:
build-amd64:
name: Build AMD64 Prerelease Image
runs-on: ubuntu-latest
environment: release
permissions:
contents: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Check out the repo
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log in to Docker Hub
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push AMD64 image
id: build
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
platforms: linux/amd64
push: true
tags: ${{ secrets.DOCKER_USERNAME }}/local-deep-research:prerelease-v${{ inputs.version }}-${{ inputs.short_sha }}-amd64
cache-from: type=gha,scope=linux-amd64
cache-to: type=gha,mode=max,scope=linux-amd64
build-arm64:
name: Build ARM64 Prerelease Image
runs-on: ubuntu-24.04-arm
environment: release
permissions:
contents: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Check out the repo
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log in to Docker Hub
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push ARM64 image
id: build
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
platforms: linux/arm64
push: true
tags: ${{ secrets.DOCKER_USERNAME }}/local-deep-research:prerelease-v${{ inputs.version }}-${{ inputs.short_sha }}-arm64
cache-from: type=gha,scope=linux-arm64
cache-to: type=gha,mode=max,scope=linux-arm64
security-scan:
name: Security Scan
runs-on: ubuntu-latest
environment: release
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Check out the repo
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0
- name: Free disk space
run: |
sudo rm -rf /usr/share/dotnet || true
sudo rm -rf /usr/local/lib/android || true
sudo rm -rf /opt/ghc || true
sudo rm -rf /opt/hostedtoolcache/CodeQL || true
sudo docker image prune --all --force || true
df -h
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Build Docker image for security scan
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
platforms: linux/amd64
push: false
load: true
tags: local-deep-research:security-scan
cache-from: type=gha,scope=linux-amd64
cache-to: type=gha,mode=max,scope=linux-amd64
# Generate Trivy SARIF for archival as a workflow artifact (all severities, never fails).
# Severity-gating happens in the next step.
- name: Generate Trivy SARIF report
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: local-deep-research:security-scan
format: 'sarif'
output: 'trivy-prerelease-scan.sarif'
ignore-unfixed: true
exit-code: '0'
version: 'v0.69.2'
# Separate scan that fails build only on fixable HIGH/CRITICAL vulnerabilities
- name: Check for fixable HIGH/CRITICAL vulnerabilities
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: local-deep-research:security-scan
severity: 'CRITICAL,HIGH'
ignore-unfixed: true
trivyignores: '.trivyignore'
exit-code: '1'
version: 'v0.69.2'
- name: Upload Trivy scan results
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: trivy-prerelease-scan
path: trivy-prerelease-scan.sarif
retention-days: 7
create-manifest:
name: Create Multi-Platform Prerelease Manifest
needs: [build-amd64, build-arm64, security-scan]
runs-on: ubuntu-latest
environment: release
permissions:
contents: read
id-token: write # Required for cosign keyless OIDC signing
# No `packages: write` — Docker Hub auth uses DOCKER_PASSWORD secret,
# not GITHUB_TOKEN. `packages: write` only matters for ghcr.io pushes.
outputs:
digest: ${{ steps.capture-digest.outputs.digest }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Check out the repo
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log in to Docker Hub
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
# Pin to cosign v2.x — see release.yml for rationale (v3 enables
# --new-bundle-format by default which changes the on-wire format
# and breaks downstream verifiers still on v2).
cosign-release: 'v2.6.3'
- name: Install Syft
uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
- name: Create and push multi-platform manifest
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
VERSION: ${{ inputs.version }}
SHORT_SHA: ${{ inputs.short_sha }}
run: |
set -euo pipefail
TAG="prerelease-v${VERSION}-${SHORT_SHA}"
echo "Creating manifest for: ${DOCKER_USERNAME}/local-deep-research:${TAG}"
docker buildx imagetools create -t "${DOCKER_USERNAME}/local-deep-research:${TAG}" \
"${DOCKER_USERNAME}/local-deep-research:${TAG}-amd64" \
"${DOCKER_USERNAME}/local-deep-research:${TAG}-arm64"
echo "Manifest created successfully"
# Floating tag: re-point :prerelease at the manifest just created so
# testers can pin compose to `:prerelease` and pull the latest RC via
# `docker compose pull` without editing the tag each cycle. The
# versioned tag above remains for reproducibility (and is what
# docker-publish.yml retags by digest into :1.6.9 / :1.6 / :latest).
echo "Updating floating tag: ${DOCKER_USERNAME}/local-deep-research:prerelease"
docker buildx imagetools create -t "${DOCKER_USERNAME}/local-deep-research:prerelease" \
"${DOCKER_USERNAME}/local-deep-research:${TAG}"
echo "Floating :prerelease tag updated"
- name: Capture manifest digest
id: capture-digest
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
VERSION: ${{ inputs.version }}
SHORT_SHA: ${{ inputs.short_sha }}
run: |
set -euo pipefail
IMAGE_REF="${DOCKER_USERNAME}/local-deep-research:prerelease-v${VERSION}-${SHORT_SHA}"
# Same form as the existing docker-publish.yml inspector — avoids jq.
DIGEST=$(docker buildx imagetools inspect "$IMAGE_REF" --format '{{json .Manifest.Digest}}' | tr -d '"')
if [[ -z "$DIGEST" || "$DIGEST" != sha256:* ]]; then
echo "::error::Failed to capture manifest digest (got '${DIGEST}')"
exit 1
fi
echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT"
echo "Manifest digest: ${DIGEST}"
- name: Sign manifest with Cosign
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DIGEST: ${{ steps.capture-digest.outputs.digest }}
run: |
set -euo pipefail
# Sign by digest — signature artifact lands at sha256-<digest>.sig
# in the same repo, discoverable from ANY tag pointing at the same
# digest (including release tags created later by docker-publish.yml's
# imagetools-create retag).
IMAGE_REF="${DOCKER_USERNAME}/local-deep-research@${DIGEST}"
echo "Signing image by digest: $IMAGE_REF"
cosign sign --yes "$IMAGE_REF"
# Brief sleep to allow registry to propagate signature
sleep 5
- name: Generate SLSA provenance attestation
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DIGEST: ${{ steps.capture-digest.outputs.digest }}
run: |
set -euo pipefail
IMAGE_REF="${DOCKER_USERNAME}/local-deep-research@${DIGEST}"
# entryPoint is the TOP-LEVEL caller (release.yml), not this
# reusable workflow. Per SLSA GHA buildtype v1 and the canonical
# slsa-github-generator, reusable workflows are explicitly NOT
# entryPoints. github.run_id / github.repository / github.sha all
# resolve to the caller's run context inside a reusable workflow.
# builder.id pins the workflow that actually defines the build
# steps — the trust root a verifier policy can pin against. We
# compose it from `github.repository` and a hardcoded path to
# THIS workflow file, with `github.ref` for the ref portion.
# Rationale: inside a workflow_call callee, the `github` context
# is scoped to the CALLER, so `github.workflow_ref` would point
# at release.yml (the wrong builder). The `job` context has no
# `workflow_ref` property either (only check_run_id, container,
# services, status — actionlint confirms). For a local-path
# reusable workflow (`uses: ./.github/workflows/...`), the
# callee's ref equals the caller's `github.ref`, so composing
# the path manually gives the correct
# `<owner>/<repo>/.github/workflows/prerelease-docker.yml@<ref>`
# format that matches the Fulcio cert SAN. Cosign and
# slsa-verifier both anchor on the cert anyway, so this fix is
# about correctness for raw-JSON policy engines / audit tools
# that read builder.id directly.
#
# completeness.* are FALSE because we don't capture invocation
# parameters or environment, and the build does network I/O for
# apt/pip/npm. Honest emptiness > false claims of completeness.
#
# buildInvocationId includes run_attempt so re-runs are
# distinguishable in audit logs.
#
# Uses auto-injected GITHUB_* shell env vars (GITHUB_REPOSITORY,
# GITHUB_REF, GITHUB_SHA, GITHUB_RUN_ID, GITHUB_RUN_ATTEMPT) instead
# of github-context template expansion to satisfy zizmor's
# template-injection check. The values are semantically identical
# inside a workflow_call callee (both scope to the caller's context),
# but shell-var expansion happens at runtime in a confined string
# context, eliminating any theoretical injection surface from a
# ref/branch name containing shell metacharacters.
cat > provenance.json <<EOF
{
"buildType": "https://github.com/${GITHUB_REPOSITORY}/docker-build@v1",
"builder": {
"id": "https://github.com/${GITHUB_REPOSITORY}/.github/workflows/prerelease-docker.yml@${GITHUB_REF}"
},
"invocation": {
"configSource": {
"uri": "https://github.com/${GITHUB_REPOSITORY}",
"digest": {
"sha1": "${GITHUB_SHA}"
},
"entryPoint": ".github/workflows/release.yml"
}
},
"metadata": {
"buildInvocationId": "${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}",
"buildStartedOn": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"completeness": {
"parameters": false,
"environment": false,
"materials": false
},
"reproducible": false
},
"materials": [
{
"uri": "https://github.com/${GITHUB_REPOSITORY}",
"digest": {
"sha1": "${GITHUB_SHA}"
}
}
]
}
EOF
# --replace prevents duplicate SLSA attestations on re-run. Cosign's
# Replace logic is keyed by predicate-type URI, so it leaves the
# SBOM SPDX attestation (different predicateType) untouched.
cosign attest --yes --replace --predicate provenance.json --type slsaprovenance "$IMAGE_REF"
- name: Verify image signature
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DIGEST: ${{ steps.capture-digest.outputs.digest }}
run: |
set -euo pipefail
IMAGE_REF="${DOCKER_USERNAME}/local-deep-research@${DIGEST}"
echo "Verifying signature for: $IMAGE_REF"
# Retry to handle registry propagation delay after signing
MAX_RETRIES=5
RETRY_DELAY=10
for i in $(seq 1 "$MAX_RETRIES"); do
echo "Verification attempt $i of $MAX_RETRIES..."
# Use GITHUB_REPOSITORY shell env var (auto-injected) instead of
# github.repository template expansion to avoid zizmor's
# template-injection finding.
if cosign verify \
--certificate-identity-regexp="https://github.com/${GITHUB_REPOSITORY}" \
--certificate-oidc-issuer=https://token.actions.githubusercontent.com \
"$IMAGE_REF"; then
echo "Signature verification successful!"
exit 0
fi
if [ "$i" -lt "$MAX_RETRIES" ]; then
echo "Verification failed, waiting ${RETRY_DELAY}s before retry..."
sleep "$RETRY_DELAY"
fi
done
echo "::error::Signature verification failed after $MAX_RETRIES attempts"
exit 1
- name: Generate per-platform SBOMs and attest each
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DIGEST: ${{ steps.capture-digest.outputs.digest }}
run: |
set -euo pipefail
REPO="${DOCKER_USERNAME}/local-deep-research"
MANIFEST_REF="${REPO}@${DIGEST}"
# Multi-arch SBOM correctness: syft against a manifest list digest
# only scans the host platform's layers (per anchore/syft#1708),
# which would lie to ARM64 consumers. We attest each per-arch
# digest with its OWN SBOM so end-user verification is honest.
# We deliberately do NOT also produce a "manifest-level SBOM" —
# that would be amd64-only (host arch) and re-introduce the lie
# for any arm64 consumer running the README verifier recipe.
# The README documents the per-arch verification flow instead.
MANIFEST_JSON=$(docker buildx imagetools inspect "${MANIFEST_REF}" --raw)
# Defense against future buildx output changes: assert at least
# one per-arch entry exists. Without this, an empty/malformed
# manifest list would silently produce zero SBOMs and pass CI green.
PER_ARCH_COUNT=$(echo "${MANIFEST_JSON}" \
| jq '[.manifests[] | select(.platform.architecture != "unknown")] | length')
if [[ "${PER_ARCH_COUNT}" -lt 1 ]]; then
echo "::error::No per-arch manifest entries found in ${MANIFEST_REF} — SBOM generation cannot proceed"
echo "Raw manifest: ${MANIFEST_JSON}"
exit 1
fi
echo "Found ${PER_ARCH_COUNT} per-arch manifest(s) to scan"
echo "$MANIFEST_JSON" \
| jq -r '.manifests[] | select(.platform.architecture != "unknown") | "\(.platform.os)/\(.platform.architecture)\t\(.digest)"' \
| while IFS=$'\t' read -r PLAT PER_ARCH_DIGEST; do
ARCH="${PLAT##*/}"
PER_ARCH_REF="${REPO}@${PER_ARCH_DIGEST}"
SBOM_FILE="sbom-${ARCH}.spdx.json"
echo "=== Scanning ${PLAT} (${PER_ARCH_DIGEST}) ==="
# --platform tells syft which arch to scan — matters when
# the host runner can't natively execute the image.
syft --platform "${PLAT}" "${PER_ARCH_REF}" -o spdx-json > "${SBOM_FILE}"
# --replace prevents accumulation when a re-run lands on
# the same digest (e.g. "Re-run failed jobs" after a flake).
# Per cosign source pkg/cosign/remote/remote.go, --replace
# is per-predicate-type, so it doesn't disturb the SLSA
# attestation already on the manifest list digest.
cosign attest --yes --replace \
--predicate "${SBOM_FILE}" --type spdxjson "${PER_ARCH_REF}"
done
- name: Upload SBOMs artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: sbom
# Per-arch SBOMs only — `sbom-amd64.spdx.json`, `sbom-arm64.spdx.json`,
# one per platform in the manifest list. No manifest-level SBOM is
# produced (would be host-arch-only and misleading for non-amd64
# consumers).
path: sbom-*.spdx.json
retention-days: 90
- name: Summary
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
VERSION: ${{ inputs.version }}
SHORT_SHA: ${{ inputs.short_sha }}
DIGEST: ${{ steps.capture-digest.outputs.digest }}
run: |
TAG="prerelease-v${VERSION}-${SHORT_SHA}"
{
echo "## Prerelease Docker Image"
echo ""
echo "**Versioned tag:** \`${TAG}\`"
echo "**Floating tag:** \`prerelease\` (now points at this build)"
echo ""
echo "**Digest:** \`${DIGEST}\`"
echo ""
echo '```'
echo "docker pull ${DOCKER_USERNAME}/local-deep-research:${TAG}"
echo "docker pull ${DOCKER_USERNAME}/local-deep-research:prerelease"
echo '```'
echo ""
echo "Signed and attested. After release approval, docker-publish.yml"
echo "will retag this exact digest as \`:${VERSION}\`, \`:major.minor\`,"
echo "and \`:latest\`."
} >> "$GITHUB_STEP_SUMMARY"
+625
View File
@@ -0,0 +1,625 @@
name: Publish to PyPI
# SECURITY: Only triggered via repository_dispatch from release.yml
# (after security gate passes). We intentionally do NOT support
# workflow_dispatch because that would bypass security checks.
# To re-publish, trigger a new release through release.yml.
#
# REPRODUCIBILITY: repository_dispatch runs on default-branch HEAD at
# dispatch time — NOT the release commit. The `release` env approval is
# human-paced, so main can move between the release commit and the
# dispatch (on v1.9.0 it was 4 PRs ahead, and PyPI shipped code absent
# from the tag/Docker image). Both build jobs therefore check out
# `client_payload.sha` — the exact commit every gate ran against — and
# fail closed if the dispatcher didn't pin it.
on:
repository_dispatch:
types: [publish-pypi]
permissions: {} # Minimal top-level for OSSF Scorecard Token-Permissions
jobs:
# Build frontend if package.json exists (isolated, no secrets)
build-frontend:
runs-on: ubuntu-latest
outputs:
has-frontend: ${{ steps.check.outputs.has-frontend }}
container:
# Node 24 to match `package.json`'s `engines: { node: ">=24.0.0" }`.
# Was previously node:20 — npm could resolve dependencies that target
# APIs missing on 20 and the wheel-building publish path could ship
# frontend assets that break at runtime on the Node-24 Docker image.
image: node:24-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f # node:24-alpine
# Note: Network is needed for npm ci to work, but no secrets are available
options: --user 1001
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Validate pinned release commit
# POSIX sh (this job's container is Alpine — no bash). Fail closed:
# without a full 40-hex SHA, checkout would silently fall back to
# default-branch HEAD and reintroduce the drift this pin prevents.
# The length test matters: grep -E matches PER LINE, so a crafted
# multiline value ("<40-hex>\n<junk>") would pass the regex alone;
# ${#VAR} counts bytes in POSIX sh, so -eq 40 also excludes any
# embedded newline. Ancestry (sha actually on main) is enforced in
# build-package — this container has no python3/gh; this job holds
# no secrets and its output only reaches PyPI through build-package,
# which checks out the same validated sha.
env:
RELEASE_SHA: ${{ github.event.client_payload.sha }}
run: |
if [ "${#RELEASE_SHA}" -ne 40 ] || ! printf '%s' "$RELEASE_SHA" | grep -qE '^[0-9a-f]{40}$'; then
echo "::error::client_payload.sha is missing or not a full commit SHA — refusing to build from default-branch HEAD. The dispatcher (release.yml trigger-pypi) must pin the release commit."
exit 1
fi
echo "Building from pinned release commit $RELEASE_SHA"
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.client_payload.sha }}
persist-credentials: false
- name: Check for frontend assets
id: check
run: |
if [ -f "package.json" ]; then
echo "has-frontend=true" >> "$GITHUB_OUTPUT"
echo "Found package.json - will build frontend"
else
echo "has-frontend=false" >> "$GITHUB_OUTPUT"
echo "ERROR: No package.json found - frontend build is required for PyPI releases"
exit 1
fi
- name: Build frontend
if: steps.check.outputs.has-frontend == 'true'
run: |
echo "=== Frontend Build Process Starting ==="
echo "Current directory: $(pwd)"
echo "Node version: $(node --version)"
echo "NPM version: $(npm --version)"
# Install dependencies from root package.json
echo ""
echo "📦 Installing dependencies..."
# Use npm ci for reproducible builds from lockfile
npm ci --ignore-scripts --no-audit --no-fund
echo "✅ Dependencies installed"
# Show pre-build directory structure
echo ""
echo "📁 Pre-build directory structure:"
echo "Contents of src/local_deep_research/web/static/:"
find src/local_deep_research/web/static/ -maxdepth 1 -type f -exec ls -la {} + 2>/dev/null || echo "Directory not found"
# Build with Vite (outputs to src/local_deep_research/web/static/dist)
echo ""
echo "🔨 Running Vite build..."
npm run build
echo "✅ Vite build completed"
# Show post-build directory structure
echo ""
echo "📁 Post-build directory structure:"
echo "Contents of src/local_deep_research/web/static/:"
find src/local_deep_research/web/static/ -maxdepth 1 -type f -exec ls -la {} + 2>/dev/null
echo ""
echo "Contents of src/local_deep_research/web/static/dist/:"
find src/local_deep_research/web/static/dist/ -maxdepth 1 -type f -exec ls -la {} + 2>/dev/null
echo ""
echo "Looking for manifest.json:"
find src/local_deep_research/web/static/dist -name "manifest.json" -type f 2>/dev/null || echo "No manifest.json found"
# Verify the build completed successfully
if [ ! -f "src/local_deep_research/web/static/dist/.vite/manifest.json" ]; then
echo "❌ ERROR: Build failed - manifest.json not created at expected location"
echo "Expected location: src/local_deep_research/web/static/dist/.vite/manifest.json"
echo "Actual dist contents:"
find src/local_deep_research/web/static/dist -type f | head -20
exit 1
fi
echo "✅ Found manifest.json at: src/local_deep_research/web/static/dist/.vite/manifest.json"
echo "Manifest contents (first 10 lines):"
head -10 src/local_deep_research/web/static/dist/.vite/manifest.json
# Create build marker
echo ""
echo "📝 Creating build marker..."
echo "{\"status\":\"complete\",\"built\":\"$(date -Iseconds)\"}" > src/local_deep_research/web/static/.frontend-built
echo "✅ Build marker created"
- name: Upload frontend artifacts
if: steps.check.outputs.has-frontend == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: frontend-assets
path: src/local_deep_research/web/static/
retention-days: 1
include-hidden-files: true # Ensure .vite directory is included
# Build Python package (isolated, no PyPI access)
build-package:
needs: build-frontend
runs-on: ubuntu-latest
container:
image: python:3.12-slim@sha256:971f04b358cf483ec445a8d388fb55267451f080d90fb136c8e69684a02a9604 # python:3.12-slim
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Validate pinned release commit
# Same fail-closed shape guard as build-frontend (length test: see
# comment there), plus the ANCESTRY check that only this job can
# run (python3 in the container): the pinned sha must be reachable
# from main. Anyone able to send repository_dispatch (repo write /
# the PAT holder) chooses the payload — without this check they
# could point the build at ANY commit object in the repo network
# (scratch branches, fork-PR heads) that never passed review or the
# release gates. release.yml's own build job verifies its sha is on
# main, but a forged dispatch bypasses release.yml entirely; the
# `release` environment approval on the publish job is then the
# only barrier, and an approver has no reason to suspect a
# legitimate-looking pending deployment. Enforced here (not in
# build-frontend) because every byte that reaches PyPI flows
# through this job's checkout.
env:
RELEASE_SHA: ${{ github.event.client_payload.sha }}
GH_TOKEN: ${{ github.token }}
run: |
if [ "${#RELEASE_SHA}" -ne 40 ] || ! printf '%s' "$RELEASE_SHA" | grep -qE '^[0-9a-f]{40}$'; then
echo "::error::client_payload.sha is missing or not a full commit SHA — refusing to build from default-branch HEAD. The dispatcher (release.yml trigger-pypi) must pin the release commit."
exit 1
fi
python3 - <<'PYEOF'
import json
import os
import sys
import urllib.request
sha = os.environ["RELEASE_SHA"]
repo = os.environ["GITHUB_REPOSITORY"]
req = urllib.request.Request(
f"https://api.github.com/repos/{repo}/compare/{sha}...heads/main",
headers={
"Authorization": f"Bearer {os.environ['GH_TOKEN']}",
"Accept": "application/vnd.github+json",
},
)
try:
with urllib.request.urlopen(req) as resp:
status = json.load(resp)["status"]
except urllib.error.HTTPError as exc:
# 404 = sha unknown to the repo network. Fail closed either way.
print(f"::error::compare API rejected sha {sha}: HTTP {exc.code}")
sys.exit(1)
# base=sha, head=main: main being "ahead" of (or "identical" to)
# the sha means the sha is an ancestor of main. "diverged" or
# "behind" means it is not.
if status not in ("ahead", "identical"):
print(
f"::error::pinned sha {sha} is not an ancestor of main "
f"(compare status: {status}) — refusing to build unreviewed code"
)
sys.exit(1)
print(f"Pinned sha is on main (compare status: {status})")
PYEOF
echo "Building from pinned release commit $RELEASE_SHA"
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.client_payload.sha }}
persist-credentials: false
- name: Install build dependencies
run: |
apt-get update && apt-get install -y libsqlcipher-dev build-essential
pip install pdm==2.26.2
- name: Download frontend artifacts
if: needs.build-frontend.outputs.has-frontend == 'true'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
continue-on-error: true
with:
name: frontend-assets
path: src/local_deep_research/web/static/
- name: Verify frontend build artifacts
run: |
echo "=== Frontend Artifacts Verification ==="
echo "Current directory: $(pwd)"
echo ""
echo "📁 Checking downloaded artifacts structure:"
echo "Contents of src/:"
find src/ -maxdepth 1 -type f -exec ls -la {} + 2>/dev/null || echo "src/ directory not found"
echo ""
echo "Contents of src/local_deep_research/:"
find src/local_deep_research/ -maxdepth 1 -type f -exec ls -la {} + 2>/dev/null || echo "src/local_deep_research/ not found"
echo ""
echo "Contents of src/local_deep_research/web/:"
find src/local_deep_research/web/ -maxdepth 1 -type f -exec ls -la {} + 2>/dev/null || echo "src/local_deep_research/web/ not found"
echo ""
echo "Contents of src/local_deep_research/web/static/:"
find src/local_deep_research/web/static/ -maxdepth 1 -type f -exec ls -la {} + 2>/dev/null || echo "src/local_deep_research/web/static/ not found"
# Check if dist directory exists
echo ""
echo "🔍 Checking for dist directory..."
if [ ! -d "src/local_deep_research/web/static/dist" ]; then
echo "❌ ERROR: Frontend dist directory not found!"
echo "Expected location: src/local_deep_research/web/static/dist"
echo "Actual structure of static directory:"
find src/local_deep_research/web/static -type d 2>/dev/null | head -20
echo "The frontend build artifacts are missing."
exit 1
fi
echo "✅ Found dist directory at: src/local_deep_research/web/static/dist"
# Show dist directory structure
echo ""
echo "📁 Dist directory structure:"
echo "Contents of dist/:"
find src/local_deep_research/web/static/dist/ -maxdepth 1 -type f -exec ls -la {} + 2>/dev/null
echo ""
echo "All files in dist (recursive):"
find src/local_deep_research/web/static/dist -type f | head -20
# Check for critical files
echo ""
echo "🔍 Checking for manifest.json..."
# Check both possible locations for manifest.json
MANIFEST_PATH=""
if [ -f "src/local_deep_research/web/static/dist/.vite/manifest.json" ]; then
MANIFEST_PATH="src/local_deep_research/web/static/dist/.vite/manifest.json"
elif [ -f "src/local_deep_research/web/static/dist/manifest.json" ]; then
MANIFEST_PATH="src/local_deep_research/web/static/dist/manifest.json"
fi
if [ -z "$MANIFEST_PATH" ]; then
echo "❌ ERROR: Vite manifest.json not found!"
echo "Checked locations:"
echo " - src/local_deep_research/web/static/dist/.vite/manifest.json"
echo " - src/local_deep_research/web/static/dist/manifest.json"
echo "Looking for any manifest.json files:"
find src/local_deep_research/web/static -name "manifest.json" -type f 2>/dev/null || echo "None found"
echo "Checking if .vite directory exists:"
find src/local_deep_research/web/static/dist/ -maxdepth 1 -name ".*" -type d || echo "No hidden directories found"
echo ""
echo "This is likely due to artifact transfer losing the hidden .vite directory."
echo "The include-hidden-files option should fix this."
echo "The frontend build appears to be incomplete - cannot continue."
exit 1
fi
echo "✅ Found manifest.json at: $MANIFEST_PATH"
echo "Manifest size: $(wc -c < "$MANIFEST_PATH") bytes"
# Check for JS files
echo ""
echo "🔍 Checking for JavaScript files..."
JS_COUNT=$(find src/local_deep_research/web/static/dist -name "*.js" -type f | wc -l)
if [ "$JS_COUNT" -eq 0 ]; then
echo "❌ ERROR: No JavaScript files found!"
echo "Expected JS files in dist/"
echo "The frontend build appears to be incomplete."
exit 1
fi
echo "✅ Found $JS_COUNT JavaScript file(s)"
echo "JS files:"
find src/local_deep_research/web/static/dist -name "*.js" -type f | head -5
# Check for CSS files
echo ""
echo "🔍 Checking for CSS files..."
CSS_COUNT=$(find src/local_deep_research/web/static/dist -name "*.css" -type f | wc -l)
if [ "$CSS_COUNT" -eq 0 ]; then
echo "❌ ERROR: No CSS files found!"
echo "Expected CSS files in dist/"
echo "The frontend build appears to be incomplete."
exit 1
fi
echo "✅ Found $CSS_COUNT CSS file(s)"
echo "CSS files:"
find src/local_deep_research/web/static/dist -name "*.css" -type f | head -5
# Check for build marker
echo ""
echo "🔍 Checking for build marker..."
if [ ! -f "src/local_deep_research/web/static/.frontend-built" ]; then
echo "⚠️ WARNING: Frontend build marker not found (non-critical)"
else
echo "✅ Found build marker"
echo "Build marker contents:"
cat src/local_deep_research/web/static/.frontend-built
fi
echo ""
echo "=== ✅ Frontend build artifacts verified successfully ==="
echo "Summary:"
echo " - Dist directory: ✓"
echo " - Manifest.json: ✓"
echo " - JS files: $JS_COUNT file(s)"
echo " - CSS files: $CSS_COUNT file(s)"
- name: Build Python package
run: |
echo "=== Python Package Build ==="
echo "Current directory: $(pwd)"
echo ""
echo "📦 Building Python package with PDM..."
pdm build
echo ""
echo "✅ Package build completed"
echo ""
echo "📁 Package contents:"
find dist/ -maxdepth 1 -type f -exec ls -la {} + 2>/dev/null
echo ""
echo "Package sizes:"
du -h dist/*
- name: Upload package
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: python-dist
path: dist/
retention-days: 1
# Publish (ONLY job with PyPI access)
publish:
needs: build-package
runs-on: ubuntu-latest
environment: release
permissions:
id-token: write
attestations: write # Required for generating attestations
steps:
- name: Harden Runner
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Download package
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: python-dist
path: dist/
- name: List packages
run: |
echo "=== Downloaded Package Artifacts ==="
echo "📦 Packages in dist/:"
ls -lh dist/
echo ""
echo "Package details:"
for file in dist/*; do
echo " - $(basename "$file"): $(du -h "$file" | cut -f1)"
done
- name: Verify package contents
run: |
echo "=== Package Content Verification ==="
echo "Verifying package contains frontend assets..."
echo ""
# Install wheel to inspect package
echo "📦 Installing wheel for package inspection..."
pip install wheel==0.46.2
# Find the wheel file
WHEEL_FILE=$(find dist -name "*.whl" -type f 2>/dev/null | head -1)
TAR_FILE=$(find dist -name "*.tar.gz" -type f 2>/dev/null | head -1)
if [ -z "$WHEEL_FILE" ]; then
echo "❌ ERROR: No wheel file found in dist/"
echo "Found files:"
find dist/ -maxdepth 1 -type f -exec ls -la {} + 2>/dev/null
exit 1
fi
echo "✅ Found wheel file: $WHEEL_FILE"
echo "Wheel size: $(du -h "$WHEEL_FILE" | cut -f1)"
echo ""
if [ -n "$TAR_FILE" ]; then
echo "✅ Found source distribution: $TAR_FILE"
echo "Source dist size: $(du -h "$TAR_FILE" | cut -f1)"
fi
echo ""
echo "🔍 Inspecting wheel contents..."
# Extract and check for frontend files
python -m zipfile -l "$WHEEL_FILE" > wheel_contents.txt
TOTAL_FILES=$(wc -l < wheel_contents.txt)
echo "Total files in wheel: $TOTAL_FILES"
echo ""
# Count different file types
echo "📊 File type breakdown:"
echo " Python files: $(grep -c '\.py' wheel_contents.txt || echo 0)"
echo " JavaScript files: $(grep -c '\.js' wheel_contents.txt || echo 0)"
echo " CSS files: $(grep -c '\.css' wheel_contents.txt || echo 0)"
echo " JSON files: $(grep -c '\.json' wheel_contents.txt || echo 0)"
echo " HTML files: $(grep -c '\.html' wheel_contents.txt || echo 0)"
echo ""
# Check for critical frontend directories/files
echo "🔍 Checking for frontend assets..."
echo "Looking for dist directory..."
if ! grep -q "local_deep_research/web/static/dist/" wheel_contents.txt; then
echo "❌ ERROR: Frontend dist directory not found in package!"
echo "Package is missing frontend build artifacts."
echo "Static files found in package:"
grep "static" wheel_contents.txt | head -10 || echo "No static files found"
exit 1
fi
echo "✅ Found dist directory"
echo ""
echo "Looking for JavaScript files..."
JS_IN_WHEEL=$(grep -c "local_deep_research/web/static/dist.*\.js" wheel_contents.txt || echo 0)
if [ "$JS_IN_WHEEL" -eq 0 ]; then
echo "❌ ERROR: Frontend JS files not found in package!"
echo "JavaScript files in package:"
grep "\.js" wheel_contents.txt | head -10 || echo "No JS files found"
exit 1
fi
echo "✅ Found $JS_IN_WHEEL JavaScript file(s) in dist"
echo ""
echo "Looking for CSS files..."
CSS_IN_WHEEL=$(grep -c "local_deep_research/web/static/dist.*\.css" wheel_contents.txt || echo 0)
if [ "$CSS_IN_WHEEL" -eq 0 ]; then
echo "❌ ERROR: Frontend CSS files not found in package!"
echo "CSS files in package:"
grep "\.css" wheel_contents.txt | head -10 || echo "No CSS files found"
exit 1
fi
echo "✅ Found $CSS_IN_WHEEL CSS file(s) in dist"
echo ""
echo "Looking for manifest.json..."
if ! grep -q "local_deep_research/web/static/dist/.vite/manifest.json" wheel_contents.txt; then
echo "⚠️ WARNING: manifest.json not found in package (may be okay)"
else
echo "✅ Found manifest.json"
fi
echo ""
echo "=== ✅ Package verification successful ==="
echo "Summary:"
echo " - Wheel file: ✓"
echo " - Frontend dist: ✓"
echo " - JS files: $JS_IN_WHEEL file(s)"
echo " - CSS files: $CSS_IN_WHEEL file(s)"
echo ""
echo "Sample frontend assets in package:"
grep "local_deep_research/web/static/dist/" wheel_contents.txt | head -10
- name: Generate attestations
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-path: 'dist/*'
- name: Pre-publish summary
env:
EVENT_NAME: ${{ github.event_name }}
DISPATCH_TAG: ${{ github.event.client_payload.tag }}
DISPATCH_PRERELEASE: ${{ github.event.client_payload.prerelease }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
RELEASE_PRERELEASE: ${{ github.event.release.prerelease }}
run: |
echo "=== 🚀 Ready to Publish ==="
echo ""
echo "📦 Package Information:"
# Handle both release event and repository_dispatch
if [ "$EVENT_NAME" = "repository_dispatch" ]; then
echo " - Trigger: repository_dispatch"
echo " - Tag: $DISPATCH_TAG"
echo " - Prerelease: $DISPATCH_PRERELEASE"
else
echo " - Trigger: release"
echo " - Tag: $RELEASE_TAG"
echo " - Prerelease: $RELEASE_PRERELEASE"
fi
echo ""
echo "📁 Package files to publish:"
ls -lh dist/
echo ""
echo "✅ All checks passed - proceeding with publication"
- name: Publish to Test PyPI
if: (github.event_name == 'release' && github.event.release.prerelease == true) || (github.event_name == 'repository_dispatch' && github.event.client_payload.prerelease == true)
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
with:
repository-url: https://test.pypi.org/legacy/
skip-existing: true # Don't fail if version already exists
- name: Publish to PyPI
if: (github.event_name == 'release' && github.event.release.prerelease != true) || (github.event_name == 'repository_dispatch' && github.event.client_payload.prerelease != true)
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
with:
skip-existing: true # Don't fail if version already exists
# Verify the published package is installable (skipped for prereleases which go to Test PyPI)
verify-publish:
needs: publish
if: ${{ !(github.event_name == 'release' && github.event.release.prerelease == true) && !(github.event_name == 'repository_dispatch' && github.event.client_payload.prerelease == true) }}
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Determine published version
id: version
env:
EVENT_NAME: ${{ github.event_name }}
PAYLOAD_TAG: ${{ github.event.client_payload.tag }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
if [ "$EVENT_NAME" = "repository_dispatch" ]; then
TAG="$PAYLOAD_TAG"
else
TAG="$RELEASE_TAG"
fi
# Strip leading 'v' if present
VERSION="${TAG#v}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Published version: $VERSION"
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libsqlcipher-dev
- name: Wait for PyPI index to update
run: |
echo "Waiting 60 seconds for PyPI index propagation..."
sleep 60
- name: Verify package installation
run: |
python3 -m venv /tmp/verify-env
. /tmp/verify-env/bin/activate
VERSION="${{ steps.version.outputs.version }}"
echo "Installing local-deep-research==${VERSION}..."
# Retry up to 5 times with increasing delay for PyPI propagation
for i in 1 2 3 4 5; do
if pip install "local-deep-research==${VERSION}" 2>&1; then
echo "Installation successful on attempt $i"
break
fi
if [ "$i" -lt 5 ]; then
echo "Attempt $i failed, waiting $((i * 30))s..."
sleep $((i * 30))
else
echo "::error::Failed to install local-deep-research==${VERSION} after 5 attempts"
exit 1
fi
done
# Verify import works
python3 -c "import local_deep_research; print(f'Successfully imported v{local_deep_research.__version__}')"
+495
View File
@@ -0,0 +1,495 @@
name: Puppeteer E2E Tests
on:
pull_request:
types: [labeled]
workflow_call: # Called by release.yml (e2e-test-gate)
secrets:
OPENROUTER_API_KEY:
required: false
SERPER_API_KEY:
required: false
workflow_dispatch: # Manual trigger runs all tests
schedule:
# Run weekly on Sunday at 2 AM UTC
- cron: '0 2 * * 0'
# No concurrency group — intentionally omitted.
# This workflow triggers on both pull_request and workflow_call (from
# ci-gate.yml / release-gate.yml). A shared concurrency key would cause
# direct PR runs and workflow_call runs to cancel each other mid-flight.
# See #3554 (reverted in #3599) for context.
permissions: {} # Minimal top-level for OSSF Scorecard
jobs:
puppeteer-tests:
name: Puppeteer E2E Tests
runs-on: ubuntu-latest
timeout-minutes: 120
# For pull_request events, only run when labeled test:puppeteer or test:e2e.
# For all other triggers (push via workflow_call from release.yml,
# workflow_dispatch, schedule), always run.
# NOTE: workflow_call inherits the caller's github.event_name (e.g. "push"),
# so checking for 'workflow_call' never matches — use != 'pull_request' instead.
if: |
github.event_name != 'pull_request' ||
github.event.label.name == 'test:puppeteer' ||
github.event.label.name == 'test:e2e'
environment: ci
permissions:
contents: read
pull-requests: write
issues: write
env:
LABEL_NAME: ${{ github.event.label.name || 'manual' }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup PDM
uses: pdm-project/setup-pdm@973541a5febeafcfdadf8a51211435be6ecfd90f # v4.5
with:
python-version: '3.12'
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24'
- name: Install system dependencies
run: |
sudo apt-get update
# Note: libasound2t64 is the Ubuntu 24.04 name for libasound2
sudo apt-get install -y libsqlcipher-dev xvfb \
libnss3 libgbm1 libasound2t64 libatk1.0-0 libatk-bridge2.0-0 \
libcups2 libdrm2 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \
fonts-liberation xdg-utils
- name: Install Python dependencies
run: pdm install
- name: Install root frontend dependencies
run: npm ci
- name: Build Vite frontend bundle
# Generates src/local_deep_research/web/static/dist/, which the
# Flask app loads via vite_helper. Without this the page renders
# without bundled CSS, causing tests to run against a partially
# unstyled UI (the responsive baseline tracked in PR #3979 was
# captured against a built bundle).
run: npm run build
- name: Install Puppeteer dependencies
working-directory: tests/puppeteer
run: |
npm ci
npx puppeteer browsers install chrome
- name: Setup directories
run: |
mkdir -p data/encrypted_databases
mkdir -p tests/puppeteer/screenshots
- name: Initialize database
env:
TEST_ENV: true
LDR_DATA_DIR: ${{ github.workspace }}/data
LDR_DB_CONFIG_KDF_ITERATIONS: "1000"
# Without LDR_TEST_MODE the 1000 is clamped to the production
# minimum (256000), making SQLCipher ~256x slower — see #4430.
LDR_TEST_MODE: "1"
run: |
cd src
pdm run python ../scripts/ci/init_test_database.py
- name: Create test configuration
run: |
# Create config directory if needed
mkdir -p ~/.config/local-deep-research
# Create settings with OpenRouter + Gemini 2.5 Flash Lite
# (Gemini 2.0 Flash is heavily rate-limited on the OpenRouter free
# pool; 2.5 Flash Lite is GA, cheaper, and far less contended.)
cat > ~/.config/local-deep-research/settings.toml << 'EOF'
[llm]
provider = "openrouter"
model = "google/gemini-2.5-flash-lite"
[search]
tool = "serper"
iterations = 1
questions_per_iteration = 2
[general]
report_type = "quick"
EOF
- name: Start LDR server
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
SERPER_API_KEY: ${{ secrets.SERPER_API_KEY }}
# Settings manager env var overrides (format: LDR_<KEY_WITH_UNDERSCORES>)
# LLM settings - use OpenRouter with Gemini 2.5 Flash Lite
LDR_LLM_PROVIDER: openrouter
LDR_LLM_MODEL: google/gemini-2.5-flash-lite
LDR_LLM_OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
# Search settings
LDR_SEARCH_TOOL: serper
LDR_SEARCH_ENGINE_WEB_SERPER_API_KEY: ${{ secrets.SERPER_API_KEY }}
# Database and server settings
LDR_DATA_DIR: ${{ github.workspace }}/data
LDR_DB_CONFIG_KDF_ITERATIONS: "1000"
# Without LDR_TEST_MODE the 1000 is clamped to the production
# minimum (256000), making SQLCipher ~256x slower — see #4430.
LDR_TEST_MODE: "1"
CI: true
LDR_DISABLE_RATE_LIMITING: true
run: |
# Start server in background
cd src
nohup pdm run python -m local_deep_research.web.app > /tmp/ldr_server.log 2>&1 &
echo "Waiting for server to start..."
for i in {1..60}; do
if curl -fsS --connect-timeout 2 --max-time 5 http://localhost:5000/api/v1/health 2>/dev/null; then
echo "Server is ready after $i seconds!"
break
fi
if ! pgrep -f 'python -m local_deep_research.web.app' > /dev/null; then
echo "Server process died!"
cat /tmp/ldr_server.log
exit 1
fi
echo "Waiting... ($i/60)"
sleep 1
done
# Final check if server is ready
if ! curl -fsS --connect-timeout 2 --max-time 5 http://localhost:5000/api/v1/health 2>/dev/null; then
echo "Server failed to start. Logs:"
cat /tmp/ldr_server.log
exit 1
fi
- name: Run Puppeteer tests
working-directory: tests/puppeteer
env:
TEST_URL: http://localhost:5000
HEADLESS: true
run: |
set -o pipefail
# Run all test suites with xvfb for virtual display
xvfb-run -a -s "-screen 0 1920x1080x24" npm run test:ci 2>&1 | tee test-output.log
TEST_RESULT="${PIPESTATUS[0]}"
# Save exit code for downstream steps
echo "TEST_EXIT_CODE=${TEST_RESULT}" >> "$GITHUB_ENV"
# Exit with test result so the step reflects pass/fail
exit "${TEST_RESULT}"
- name: Run egress-policy UI test
# Release gate for the Privacy & Egress UI (scope dropdown, the
# require-local toggles, the STRICT+meta-picker guard, and the
# data-scope propagation across pages). This test lives in
# tests/ui_tests (a standalone Puppeteer script, NOT the mocha suite
# above) and runs against the SAME booted server. It authenticates as
# the CI test user (test_admin) created by init_test_database.py and
# matched in tests/ui_tests/auth_helper.js, and skips screenshots when
# CI=true. `if: always()` so its result is reported even when the mocha
# suite above failed; the script exits non-zero on any failed check, so
# a regression fails the release gate.
if: always()
working-directory: tests/ui_tests
env:
BASE_URL: http://localhost:5000
CI: true
HEADLESS: true
run: |
set -o pipefail
npm ci
npx puppeteer browsers install chrome
xvfb-run -a -s "-screen 0 1920x1080x24" \
node test_egress_policy_ui.js 2>&1 | tee egress-ui-output.log
- name: Extract test summary
if: always()
working-directory: tests/puppeteer
run: |
# Extract pass/fail summary from mocha output
if [ -f test-output.log ]; then
# Get the summary line (e.g., "26 passing (3m)")
PASSING=$(grep -oP '\d+ passing' test-output.log | tail -1 || echo "0 passing")
FAILING=$(grep -oP '\d+ failing' test-output.log | tail -1 || echo "0 failing")
echo "TESTS_PASSING=${PASSING}" >> "$GITHUB_ENV"
echo "TESTS_FAILING=${FAILING}" >> "$GITHUB_ENV"
# Extract key test results (collection creation, subscription, research)
echo "## Key Test Results" > test-summary.md
echo "" >> test-summary.md
# Check for collection creation
if grep -q "Collection found in dropdown: true" test-output.log; then
echo "- ✅ Collection creation: **Success**" >> test-summary.md
elif grep -q "Collection found in dropdown: false" test-output.log; then
echo "- ❌ Collection creation: **Failed**" >> test-summary.md
fi
# Check for subscription creation
if grep -q "Subscription name found: true" test-output.log; then
echo "- ✅ Subscription creation: **Success**" >> test-summary.md
elif grep -q "Subscription name found: false" test-output.log; then
echo "- ❌ Subscription creation: **Failed**" >> test-summary.md
fi
# Check for research completion
if grep -q "Research completed: true" test-output.log; then
RESEARCH_TIME=$(grep -oP "Research completed: true \(took \K\d+(?=s\))" test-output.log || echo "?")
echo "- ✅ Research workflow: **Completed** (${RESEARCH_TIME}s)" >> test-summary.md
elif grep -q "Research completed: false" test-output.log; then
echo "- ⚠️ Research workflow: **Timed out**" >> test-summary.md
fi
# Check for settings persistence
if grep -q "After reload value:" test-output.log; then
echo "- ✅ Settings persistence: **Working**" >> test-summary.md
fi
# Add screenshot count
SCREENSHOT_COUNT=$(find screenshots -name "*.png" 2>/dev/null | wc -l || echo "0")
echo "" >> test-summary.md
echo "**Screenshots captured:** ${SCREENSHOT_COUNT}" >> test-summary.md
fi
- name: Show test summary in workflow UI
if: always()
working-directory: tests/puppeteer
# zizmor: ignore[template-injection] — env.TEST_EXIT_CODE/TESTS_PASSING/TESTS_FAILING are set by this workflow, not user input
run: |
# Output to GITHUB_STEP_SUMMARY so results are visible in Actions UI
{
echo "## 🧪 Puppeteer E2E Test Results"
echo ""
if [ "$TEST_EXIT_CODE" == "0" ]; then
echo "### ✅ Tests Passed!"
else
echo "### ❌ Tests Failed"
fi
echo ""
echo "**Summary:** $TESTS_PASSING, $TESTS_FAILING"
echo ""
# Include key test results
if [ -f test-summary.md ]; then
cat test-summary.md
echo ""
fi
echo "---"
echo ""
echo "<details>"
echo "<summary>📋 Last 50 lines of test output</summary>"
echo ""
echo '```'
if [ -f test-output.log ]; then
tail -50 test-output.log
fi
echo '```'
echo "</details>"
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload server logs
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: server-logs
path: /tmp/ldr_server.log
retention-days: 7
- name: Upload test artifacts
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: puppeteer-test-results
path: |
tests/puppeteer/test-output.log
tests/puppeteer/test-summary.md
tests/puppeteer/screenshots/
retention-days: 7
- name: Find PR number
id: find-pr
if: always()
# zizmor: ignore[template-injection] — github.ref_name and github.repository are safe GitHub context values
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EVENT_NAME: ${{ github.event_name }}
PR_NUM: ${{ github.event.pull_request.number }}
REF_NAME: ${{ github.ref_name }}
REPO: ${{ github.repository }}
run: |
if [ "$EVENT_NAME" == "pull_request" ]; then
# Labeled PR run — the primary, correct path. Real branches targeting main.
echo "pr_number=$PR_NUM" >> "$GITHUB_OUTPUT"
elif [ "$EVENT_NAME" == "workflow_dispatch" ] && [ "$REF_NAME" != "main" ]; then
# Manual run on a feature branch. Map branch -> its open PR, but only a
# UNIQUE, SAME-REPO match: cross-repo (fork) PRs and ambiguous matches are
# skipped so we never post to the wrong PR (e.g. a fork PR'd from `main`).
PR_NUMBER=$(gh pr list --head "$REF_NAME" --state open --repo "$REPO" \
--json number,isCrossRepository \
--jq '[.[] | select(.isCrossRepository == false)]
| if length == 1 then .[0].number | tostring else "" end')
echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT"
else
# release.yml workflow_call (event=push) and the weekly schedule both run
# on the default branch. There is never a PR from main, so any
# `gh pr list --head main` match is a coincidental fork PR — skip
# entirely (results still go to the Actions step summary). See #3884.
echo "pr_number=" >> "$GITHUB_OUTPUT"
echo "event=$EVENT_NAME ref=$REF_NAME has no associated PR — skipping comment"
fi
- name: Build PR comment
if: always() && steps.find-pr.outputs.pr_number != ''
# zizmor: ignore[template-injection] — env.TEST_EXIT_CODE/TESTS_PASSING/TESTS_FAILING are set by this workflow, not user input
env:
EVENT_NAME: ${{ github.event_name }}
run: |
{
# Hidden marker — the "Post or update PR comment" step finds this
# comment by this marker so re-runs edit it in place (see #3884).
echo '<!-- puppeteer-e2e-results -->'
echo "## 🧪 Puppeteer E2E Test Results"
echo ""
if [ "$TEST_EXIT_CODE" == "0" ]; then
echo "### ✅ Tests Passed!"
else
echo "### ❌ Tests Failed"
fi
echo ""
echo "**Summary:** $TESTS_PASSING, $TESTS_FAILING"
echo ""
# Include key test results
if [ -f tests/puppeteer/test-summary.md ]; then
cat tests/puppeteer/test-summary.md
echo ""
fi
# Include research output if available
if [ -f tests/puppeteer/research-output/research-result.txt ]; then
echo "---"
echo ""
echo "### 📝 Research Output"
echo ""
if [ -f tests/puppeteer/research-output/research-metadata.json ]; then
QUERY=$(jq -r '.query // "unknown"' tests/puppeteer/research-output/research-metadata.json)
echo "**Query:** \`$QUERY\`"
echo ""
fi
echo "<details>"
echo "<summary>📄 Research Result (click to expand)</summary>"
echo ""
echo '```markdown'
# Get first 5000 chars of research output
head -c 5000 tests/puppeteer/research-output/research-result.txt
TOTAL_SIZE=$(wc -c < tests/puppeteer/research-output/research-result.txt)
if [ "$TOTAL_SIZE" -gt 5000 ]; then
echo ""
echo "... [truncated, total $TOTAL_SIZE chars]"
fi
echo '```'
echo "</details>"
echo ""
fi
echo "---"
echo ""
echo "<details>"
echo "<summary>📋 Full Test Output (click to expand)</summary>"
echo ""
echo '```'
# Get last 100 lines of test output (most relevant)
if [ -f tests/puppeteer/test-output.log ]; then
tail -100 tests/puppeteer/test-output.log
fi
echo '```'
echo "</details>"
echo ""
echo "---"
echo ""
echo "**Configuration:**"
echo "- Model: \`google/gemini-2.5-flash-lite\` (via OpenRouter)"
echo "- Search: \`serper\`"
echo "- Test suite: \`all\`"
echo ""
echo "_Triggered by: \`${EVENT_NAME}\` (label: \`${LABEL_NAME}\`)_"
} > comment.md
- name: Post or update PR comment
if: always() && steps.find-pr.outputs.pr_number != ''
continue-on-error: true # required release gate must not fail on a GitHub API hiccup
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
PR_NUMBER: ${{ steps.find-pr.outputs.pr_number }}
with:
retries: 3
script: |
const fs = require('fs');
const issue_number = Number(process.env.PR_NUMBER);
const marker = '<!-- puppeteer-e2e-results -->';
const body = fs.readFileSync('comment.md', 'utf8');
// Paginate — on a long-lived PR the existing comment may be past the
// first page (default 30), which would otherwise create a new comment
// every run and reintroduce the spam this fix removes (#3884).
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number,
per_page: 100,
});
const existing = comments.find(
(c) => c.user.type === 'Bot' && c.body.includes(marker)
);
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number,
body,
});
}
- name: Remove label for re-triggering
if: always() && github.event_name == 'pull_request'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUM: ${{ github.event.pull_request.number }}
run: |
gh issue edit "$PR_NUM" --remove-label "$LABEL_NAME" 2>/dev/null || true
- name: Cleanup
if: always()
run: |
# Kill server
pkill -f 'python -m local_deep_research.web.app' || true
rm -f comment.md
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,375 @@
name: Responsive UI Tests
on:
workflow_call:
workflow_dispatch:
# No concurrency group — intentionally omitted.
# This workflow runs only via workflow_call (from release.yml's
# responsive-test-gate) and workflow_dispatch. The pull_request trigger
# was deliberately removed in #2248 to reduce PR CI load on what is a
# heavy ~20-minute matrix build (mobile + desktop). A shared concurrency
# key here previously caused workflow_call invocations to cancel each
# other mid-flight; see #3554 (reverted in #3599) for that history.
permissions:
contents: read
jobs:
ui-tests:
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
viewport: [mobile, desktop] # Test mobile and desktop on each PR
services:
postgres:
image: postgres:14@sha256:ca25035f7e6f74552655a1c5e4a9eb21f85e9d316f1f70371f790ef70095dd58 # v14
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: ldr_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up PDM
uses: pdm-project/setup-pdm@973541a5febeafcfdadf8a51211435be6ecfd90f # v4.5
with:
python-version: '3.12'
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: tests/ui_tests/package-lock.json
- name: Free up disk space
run: |
# Remove unnecessary large packages to prevent disk space issues during cache save
sudo rm -rf /usr/share/dotnet
sudo rm -rf /usr/local/lib/android
sudo rm -rf /opt/ghc
sudo rm -rf /opt/hostedtoolcache/CodeQL
df -h
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
wget \
gnupg \
ca-certificates \
fonts-liberation \
libasound2t64 \
libatk-bridge2.0-0 \
libatk1.0-0 \
libcups2 \
libdbus-1-3 \
libdrm2 \
libgbm1 \
libgtk-3-0 \
libnspr4 \
libnss3 \
libx11-xcb1 \
libxcomposite1 \
libxdamage1 \
libxrandr2 \
xdg-utils \
imagemagick
- name: Install Python dependencies
run: pdm install
- name: Install root frontend dependencies
run: npm ci
- name: Build Vite frontend bundle
# Generates src/local_deep_research/web/static/dist/, which the
# Flask app loads via vite_helper. Without this the responsive UI
# tests run against an unstyled page (no styles.css), which means
# any CSS source changes between PRs are invisible to the test
# baseline. See follow-up to PR #3985 for context.
run: npm run build
- name: Install Node test dependencies
working-directory: tests/ui_tests
run: |
npm ci
# Keep this version in sync with tests/ui_tests/package-lock.json's
# puppeteer entry. A mismatch makes npx fetch a second puppeteer
# that targets a different Chrome build, corrupting the browser
# cache ("folder exists but executable is missing"). Matching the
# locked version lets npx reuse the puppeteer just installed above.
npx puppeteer@25.1.0 browsers install chrome
- name: Set up test directories
run: |
mkdir -p ${{ github.workspace }}/data/encrypted_databases
mkdir -p tests/ui_tests/screenshots
echo "Created data and screenshots directories for tests"
- name: Start test server
env:
CI: true
FLASK_ENV: testing
TEST_ENV: true
SECRET_KEY: test-secret-key-for-ci # Security: CI test credential, not production secret
LDR_DISABLE_RATE_LIMITING: true
LDR_DATA_DIR: ${{ github.workspace }}/data
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/ldr_test # Security: CI test database credentials, not production secrets
run: |
cd src
# Start server and get its PID
pdm run python -m local_deep_research.web.app > server.log 2>&1 &
SERVER_PID=$!
echo "Server PID: $SERVER_PID"
# Persist the PID for the "Stop application server" step. That step
# runs at the workspace root (no working-directory) while we are in
# src/ here, so write an absolute path — otherwise the pidfile never
# lines up and cleanup is a silent no-op.
echo "$SERVER_PID" > "$GITHUB_WORKSPACE/server.pid"
# Wait for server to start
SERVER_READY=false
for i in {1..60}; do
# Probe the health endpoint, not bare `/`: `/` 302-redirects to
# /auth/login and `curl -f` treats a 302 as success the instant
# the socket binds — before DB/app init finishes — so it can pass
# while the app is still not ready. /api/v1/health is a true
# readiness signal and matches the docker-tests.yml gate.
if curl -fsS --connect-timeout 2 --max-time 5 http://127.0.0.1:5000/api/v1/health 2>/dev/null; then
echo "Server is ready after $i seconds"
SERVER_READY=true
break
fi
# Check if process is still running
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
echo "Server process died!"
echo "Server log:"
cat server.log
exit 1
fi
echo "Waiting for server... ($i/60)"
sleep 1
done
# Fail fast if the server is alive but never became ready in time.
# Without this guard the loop fell through silently on a slow-but-
# not-crashed start, so "Register CI test user" and the UI tests ran
# against a server that was not yet listening and failed with a
# confusing `net::ERR_CONNECTION_REFUSED` in the *test* step instead
# of a clear startup failure here. Window was also bumped 30s -> 60s
# to match the proven puppeteer-e2e-tests.yml / docker-tests.yml
# startup gates (Postgres-backed boot can exceed 30s on a loaded
# runner).
if [ "$SERVER_READY" != "true" ]; then
echo "❌ Server did not become ready within 60 seconds"
echo "Server log:"
cat server.log
exit 1
fi
- name: Register CI test user
working-directory: tests/ui_tests
run: node register_ci_user.js http://127.0.0.1:5000
- name: Run responsive UI tests - ${{ matrix.viewport }}
id: run-tests
working-directory: tests/ui_tests
env:
VIEWPORT: ${{ matrix.viewport }}
run: |
set +e # Don't exit on test failure
# Run tests and capture output
HEADLESS=true node test_responsive_ui_comprehensive.js "$VIEWPORT" 2>&1 | tee test-output.log
TEST_EXIT_CODE="${PIPESTATUS[0]}"
# Extract summary for PR comment
echo "### 📱 $VIEWPORT Test Results" > test-summary.md
echo "" >> test-summary.md
# Extract pass/fail counts
PASSED=$(grep -oP '\d+(?= passed)' test-output.log | tail -1 || echo "0")
FAILED=$(grep -oP '\d+(?= failed)' test-output.log | tail -1 || echo "0")
WARNINGS=$(grep -oP '\d+(?= warnings)' test-output.log | tail -1 || echo "0")
if [ "$FAILED" -eq "0" ]; then
echo "✅ **All tests passed!**" >> test-summary.md
else
echo "❌ **$FAILED critical issues found**" >> test-summary.md
fi
{
echo ""
echo "- ✅ Passed: $PASSED"
echo "- ❌ Failed: $FAILED"
echo "- ⚠️ Warnings: $WARNINGS"
} >> test-summary.md
{
echo "test_exit_code=$TEST_EXIT_CODE"
echo "test_failed=$FAILED"
} >> "$GITHUB_OUTPUT"
# Only fail if critical failures
exit "$TEST_EXIT_CODE"
- name: Upload viewport test results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ui-test-results-${{ matrix.viewport }}
path: |
tests/ui_tests/test-output.log
tests/ui_tests/test-summary.md
tests/ui_tests/screenshots/
tests/ui_tests/responsive/
src/server.log
retention-days: 7
if-no-files-found: ignore
# NOTE: Earlier screenshot generation/upload steps were removed to fix actionlint warnings
# caused by "if: false" disablement. Screenshots are now captured into the artifact above
# when they exist. Visual regression workflows can re-enable dedicated screenshot steps later.
- name: Upload to GitHub Pages (if available)
if: always() && github.event_name == 'pull_request'
continue-on-error: true
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
VIEWPORT: ${{ matrix.viewport }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPOSITORY: ${{ github.repository }}
GH_REPO_OWNER: ${{ github.repository_owner }}
GH_REPO_NAME: ${{ github.event.repository.name }}
run: |
# This requires GitHub Pages to be enabled for the repo
# Create a branch for the screenshots
BRANCH_NAME="pr-screenshots-$PR_NUMBER-$VIEWPORT"
# Check if screenshots directory exists
if [ ! -d "tests/ui_tests/responsive" ]; then
echo "No screenshots directory found, skipping upload"
exit 0
fi
cd tests/ui_tests/responsive
git init
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
# Add all screenshots and gallery
git add responsive-ui-tests screenshot-gallery.html
git commit -m "Screenshots for PR #$PR_NUMBER"
# Push to a dedicated branch (requires write permissions)
git push --force "https://x-access-token:$GH_TOKEN@github.com/$GH_REPOSITORY.git" "HEAD:$BRANCH_NAME" 2>/dev/null || true
# The URL would be: https://[owner].github.io/[repo]/pr-screenshots-[number]-[viewport]/screenshot-gallery.html
echo "Screenshots available at: https://$GH_REPO_OWNER.github.io/$GH_REPO_NAME/$BRANCH_NAME/screenshot-gallery.html" || true
- name: Stop application server
if: always()
run: |
if [ -f server.pid ]; then
kill "$(cat server.pid)" || true
rm server.pid
fi
# `pdm run` stays alive as a wrapper, so the pidfile holds the
# wrapper PID and killing it can orphan the actual python server.
# Add the same pkill fallback the webkit workflow uses so the
# python process (and port 5000) is actually released.
pkill -f "python -m local_deep_research.web.app" || true
post-results:
needs: ui-tests
runs-on: ubuntu-latest
# Workflow has no pull_request trigger; gating on event_name == 'pull_request' kept this job
# from ever running. Always run so the combined report is built for workflow_dispatch / release.
if: always()
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Download all artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: test-artifacts/
- name: Generate combined report
run: |
# Create a combined markdown report with screenshot links
{
echo "# 📊 Responsive UI Test Report"
echo ""
echo "## Test Summary"
echo ""
} > combined-report.md
# Process each viewport's results
for dir in test-artifacts/ui-test-results-*/; do
if [ -d "$dir" ]; then
viewport=$(basename "$dir" | sed 's/ui-test-results-//')
echo "### $viewport" >> combined-report.md
if [ -f "$dir/test-summary.md" ]; then
cat "$dir/test-summary.md" >> combined-report.md
fi
# Count screenshots
screenshot_count=$(find "$dir" -name "*.png" 2>/dev/null | wc -l)
if [ "$screenshot_count" -gt "0" ]; then
{
echo ""
echo "📸 **$screenshot_count screenshots captured**"
} >> combined-report.md
fi
echo "" >> combined-report.md
fi
done
{
echo "## 📥 Download Options"
echo ""
echo "- [Download all test artifacts](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})"
echo "- View artifacts in the 'Artifacts' section below the workflow summary"
} >> combined-report.md
- name: Upload combined report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: combined-test-report
path: combined-report.md
retention-days: 30
+86
View File
@@ -0,0 +1,86 @@
name: Retire.js Security Scan
on:
workflow_dispatch:
workflow_call: # Called by release-gate.yml
schedule:
# Run weekly security scan on Mondays at 4 AM UTC
- cron: '0 4 * * 1'
permissions: {} # Minimal top-level for OSSF Scorecard Token-Permissions
# Note: No concurrency block - this workflow is called by release-gate.yml
# and concurrency settings can cause unexpected cancellations for reusable workflows
jobs:
retirejs:
name: Retire.js Vulnerability Scan
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
security-events: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24'
- name: Run Retire.js scan
id: retire-scan
run: |
echo "=== Running Retire.js scan ==="
# Use npx with specific version for reproducible builds
# Scan JavaScript files and node_modules for known vulnerabilities
# Only flag medium severity and above (exit code 13 when issues found)
# Output both SARIF (for GitHub Security) and human-readable format
npx retire@5.2.4 --js --node --severity medium --outputformat sarif --outputpath retire-results.sarif 2>&1 | tee retire-output.txt
RETIRE_EXIT_CODE=$?
# Check if vulnerabilities were found (exit code 13 indicates issues)
if [ $RETIRE_EXIT_CODE -eq 13 ]; then
echo "RETIRE_FOUND_ISSUES=true" >> "$GITHUB_ENV"
fi
# Display human-readable summary
echo ""
echo "=== Scan Summary ==="
cat retire-output.txt
# Check if SARIF file was created (for use in upload step)
if [ -f "retire-results.sarif" ]; then
echo "sarif_exists=true" >> "$GITHUB_OUTPUT"
fi
- name: Upload SARIF results
uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
if: always() && steps.retire-scan.outputs.sarif_exists == 'true'
with:
sarif_file: retire-results.sarif
category: retirejs
- name: Check scan results
run: |
if [[ "$RETIRE_FOUND_ISSUES" == "true" ]]; then
echo "::error::Retire.js found medium or higher severity vulnerabilities"
echo "Review the Security tab for details"
echo "Consider updating or replacing affected libraries"
# Only fail for PR/push events, not scheduled scans
if [[ "${{ github.event_name }}" != "schedule" ]]; then
exit 1
fi
echo "::warning::Scheduled scan - reporting only, not failing the workflow"
else
echo "✅ No medium or higher severity vulnerabilities detected"
fi
+81
View File
@@ -0,0 +1,81 @@
name: SBOM Generation
on:
workflow_dispatch:
schedule:
# Run weekly on Wednesday at 10 AM UTC (staggered with other scans)
- cron: '0 10 * * 3'
release:
types: [published]
permissions: {}
jobs:
sbom:
name: Generate Software Bill of Materials
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Generate SBOM for source code
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
with:
path: .
artifact-name: sbom-source.spdx.json
output-file: sbom-source.spdx.json
format: spdx-json
- name: Generate SBOM for Python dependencies
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
with:
path: .
artifact-name: sbom-python.cyclonedx.json
output-file: sbom-python.cyclonedx.json
format: cyclonedx-json
- name: Upload SBOMs as artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: sbom-reports
path: |
sbom-source.spdx.json
sbom-python.cyclonedx.json
retention-days: 90
- name: Display SBOM summary
run: |
{
echo "## SBOM Generation Summary"
echo ""
echo "### Generated SBOMs"
echo "- **Source Code SBOM**: \`sbom-source.spdx.json\` (SPDX format)"
echo "- **Dependencies SBOM**: \`sbom-python.cyclonedx.json\` (CycloneDX format)"
echo ""
echo "### What is an SBOM?"
echo "A Software Bill of Materials (SBOM) is a formal record of all components,"
echo "libraries, and dependencies used in building software. It enables:"
echo ""
echo "- **Vulnerability tracking**: Know exactly what's in your software"
echo "- **License compliance**: Verify all dependencies have compatible licenses"
echo "- **Supply chain security**: Detect compromised dependencies"
echo "- **Regulatory compliance**: Meet requirements (EO 14028, etc.)"
echo ""
echo "### Artifacts"
echo "SBOMs are available as workflow artifacts for 90 days."
if [ "${{ github.event_name }}" = "release" ]; then
echo ""
echo "SBOMs have also been attached to the release assets."
fi
} >> "$GITHUB_STEP_SUMMARY"
@@ -0,0 +1,34 @@
name: Security File Write Check
on:
pull_request:
types: [opened, synchronize, reopened]
workflow_call: # Called by ci-gate.yml for release pipeline
workflow_dispatch:
# No concurrency group — intentionally omitted.
# This workflow triggers on both pull_request and workflow_call (from
# ci-gate.yml / release-gate.yml). A shared concurrency key would cause
# direct PR runs and workflow_call runs to cancel each other mid-flight.
# See #3554 (reverted in #3599) for context.
permissions:
contents: read
jobs:
check-file-writes:
name: Check for Unencrypted File Writes
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Check for potential unencrypted file writes
run: ./.github/scripts/check-file-writes.sh
@@ -0,0 +1,194 @@
name: Security Headers Validation
on:
workflow_call: # Called by release-gate.yml
schedule:
- cron: '0 3 * * *' # Daily scan for early detection
workflow_dispatch: # Manual trigger for debugging/verification
permissions:
contents: read
# NOTE: No concurrency block here. When called via workflow_call from
# release-gate.yml, the caller's workflow name is used for the concurrency
# group, which can cause this job to be cancelled if another release gate
# run starts on the same ref. The parent workflow manages concurrency.
jobs:
validate-headers:
runs-on: ubuntu-latest
timeout-minutes: 15
services:
postgres:
image: postgres:13@sha256:4689940c683801b4ab839ab3b0a0a3555a5fe425371422310944e89eca7d8068 # v13
env:
POSTGRES_USER: ldr_test
POSTGRES_PASSWORD: ${{ github.run_id }}_test_pwd
POSTGRES_DB: test_db
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Python 3.12
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.12'
- name: Cache pip packages
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-headers-${{ hashFiles('**/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-pip-headers-
${{ runner.os }}-pip-
- name: Set up PDM
uses: pdm-project/setup-pdm@973541a5febeafcfdadf8a51211435be6ecfd90f # v4.5
with:
python-version: '3.12'
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libsqlcipher-dev build-essential
- name: Install dependencies
run: |
pdm install --dev
- name: Set up test environment
run: |
mkdir -p data
mkdir -p research_outputs
cp -r src/local_deep_research/defaults/.env.template .env.test
- name: Start Flask application in background
run: |
# Use FLASK_DEBUG=0 (FLASK_ENV is deprecated)
export FLASK_DEBUG=0
export DATABASE_URL=postgresql://ldr_test:${{ github.run_id }}_test_pwd@localhost:5432/test_db
pdm run python -m local_deep_research.web.app &
echo $! > flask.pid
- name: Wait for application to be ready
run: |
timeout 60 bash -c 'until curl -fsS --connect-timeout 2 --max-time 5 http://localhost:5000/api/v1/health > /dev/null; do sleep 2; done'
- name: Validate security headers
run: |
echo "=== Testing Security Headers ==="
echo ""
# Capture headers once for efficiency (with timeout)
HEADERS=$(curl -sI --max-time 10 http://localhost:5000/)
echo "Full response headers:"
echo "$HEADERS"
echo ""
# Check for required security headers and validate values
ERRORS=()
# X-Frame-Options - must be SAMEORIGIN or DENY
XFO=$(echo "$HEADERS" | grep -i "^X-Frame-Options:" | cut -d':' -f2- | tr -d '\r' | xargs)
if [[ -z "$XFO" ]]; then
ERRORS+=("X-Frame-Options: MISSING")
elif [[ "$XFO" != "SAMEORIGIN" && "$XFO" != "DENY" ]]; then
ERRORS+=("X-Frame-Options: Invalid value '$XFO' (expected SAMEORIGIN or DENY)")
else
echo "✅ X-Frame-Options: $XFO"
fi
# X-Content-Type-Options - must be nosniff
XCTO=$(echo "$HEADERS" | grep -i "^X-Content-Type-Options:" | cut -d':' -f2- | tr -d '\r' | xargs)
if [[ -z "$XCTO" ]]; then
ERRORS+=("X-Content-Type-Options: MISSING")
elif [[ "$XCTO" != "nosniff" ]]; then
ERRORS+=("X-Content-Type-Options: Invalid value '$XCTO' (expected nosniff)")
else
echo "✅ X-Content-Type-Options: $XCTO"
fi
# Referrer-Policy - must not be unsafe
RP=$(echo "$HEADERS" | grep -i "^Referrer-Policy:" | cut -d':' -f2- | tr -d '\r' | xargs)
if [[ -z "$RP" ]]; then
ERRORS+=("Referrer-Policy: MISSING")
elif [[ "$RP" == "unsafe-url" || "$RP" == "no-referrer-when-downgrade" ]]; then
ERRORS+=("Referrer-Policy: Insecure value '$RP'")
else
echo "✅ Referrer-Policy: $RP"
fi
# Permissions-Policy - must restrict dangerous features
PP=$(echo "$HEADERS" | grep -i "^Permissions-Policy:" | cut -d':' -f2- | tr -d '\r' | xargs)
if [[ -z "$PP" ]]; then
ERRORS+=("Permissions-Policy: MISSING")
elif [[ "$PP" != *"geolocation=()"* ]]; then
ERRORS+=("Permissions-Policy: Should restrict geolocation")
else
echo "✅ Permissions-Policy: $PP"
fi
# Content-Security-Policy - must have default-src and not be overly permissive
CSP=$(echo "$HEADERS" | grep -i "^Content-Security-Policy:" | cut -d':' -f2- | tr -d '\r' | xargs)
if [[ -z "$CSP" ]]; then
ERRORS+=("Content-Security-Policy: MISSING")
elif [[ "$CSP" == *"default-src *"* || "$CSP" == *"default-src 'unsafe-inline' 'unsafe-eval'"* ]]; then
ERRORS+=("Content-Security-Policy: Overly permissive policy detected")
elif [[ "$CSP" != *"default-src"* ]]; then
ERRORS+=("Content-Security-Policy: Missing default-src directive")
else
echo "✅ Content-Security-Policy: Present with default-src"
fi
# Report results
echo ""
if [ ${#ERRORS[@]} -eq 0 ]; then
echo "✅ All required security headers are present and valid"
else
echo "❌ Security header issues found:"
for error in "${ERRORS[@]}"; do
echo " - $error"
done
exit 1
fi
- name: Test API endpoint headers
run: |
echo "=== Testing Security Headers on API Endpoints ==="
echo ""
# Test health endpoint (with timeout)
API_HEADERS=$(curl -sI --max-time 10 http://localhost:5000/api/health)
if echo "$API_HEADERS" | grep -iq "^X-Frame-Options:"; then
echo "✅ Security headers present on API endpoints"
else
echo "❌ Security headers missing on API endpoints"
echo "$API_HEADERS"
exit 1
fi
- name: Cleanup
if: always()
run: |
if [ -f flask.pid ]; then
kill "$(cat flask.pid)" 2>/dev/null || true
rm -f flask.pid
fi
+137
View File
@@ -0,0 +1,137 @@
name: Security Tests
on:
workflow_call: # Called by release-gate.yml
workflow_dispatch:
permissions:
contents: read
security-events: write # Required for uploading SARIF results to Code Scanning
jobs:
security-tests:
runs-on: ubuntu-latest
timeout-minutes: 25
services:
postgres:
image: postgres:13@sha256:1094e2cdc5605e5c7914633bcd93758a9c52ae8c8b2855ddd1c3a8afbe4795d5 # postgres:13
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test_security_db
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Python 3.12
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.12'
- name: Cache pip packages
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-security-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-security-
${{ runner.os }}-pip-
- name: Set up PDM
uses: pdm-project/setup-pdm@973541a5febeafcfdadf8a51211435be6ecfd90f # v4.5
with:
python-version: '3.12'
- name: Install system dependencies for SQLCipher
run: |
sudo apt-get update
sudo apt-get install -y libsqlcipher-dev
- name: Install dependencies
run: |
pdm sync -d
pdm add "bandit[sarif]" safety sqlparse pytest pytest-cov --no-sync
pdm sync
- name: Run Bandit security linter
run: |
# --exit-zero: report findings without failing (findings go to SARIF/Security tab)
# If bandit crashes (bad config, missing files), it exits non-zero WITHOUT --exit-zero
# and won't produce output files — the checks below catch that.
pdm run bandit -r src/ -f json -o bandit-report.json -lll --exit-zero
pdm run bandit -r src/ -f sarif -o bandit-results.sarif -lll --exit-zero
# Crash detection: if bandit crashed, no output files were produced
if [ ! -f bandit-report.json ] || [ ! -f bandit-results.sarif ]; then
echo "::error::Bandit crashed — expected output files not produced"
exit 1
fi
echo "Bandit security scan completed"
- name: Upload Bandit SARIF to Code Scanning
uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
if: always()
with:
sarif_file: bandit-results.sarif
category: bandit
# Dependency vulnerability scanning is handled by OSV-Scanner
# (osv-scanner.yml), which reads pdm.lock directly and doesn't
# fight PDM's resolution overrides the way pip-audit did.
- name: Run security test suite
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_security_db
run: |
# Run the entire tests/security/ tree as a directory rather than
# enumerating individual files. The old per-file list only covered
# ~9 of the 87 test files and silently broke whenever a file was
# renamed or deleted: a hardcoded reference to the deleted
# test_input_validation.py (removed in #4243) made pytest exit with
# code 5 ("no tests collected") and failed this gate (#4411).
# Running the directory picks up new security tests automatically and
# never rots when files move.
pdm run python -m pytest tests/security/ -v --tb=short -n auto
- name: Check for hardcoded secrets
run: |
# Check for potential secrets in code
grep -r -E "(api[_-]?key|secret[_-]?key|password|token)" src/ --include="*.py" | \
grep -v -E "(os\.environ|getenv|config\[|placeholder|example|test)" | \
grep -E "=\s*['\"]" || echo "No hardcoded secrets found"
- name: Generate security report
if: always()
run: |
echo "Security Test Report"
echo "==================="
echo ""
if [ -f bandit-report.json ]; then
echo "Bandit Security Issues:"
python -c "import json; data=json.load(open('bandit-report.json')); print(f' High: {len([i for i in data.get(\"results\", []) if i[\"issue_severity\"] == \"HIGH\"])}'); print(f' Medium: {len([i for i in data.get(\"results\", []) if i[\"issue_severity\"] == \"MEDIUM\"])}'); print(f' Low: {len([i for i in data.get(\"results\", []) if i[\"issue_severity\"] == \"LOW\"])}')" || true
fi
echo ""
echo "Dependency vulnerabilities: covered by OSV-Scanner (osv-scanner.yml)"
- name: Upload security reports
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: security-reports
path: |
bandit-report.json
bandit-results.sarif
+251
View File
@@ -0,0 +1,251 @@
name: Semgrep Security Scan
on:
workflow_call: # Called by release-gate.yml
workflow_dispatch:
permissions: {} # Minimal top-level for OSSF Scorecard Token-Permissions
jobs:
semgrep-scan:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
security-events: write
actions: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.12'
- name: Install Semgrep
run: |
# setuptools required: semgrep 1.87.0's opentelemetry dep imports pkg_resources,
# which is not bundled with Python 3.12 by default on GitHub runners.
# Pin setuptools<82 because 82.0 removed the pkg_resources module.
pip install "setuptools<82" semgrep==1.87.0
- name: Run Semgrep security scan
run: |
semgrep \
--config=p/security-audit \
--config=p/secrets \
--severity=INFO \
--json \
--output=semgrep-results.json \
src/ || true
# Crash detection: if semgrep crashed, no output file was produced
if [ ! -f semgrep-results.json ]; then
echo "::error::Semgrep security scan crashed — no output produced"
exit 1
fi
- name: Run Semgrep custom rules for LDR
run: |
semgrep \
--config=.semgrep/rules/ \
--severity=INFO \
--json \
--output=semgrep-custom-results.json \
src/ || true
# Crash detection: if semgrep crashed, no output file was produced
if [ ! -f semgrep-custom-results.json ]; then
echo "::error::Semgrep custom rules scan crashed — no output produced"
exit 1
fi
- name: Merge Semgrep results
run: |
python3 -c "
import json
import glob
results = []
for file in glob.glob('semgrep-*.json'):
try:
with open(file) as f:
data = json.load(f)
if isinstance(data, dict) and 'results' in data:
results.extend(data['results'])
elif isinstance(data, list):
results.extend(data)
except Exception as e:
print(f'Error reading {file}: {e}')
output = {'results': results, 'version': '1.0.0'}
with open('semgrep-combined-results.json', 'w') as f:
json.dump(output, f, indent=2)
print(f'Combined {len(results)} findings from Semgrep scans')
"
- name: Convert to SARIF format
run: |
python3 -c "
import json
import uuid
from datetime import datetime
# Load combined results
with open('semgrep-combined-results.json') as f:
semgrep_data = json.load(f)
# Convert to SARIF
sarif = {
'\$schema': 'https://json.schemastore.org/sarif-2.1.0',
'version': '2.1.0',
'runs': [{
'tool': {
'driver': {
'name': 'Semgrep',
'version': '1.87.0',
'informationUri': 'https://semgrep.dev'
}
},
'results': []
}]
}
for result in semgrep_data.get('results', []):
sarif_result = {
'ruleId': result.get('check_id', 'unknown'),
'message': {
'text': result.get('message', 'Security issue detected')
},
'level': 'warning' if result.get('metadata', {}).get('severity', 'INFO') in ['ERROR', 'WARNING'] else 'note',
'locations': [{
'physicalLocation': {
'artifactLocation': {
'uri': result.get('path', 'unknown')
},
'region': {
'startLine': result.get('start', {}).get('line', 1),
'startColumn': result.get('start', {}).get('col', 1),
'endLine': result.get('end', {}).get('line', result.get('start', {}).get('line', 1)),
'endColumn': result.get('end', {}).get('col', result.get('start', {}).get('col', 1) + 1)
}
}
}]
}
# Add rule information
metadata = result.get('metadata', {})
sarif_result['rule'] = {
'id': result.get('check_id', 'unknown'),
'name': metadata.get('name', 'Security Issue'),
'shortDescription': {
'text': metadata.get('name', 'Security Issue')
},
'fullDescription': {
'text': metadata.get('description', 'Security vulnerability detected')
},
'help': {
'text': metadata.get('remediation', 'Review and fix the security issue')
},
'properties': {
'precision': 'medium',
'tags': ['security', 'semgrep']
}
}
if 'security-severity' in metadata:
sarif_result['rule']['properties']['security-severity'] = metadata['security-severity']
sarif['runs'][0]['results'].append(sarif_result)
# Write SARIF file
with open('semgrep-results.sarif', 'w') as f:
json.dump(sarif, f, indent=2)
print(f'Converted {len(sarif[\"runs\"][0][\"results\"])} findings to SARIF format')
"
# Fail loudly if the SARIF conversion produced no file — never fabricate an
# empty one. An empty-results SARIF uploaded under the semgrep-security
# category would make GitHub mark every previously-open Semgrep alert as
# fixed, silently clearing real findings. The "Convert to SARIF" step above
# always writes the file (results:[] on a clean scan), so a missing file
# means a real conversion failure. Mirror the Grype/Trivy jobs.
- name: Ensure SARIF file exists
id: check-sarif
if: always()
run: |
if [ -f semgrep-results.sarif ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "::error::Semgrep SARIF conversion did not produce a file — scan needs to be rerun"
exit 1
fi
- name: Upload Semgrep results to GitHub Security tab
uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
if: always() && steps.check-sarif.outputs.exists == 'true'
with:
sarif_file: 'semgrep-results.sarif'
category: semgrep-security
- name: Upload Semgrep results as artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: semgrep-scan-results
path: |
semgrep-results.json
semgrep-custom-results.json
semgrep-combined-results.json
semgrep-results.sarif
retention-days: 7 # Reduced for security
- name: Display Semgrep summary
if: always()
run: |
{
echo "## Semgrep Security Scan Summary"
echo ""
} >> "$GITHUB_STEP_SUMMARY"
if [ -f semgrep-combined-results.json ]; then
# Count results by severity
CRITICAL=$(python3 -c "import json; data=json.load(open('semgrep-combined-results.json')); print(len([r for r in data['results'] if r.get('metadata', {}).get('severity') == 'ERROR']))" 2>/dev/null || echo "0")
HIGH=$(python3 -c "import json; data=json.load(open('semgrep-combined-results.json')); print(len([r for r in data['results'] if r.get('metadata', {}).get('severity') == 'WARNING']))" 2>/dev/null || echo "0")
MEDIUM=$(python3 -c "import json; data=json.load(open('semgrep-combined-results.json')); print(len([r for r in data['results'] if r.get('metadata', {}).get('severity') == 'INFO']))" 2>/dev/null || echo "0")
TOTAL=$(python3 -c "import json; data=json.load(open('semgrep-combined-results.json')); print(len(data['results']))" 2>/dev/null || echo "0")
{
echo "📊 **Scan Results:**"
echo "- **Critical:** $CRITICAL"
echo "- **High:** $HIGH"
echo "- **Medium:** $MEDIUM"
echo "- **Total:** $TOTAL"
echo ""
} >> "$GITHUB_STEP_SUMMARY"
if [ "$CRITICAL" -gt 0 ] || [ "$HIGH" -gt 0 ]; then
echo "⚠️ **Action Required:** Critical or High severity issues found" >> "$GITHUB_STEP_SUMMARY"
else
echo "✅ **No Critical or High severity issues found**" >> "$GITHUB_STEP_SUMMARY"
fi
{
echo ""
echo "📋 **Detailed Results:**"
echo "- Security tab: Results uploaded to GitHub Security tab"
echo "- Artifacts: Full JSON and SARIF reports available"
} >> "$GITHUB_STEP_SUMMARY"
else
echo "❌ Semgrep scan failed or no results generated" >> "$GITHUB_STEP_SUMMARY"
fi
+52
View File
@@ -0,0 +1,52 @@
name: UI Tests (Full 14 Shards via Label)
# Triggered by adding the `test:ui-full-shards` label to a PR.
# Calls docker-tests.yml with strict-mode=true so the sharded ui-tests
# matrix runs — it's otherwise gated on strict-mode and only fires at
# release time.
#
# NOTE on duplicated work: docker-tests.yml in strict mode runs its FULL
# job graph (image build, pytest, infra, etc.), so triggering this label
# re-runs everything that the normal PR docker-tests already ran. That's
# acceptable cost for a deliberate opt-in trigger; cheaper than refactoring
# docker-tests.yml to expose just ui-tests as a standalone reusable workflow.
on:
pull_request:
types: [labeled]
# Manual fallback — useful for one-off maintainer runs and for smoke-testing
# the wrapper when label-triggered workflows can't fire (e.g. on the PR that
# first introduces this file, before it lands on main).
workflow_dispatch:
# No concurrency group — intentionally omitted, matching docker-tests.yml.
# See its top-level comment for the rationale (#3554 / #3599).
permissions: {} # Minimal top-level for OSSF Scorecard Token-Permissions
jobs:
full-shards:
name: Full UI Shards
# Fire on the label add, OR on any manual dispatch (maintainer override).
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' &&
github.event.label.name == 'test:ui-full-shards')
uses: ./.github/workflows/docker-tests.yml
with:
strict-mode: true
permissions:
# MUST match the scopes docker-tests.yml's jobs DECLARE, not just the
# ones whose steps run. Reusable-workflow permission validation is
# compile-time: a called job that declares `contents: write` (the
# pytest/gh-pages job) fails the whole run at startup with
# "requesting 'contents: write', but is only allowed 'contents: none'"
# if the caller grants less — producing a 0-job startup_failure with
# no logs. #4209 dropped contents:write here for OSSF Scorecard, which
# silently broke this wrapper on every run since (the gh-pages STEP is
# runtime-gated to push@main, but the JOB still declares the scope).
# ci-gate.yml grants the same pair, which is why it works.
contents: write # docker-tests jobs checkout + declare gh-pages deploy scope
pull-requests: write # docker-tests pytest job posts PR comments
secrets:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
+73
View File
@@ -0,0 +1,73 @@
name: Update PDM dependencies
on:
workflow_call:
inputs:
pdm_args:
description: Arguments to pass to pdm
type: string
default: ''
workflow_dispatch:
inputs:
pdm_args:
description: Arguments to pass to pdm lock
type: string
default: ''
schedule:
- cron: '0 8 * * 3' # every Wednesday at 08:00 UTC
permissions: {} # Minimal top-level for OSSF Scorecard Token-Permissions
jobs:
update:
name: 👛 Update with PDM
runs-on: ubuntu-latest
permissions:
contents: write # Required for creating PRs
pull-requests: write # Required for creating PRs
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: 📰 Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
ref: main
- name: 📦 Setup PDM
uses: pdm-project/setup-pdm@973541a5febeafcfdadf8a51211435be6ecfd90f # v4.5
with:
# Pin to the floor of `requires-python` in pyproject.toml.
# Resolving on the lowest supported interpreter guarantees the
# chosen package versions are installable across the whole
# supported range. Resolving on a newer interpreter (e.g. 3.x)
# can pick packages that dropped support for the floor, producing
# a lockfile that fails `pdm lock --check` downstream (see #3480).
python-version: '3.12'
cache: true
- name: 👚 Update to latest compatible versions
env:
PDM_ARGS: ${{ inputs.pdm_args || '' }}
run: |
# Intentional word splitting for PDM args
# shellcheck disable=SC2086
pdm lock $PDM_ARGS
- name: 📝 Create pull request
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
branch: update-dependencies-${{ github.run_number }}
title: 🤖 Update dependencies
body: |
This PR updates dependencies to their latest compatible versions.
This PR was created by the PDM Update Bot.
sign-commits: true
add-paths: pdm.lock
commit-message: 🤖 Update dependencies
labels: maintenance
draft: false
base: main
reviewers: djpetti,HashedViking,LearningCircuit
@@ -0,0 +1,158 @@
name: Update NPM dependencies
on:
workflow_call:
inputs:
npm_args:
description: Arguments to pass to npm update
type: string
default: --save
workflow_dispatch:
inputs:
npm_args:
description: Arguments to pass to npm update
type: string
default: --save
schedule:
- cron: '0 8 * * 4' # every Thursday at 08:00 UTC (day after PDM updates)
permissions: {} # Minimal top-level for OSSF Scorecard Token-Permissions
jobs:
update:
name: 📦 Update with NPM
runs-on: ubuntu-latest
permissions:
contents: write # Required for creating PRs
pull-requests: write # Required for creating PRs
strategy:
matrix:
npm_directory:
- { path: '.', build_cmd: 'npm run build', test_cmd: '', cache_path: 'package-lock.json' }
- { path: 'tests', build_cmd: '', test_cmd: 'echo "Skipping API-key tests in CI - requires real API key"', cache_path: 'package-lock.json' }
- { path: 'tests/ui_tests', build_cmd: '', test_cmd: 'echo "Skipping UI tests in CI - requires server"', cache_path: 'package-lock.json' }
- { path: 'tests/puppeteer', build_cmd: '', test_cmd: 'echo "Skipping Puppeteer tests in CI - requires server"', cache_path: 'package-lock.json' }
- { path: 'tests/api_tests_with_login', build_cmd: '', test_cmd: 'echo "Skipping API-with-login tests in CI - requires server + auth"', cache_path: 'package-lock.json' }
- { path: 'tests/infrastructure_tests', build_cmd: '', test_cmd: 'echo "Skipping infrastructure tests in CI - requires server"', cache_path: 'package-lock.json' }
- { path: 'tests/accessibility_tests', build_cmd: '', test_cmd: 'echo "Skipping accessibility tests in CI - requires server"', cache_path: 'package-lock.json' }
- { path: 'tests/ui_tests/playwright', build_cmd: '', test_cmd: 'echo "Skipping playwright UI tests in CI - requires server"', cache_path: 'package-lock.json' }
continue-on-error: false
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: 📰 Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
ref: main
- name: 🟢 Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: ${{ matrix.npm_directory.path }}/${{ matrix.npm_directory.cache_path }}
- name: 🔍 Security audit
env:
NPM_DIR: ${{ matrix.npm_directory.path }}
run: |
cd "$NPM_DIR"
# Don't fail the workflow if unfixable vulns remain — the next step
# (`npm audit fix --package-lock-only`) will attempt the auto-fix and
# report what it couldn't fix. Failing here would skip the dependency
# update entirely on the first unpatchable advisory. Use a ::warning
# workflow command (not a bare echo) so the soft-fail surfaces as a
# visible annotation in the run UI instead of getting lost in the
# step log.
npm audit --audit-level moderate || echo "::warning::Vulnerabilities detected — proceeding to auto-fix attempt"
- name: 🔐 Fix security vulnerabilities (lockfile only)
env:
NPM_DIR: ${{ matrix.npm_directory.path }}
run: |
cd "$NPM_DIR"
# Update lockfile only - actual install happens via npm ci below
npm audit fix --package-lock-only || echo "Some vulnerabilities could not be auto-fixed"
- name: 👚 Update to latest compatible versions (lockfile only)
env:
NPM_DIR: ${{ matrix.npm_directory.path }}
NPM_ARGS: ${{ inputs.npm_args || '--save' }}
run: |
cd "$NPM_DIR"
# Update lockfile only - actual install happens via npm ci below
# Intentional word splitting for npm args
# shellcheck disable=SC2086
npm update --package-lock-only $NPM_ARGS
- name: 📦 Install from lockfile with integrity verification
env:
NPM_DIR: ${{ matrix.npm_directory.path }}
run: |
cd "$NPM_DIR"
# npm ci installs from lockfile with integrity hash verification
# This satisfies OSSF Scorecard pinned-dependencies requirement
npm ci
- name: 🔨 Build (if applicable)
env:
NPM_DIR: ${{ matrix.npm_directory.path }}
BUILD_CMD: ${{ matrix.npm_directory.build_cmd }}
run: |
cd "$NPM_DIR"
eval "$BUILD_CMD"
if: matrix.npm_directory.build_cmd != ''
- name: 🧪 Test (if applicable)
env:
NPM_DIR: ${{ matrix.npm_directory.path }}
TEST_CMD: ${{ matrix.npm_directory.test_cmd }}
run: |
cd "$NPM_DIR"
eval "$TEST_CMD"
if: matrix.npm_directory.test_cmd != ''
- name: 📝 Check for changes
id: verify-changed-files
env:
NPM_DIR: ${{ matrix.npm_directory.path }}
run: |
cd "$NPM_DIR"
# Check if package.json or package-lock.json have changed
if git diff --quiet package.json package-lock.json; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
echo "path=${{ matrix.npm_directory.path }}" >> "$GITHUB_OUTPUT"
fi
- name: 📝 Create pull request
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
if: steps.verify-changed-files.outputs.changed == 'true'
with:
branch: update-npm-dependencies-${{ github.run_number }}
title: 🤖 Update dependencies
body: |
This PR updates dependencies to their latest compatible versions.
**Updated directory:** `${{ steps.verify-changed-files.outputs.path }}`
**Changes include:**
- Security vulnerability fixes (moderate severity and above)
- Compatible version updates for dependencies
- Updated lock files for reproducible builds
This PR was created by the Dependency Update Bot.
sign-commits: true
add-paths: |
${{ steps.verify-changed-files.outputs.path }}/package.json
${{ steps.verify-changed-files.outputs.path }}/package-lock.json
commit-message: 🤖 Update dependencies
labels: maintenance
draft: false
base: main
reviewers: djpetti,HashedViking,LearningCircuit
@@ -0,0 +1,78 @@
name: Update Pre-commit Hooks
on:
workflow_dispatch:
schedule:
- cron: '0 8 * * 5' # every Friday at 08:00 UTC (day after NPM updates)
permissions: {} # Minimal top-level for OSSF Scorecard Token-Permissions
jobs:
update:
name: 🪝 Update Pre-commit Hooks
runs-on: ubuntu-latest
permissions:
contents: write # Required for creating PRs
pull-requests: write # Required for creating PRs
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: 📰 Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
ref: main
- name: 🐍 Setup Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.x'
- name: 📦 Install pre-commit-update
run: |
python -m pip install pip==25.0 \
--hash=sha256:b6eb97a803356a52b2dd4bb73ba9e65b2ba16caa6bcb25a7497350a4e5859b65
pip install pre-commit-update==0.6.1 \
--hash=sha256:db00891b3384776daaaa5721fd54a448ded19daf87635a3c77b7508eaf7d1634
- name: 🔄 Update pre-commit hooks (stable versions only)
run: |
# pre-commit-update skips alpha/beta/rc versions by default
pre-commit-update
- name: 📝 Check for changes
id: verify-changed-files
run: |
if git diff --quiet .pre-commit-config.yaml; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "No changes detected in .pre-commit-config.yaml"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
echo "Changes detected in .pre-commit-config.yaml"
echo "## Changes:" >> "$GITHUB_STEP_SUMMARY"
git diff .pre-commit-config.yaml >> "$GITHUB_STEP_SUMMARY"
fi
- name: 📝 Create pull request
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
if: steps.verify-changed-files.outputs.changed == 'true'
with:
branch: update-precommit-hooks-${{ github.run_number }}
title: 🤖 Update pre-commit hooks
body: |
This PR updates pre-commit hooks to their latest versions.
**Changes include:**
- Updated hook versions for better linting and bug fixes
- Ensures consistency with latest tool versions
This PR was created by the Pre-commit Update Bot.
sign-commits: true
add-paths: .pre-commit-config.yaml
commit-message: 🤖 Update pre-commit hooks
labels: maintenance
draft: false
base: main
reviewers: djpetti,HashedViking,LearningCircuit
@@ -0,0 +1,162 @@
name: Validate Docker Image Pinning
on:
pull_request:
branches: [main]
paths:
- '**/Dockerfile*'
- '**/docker-compose*.yml'
- '**/docker-compose*.yaml'
- '.github/workflows/*.yml'
- '.github/workflows/*.yaml'
- '.github/workflows/validate-image-pinning.yml'
- '.github/scripts/validate-docker-compose-images.sh'
- '.github/scripts/validate-workflow-images.py'
workflow_call: # Called by ci-gate.yml for release pipeline
workflow_dispatch:
# No concurrency group — intentionally omitted.
# This workflow triggers on both pull_request and workflow_call (from
# ci-gate.yml / release-gate.yml). A shared concurrency key would cause
# direct PR runs and workflow_call runs to cancel each other mid-flight.
# See #3554 (reverted in #3599) for context.
permissions: {} # Minimal permissions for OSSF Scorecard
jobs:
validate-docker-compose:
name: Validate docker-compose Images
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Validate docker-compose image pinning
run: |
chmod +x .github/scripts/validate-docker-compose-images.sh
.github/scripts/validate-docker-compose-images.sh
validate-workflow-images:
name: Validate Workflow Service Containers
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.12'
- name: Install PyYAML
run: pip install pyyaml==6.0.2
- name: Validate workflow image pinning
run: |
chmod +x .github/scripts/validate-workflow-images.py
python .github/scripts/validate-workflow-images.py
summary:
name: Image Pinning Validation Summary
needs: [validate-docker-compose, validate-workflow-images]
runs-on: ubuntu-latest
if: always()
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
# zizmor: ignore[template-injection] - values passed via env vars, not interpolated in shell
- name: Generate summary
env:
DOCKER_COMPOSE_RESULT: ${{ needs.validate-docker-compose.result }}
WORKFLOW_IMAGES_RESULT: ${{ needs.validate-workflow-images.result }}
run: |
{
echo "## 🔒 Docker Image Pinning Validation"
echo ""
echo "### What is Image Pinning?"
echo ""
echo "Pinning Docker images with SHA256 digests ensures:"
echo ""
echo "- 🔒 **Security**: Protection against supply chain attacks"
echo "- 🔄 **Reproducibility**: Exact same image bytes every time"
echo "- 🛡️ **Immutability**: Tags like \`:latest\` can be changed, but SHA digests cannot"
echo ""
echo "### Validation Results"
echo ""
if [ "$DOCKER_COMPOSE_RESULT" = "success" ] && \
[ "$WORKFLOW_IMAGES_RESULT" = "success" ]; then
echo "✅ **All images properly pinned with SHA256 digests**"
echo ""
echo "All docker-compose and workflow files pass validation."
else
echo "❌ **Image pinning violations found**"
echo ""
if [ "$DOCKER_COMPOSE_RESULT" != "success" ]; then
echo "- ❌ docker-compose files have unpinned images"
fi
if [ "$WORKFLOW_IMAGES_RESULT" != "success" ]; then
echo "- ❌ Workflow files have unpinned service containers"
fi
echo ""
echo "See job logs above for details and fix instructions."
fi
echo ""
echo "### How to Fix Unpinned Images"
echo ""
echo "\`\`\`bash"
echo "# 1. Pull the image"
echo "docker pull <image:tag>"
echo ""
echo "# 2. Get the SHA digest"
echo "docker inspect <image:tag> | jq -r '.[0].RepoDigests[0]'"
echo ""
echo "# 3. Update your file"
echo "image: <image:tag>@sha256:..."
echo "\`\`\`"
echo ""
echo "### Resources"
echo ""
echo "- [OSSF Scorecard - Pinned Dependencies](https://github.com/ossf/scorecard/blob/main/docs/checks.md#pinned-dependencies)"
echo "- [Docker Image Digests Documentation](https://docs.docker.com/engine/reference/commandline/pull/#pull-an-image-by-digest-immutable-identifier)"
} >> "$GITHUB_STEP_SUMMARY"
- name: Fail if validation found issues
env:
DOCKER_COMPOSE_RESULT: ${{ needs.validate-docker-compose.result }}
WORKFLOW_IMAGES_RESULT: ${{ needs.validate-workflow-images.result }}
run: |
if [ "$DOCKER_COMPOSE_RESULT" != "success" ] || \
[ "$WORKFLOW_IMAGES_RESULT" != "success" ]; then
echo "::error::Image pinning validation failed"
exit 1
fi
+203
View File
@@ -0,0 +1,203 @@
name: Version Auto-Bump
# Run AFTER merge to main, creates a PR for version bump
# Benefits:
# - Respects branch protection rules (no direct push to main)
# - PR checks run once without restarts
# - No version merge conflicts between parallel PRs
# - Version bumps are visible and reviewable
# - Uses fixed branch name to avoid clutter
#
# Manual dispatch supports minor/major bumps via the Actions tab dropdown.
# Auto-triggered runs (push to main) always bump patch.
on:
push:
branches:
- main
paths:
- 'src/**'
- 'pdm.lock'
- 'pyproject.toml'
- 'package.json'
- 'package-lock.json'
- 'Dockerfile'
workflow_dispatch:
inputs:
release_type:
description: 'Version bump type'
required: true
type: choice
options:
- patch
- minor
- major
default: 'patch'
permissions: {}
jobs:
version-bump:
runs-on: ubuntu-latest
# Skip if this push is already an auto-bump commit
if: "!contains(github.event.head_commit.message, 'chore: auto-bump version')"
permissions:
contents: write
pull-requests: write
actions: read # for generate_workflow_status.py to read run history
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 2
persist-credentials: false
- name: Determine release type
id: release
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
release_type="${{ inputs.release_type }}"
else
release_type="patch"
fi
echo "type=$release_type" >> "$GITHUB_OUTPUT"
echo "Release type: $release_type"
- name: Check if version was bumped in this push
id: check
if: github.event_name != 'workflow_dispatch'
run: |
# Check if version changed in the commits being pushed
if git diff HEAD~1 -G"__version__" -- src/local_deep_research/__version__.py | grep -E '\+.*__version__.*='; then
echo "Version was manually bumped in this push"
echo "needs_bump=false" >> "$GITHUB_OUTPUT"
else
echo "Version not bumped, will auto-bump"
echo "needs_bump=true" >> "$GITHUB_OUTPUT"
fi
- name: Set up Python
if: steps.check.outputs.needs_bump != 'false'
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.12'
- name: Set up PDM with pdm-bump plugin
if: steps.check.outputs.needs_bump != 'false'
uses: pdm-project/setup-pdm@973541a5febeafcfdadf8a51211435be6ecfd90f # v4.5
with:
python-version: '3.12'
- name: Install pdm-bump plugin
if: steps.check.outputs.needs_bump != 'false'
run: |
pdm self add pdm-bump
- name: Bump version
if: steps.check.outputs.needs_bump != 'false'
id: bump
run: |
release_type="${{ steps.release.outputs.type }}"
# Get current version before bump (prefer pdm, fallback to grep)
current_version=$(pdm show --version 2>/dev/null || grep -oP '(?<=__version__ = ")[^"]*' src/local_deep_research/__version__.py)
echo "Current version: $current_version"
echo "current_version=$current_version" >> "$GITHUB_OUTPUT"
# Bump version using pdm-bump
echo "Bumping $release_type version..."
pdm bump "$release_type"
# Get new version after bump (prefer pdm, fallback to grep)
new_version=$(pdm show --version 2>/dev/null || grep -oP '(?<=__version__ = ")[^"]*' src/local_deep_research/__version__.py)
echo "New version: $new_version"
echo "new_version=$new_version" >> "$GITHUB_OUTPUT"
# Sync package.json version with __version__.py
jq --arg v "$new_version" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
echo "Updated package.json version to $new_version"
- name: Regenerate configuration docs
if: steps.check.outputs.needs_bump != 'false'
run: python scripts/generate_config_docs.py
# Refreshes docs/ci/workflow-status.md so the version-bump PR
# carries the current snapshot. Output uses coarse "last week /
# last month" buckets so within-day reruns produce zero diff —
# the diff that lands in this PR only shows workflows whose
# bucket has actually shifted since the previous release.
#
# ~340 GitHub API calls per run (well under the GITHUB_TOKEN
# 1000/hr workflow-runs limit). Needs `actions: read` on the
# job permissions block above.
- name: Regenerate workflow status dashboard
if: steps.check.outputs.needs_bump != 'false'
# Don't block the version bump if the dashboard refresh fails.
# The regen calls ~340 GitHub API endpoints; a transient outage
# or rate-limit hit would otherwise prevent the version-bump PR
# from being created at all. On failure the dashboard just stays
# at its previous snapshot until the next successful run.
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
pip install pyyaml==6.0.3
python scripts/generate_workflow_status.py
- name: Generate PR body
if: steps.check.outputs.needs_bump != 'false'
id: pr_body
run: |
release_type="${{ steps.release.outputs.type }}"
current="${{ steps.bump.outputs.current_version }}"
new="${{ steps.bump.outputs.new_version }}"
body_file=$(mktemp)
{
echo "## Summary"
echo "- Bump **${release_type}** version: ${current} → ${new}"
echo ""
if [ "$release_type" = "major" ]; then
echo "> [!CAUTION]"
echo "> This is a **major** version bump. It signals breaking changes."
echo "> Please review carefully before merging."
elif [ "$release_type" = "minor" ]; then
echo "> [!IMPORTANT]"
echo "> This is a **minor** version bump, indicating new features or significant changes."
fi
echo ""
if [ "$release_type" = "patch" ]; then
echo "This PR was automatically created by the version bump workflow."
else
echo "This PR was manually triggered via the Actions tab."
fi
echo "Approve and merge to trigger a new release."
echo ""
echo "Configuration docs (docs/CONFIGURATION.md) and the workflow status dashboard (docs/ci/workflow-status.md) have been regenerated."
} > "$body_file"
echo "body_file=$body_file" >> "$GITHUB_OUTPUT"
# Use peter-evans/create-pull-request with GITHUB_TOKEN so the PR is created
# by github-actions[bot] - this allows the repo owner to approve the PR
- name: Create Pull Request
if: steps.check.outputs.needs_bump != 'false'
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "chore: auto-bump version to ${{ steps.bump.outputs.new_version }}"
branch: chore/auto-version-bump
delete-branch: true
title: "chore: bump ${{ steps.release.outputs.type }} version to ${{ steps.bump.outputs.new_version }}"
body-path: ${{ steps.pr_body.outputs.body_file }}
labels: |
automation
maintenance
+37
View File
@@ -0,0 +1,37 @@
name: Vulture Dead Code Detection
on:
workflow_call: # Allows this workflow to be called by other workflows
workflow_dispatch:
# Top-level permissions set to minimum (OSSF Scorecard Token-Permissions)
permissions: {}
jobs:
vulture:
runs-on: ubuntu-latest
timeout-minutes: 5
name: Scan for dead code
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.12'
- name: Install vulture
run: pip install "vulture~=2.14"
- name: Run vulture (src/ only)
run: vulture src/local_deep_research/ vulture_whitelist.py --min-confidence 80
+110
View File
@@ -0,0 +1,110 @@
name: Welcome first-time contributors
# Posts a single welcome comment on a contributor's FIRST PR (filtered per
# user). Uses pull_request_target so forked PRs receive a writable token;
# the script reads only sender.login (operator-trusted via GitHub) and
# never executes fork-controlled content — no checkout, no shell.
#
# We do NOT use actions/first-interaction: its isFirstPullRequest check
# has no author filter (lists all repo PRs and matches only on the lowest
# PR number), so it would never fire on a repo with prior history.
on:
# zizmor: ignore[dangerous-triggers] — pull_request_target is required so
# fork PRs receive a writable token to post the welcome comment. The job
# never checks out PR content, never runs fork-controlled scripts, and
# only reads `sender.login` (operator-trusted GitHub event metadata). The
# comment body is a static template with no PR-controlled interpolation.
pull_request_target:
types: [opened]
permissions: {} # Minimal top-level for OSSF Scorecard Token-Permissions
jobs:
welcome:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
# createComment on a PR returns 403 with only `issues: write` —
# GitHub requires `pull-requests: write` when the issue resource
# is actually a PR (Accepted-Permissions header lists both).
issues: write
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Welcome first-time contributor
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
// Iterative pattern from actions/github-script@v9.0.0 README:
// skip the just-opened PR by number and return early if ANY
// other PR by this author exists. Robust to GitHub's eventual
// consistency on listForRepo (the just-opened PR may not yet
// be indexed). Using sender.login (canonical) — for `opened`
// it equals pull_request.user.login.
const sender = context.payload.sender;
// Skip bots. user.type === 'Bot' is the canonical signal;
// also belt-and-suspenders the [bot]-suffix check (consistent
// with the PR triage workflow's KNOWN_BOTS handling).
if (sender.type === 'Bot' || sender.login.endsWith('[bot]')) {
console.log(`Sender ${sender.login} is a bot. Skipping.`);
return;
}
const opts = github.rest.issues.listForRepo.endpoint.merge({
...context.issue,
creator: sender.login,
state: 'all',
});
const items = await github.paginate(opts);
for (const item of items) {
if (item.number === context.issue.number) continue;
if (item.pull_request) {
console.log(
`Sender ${sender.login} already has PR #${item.number}. Skipping.`,
);
return;
}
}
const body = [
`Welcome to local-deep-research — and thank you for your contribution. We genuinely appreciate the time you're putting in.`,
``,
`Here's a starter pack to help your PR move smoothly:`,
``,
`**Get set up locally**`,
`- [Installation guide](https://github.com/LearningCircuit/local-deep-research/blob/main/docs/installation.md) — running LDR`,
`- [Developer guide](https://github.com/LearningCircuit/local-deep-research/blob/main/docs/developing.md) — setting up a dev environment`,
`- Install our pre-commit hooks (one-time, [details in CONTRIBUTING.md](https://github.com/LearningCircuit/local-deep-research/blob/main/CONTRIBUTING.md#-quick-start)):`,
` \`\`\``,
` pre-commit install`,
` pre-commit install-hooks`,
` \`\`\``,
``,
`**Understand the codebase**`,
`- [Architecture overview](https://github.com/LearningCircuit/local-deep-research/blob/main/docs/architecture.md) — how the pieces fit together`,
`- [Tests README](https://github.com/LearningCircuit/local-deep-research/blob/main/tests/README.md) — how to run the test suite locally`,
`- [FAQ](https://github.com/LearningCircuit/local-deep-research/blob/main/docs/faq.md) and [Troubleshooting](https://github.com/LearningCircuit/local-deep-research/blob/main/docs/troubleshooting.md) — common setup, config, and operational issues`,
`- Found a security issue? See [SECURITY.md](https://github.com/LearningCircuit/local-deep-research/blob/main/SECURITY.md) for the responsible-disclosure process — please don't open a public PR for it.`,
``,
`**Before you ask for review**`,
`- Open PRs against \`main\` unless a maintainer says otherwise.`,
`- Confirm your PR has a single clear purpose ([PR process](https://github.com/LearningCircuit/local-deep-research/blob/main/CONTRIBUTING.md#-pull-request-process)).`,
`- Describe in your PR body what you tested by hand — "CI is green" alone isn't enough.`,
`- Drop by [Discord](https://discord.gg/ttcqQeFcJ3) if you'd like to chat with maintainers or other contributors.`,
``,
`A maintainer will take a look — if you don't hear back within 7 days, feel free to ping.`,
].join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body,
});
+65
View File
@@ -0,0 +1,65 @@
name: Zizmor GitHub Actions Security
on:
workflow_dispatch:
workflow_call: # Called by release-gate.yml
schedule:
# Run security audit weekly on Monday at 9 AM UTC
- cron: '0 9 * * 1'
permissions:
contents: read
jobs:
zizmor-scan:
name: Zizmor Workflow Security Scan
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
security-events: write
actions: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Run zizmor security scan
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
with:
inputs: .github/workflows/
min-severity: low
advanced-security: true
token: ${{ secrets.GITHUB_TOKEN }}
- name: Display scan summary
if: always()
run: |
{
echo "## Zizmor GitHub Actions Security Scan"
echo ""
echo "### What is Zizmor?"
echo "Zizmor is a security linter for GitHub Actions workflows that detects:"
echo ""
echo "- **Template Injection**: Attacker-controlled input in expressions"
echo "- **ArtiPACKED**: Credentials leaked in workflow artifacts"
echo "- **Vulnerable Actions**: Third-party actions with known CVEs"
echo "- **Impostor Commits**: Hash-pinned actions pointing to forks"
echo "- **Hardcoded Credentials**: Plain-text secrets in workflows"
echo "- **Excessive Permissions**: Over-privileged workflow tokens"
echo "- **Unpinned Actions**: Mutable action references"
echo "- **Cache Poisoning**: Unsafe cache usage patterns"
echo ""
echo "### Resources"
echo "- [Zizmor GitHub](https://github.com/woodruffw/zizmor)"
echo "- [Security Tab](https://github.com/${{ github.repository }}/security/code-scanning)"
echo "- Results uploaded to GitHub Security tab for detailed analysis"
} >> "$GITHUB_STEP_SUMMARY"