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
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:
@@ -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.');
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)"
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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.');
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
# ~30–60 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
./
|
||||
@@ -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
|
||||
./
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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__}')"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user