chore: import upstream snapshot with attribution
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
dependency-audit / pip-audit (push) Has been cancelled
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
dependency-audit / pip-audit (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
# AND-gate on both the actor (``sender.login``) and the keyword. For the
|
||||
# ``issues`` event we additionally require ``issue.user.login`` to match
|
||||
# — otherwise an attacker could open an issue with ``@claude`` in the
|
||||
# body and wait for ``teng-lin`` to assign it: ``sender`` would then be
|
||||
# ``teng-lin`` while the prompt text remains attacker-authored. Keeping
|
||||
# the author check pins both the trigger and the prompt to the same
|
||||
# trusted account.
|
||||
if: |
|
||||
github.event.sender.login == 'teng-lin' && (
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && github.event.issue.user.login == 'teng-lin' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write # Required to post inline review-thread comments (not just a sticky issue comment)
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@558b1d6cab4085c7753fe402c10bef0fbb92ac7a # @ v1.0.165
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Allow the GitHub inline-comment tool so a `@claude review` lands its
|
||||
# findings as inline PR review-thread comments (visible + addressable
|
||||
# like gemini/coderabbit), instead of only a single sticky issue
|
||||
# comment that the merge gate can miss. `--allowedTools` is additive in
|
||||
# this action (base Read/Glob/Grep/LS stay included). See docs/usage.md.
|
||||
claude_args: '--allowedTools "mcp__github_inline_comment__create_inline_comment"'
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
name: CodeQL
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '0 12 * * 1' # Monday at noon UTC
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
security-events: write
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: python
|
||||
# ``py/clear-text-logging-sensitive-data`` flags every path
|
||||
# where a value tagged as secret reaches a print statement,
|
||||
# even when the project's local sanitiser
|
||||
# ``notebooklm._logging.scrub_secrets`` has already redacted
|
||||
# the value. ``codeql-config.yml`` scopes the suppression
|
||||
# narrowly to ``scripts/check_rpc_health.py`` (the only
|
||||
# script that prints decoded RPC responses for diagnostics);
|
||||
# the query still runs everywhere else.
|
||||
config-file: .github/codeql-config.yml
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
@@ -0,0 +1,68 @@
|
||||
# Runs pip-audit against the locked environment so a CVE landing in a
|
||||
# transitive dep surfaces in CI rather than at next user install. Any
|
||||
# unresolved advisory fails the job (a hard merge gate on PRs, a red main
|
||||
# build on push/schedule).
|
||||
name: dependency-audit
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- "pyproject.toml"
|
||||
- "uv.lock"
|
||||
- ".github/workflows/dependency-audit.yml"
|
||||
schedule:
|
||||
# Nightly at 06:00 UTC so transitive CVEs surface even when no PR
|
||||
# touches the manifest.
|
||||
- cron: "0 6 * * *"
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pip-audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Sync locked env (browser + dev + markdown)
|
||||
# Retried to ride out transient registry/network blips: a real
|
||||
# lockfile problem fails all 3 attempts identically, so this only
|
||||
# absorbs flakes — it never masks a genuine resolution error.
|
||||
shell: bash
|
||||
run: |
|
||||
attempt=1; max=3
|
||||
until uv sync --frozen --extra browser --extra dev --extra markdown; do
|
||||
if [ "$attempt" -ge "$max" ]; then
|
||||
echo "::error::uv sync failed after $max attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::uv sync attempt $attempt failed; retrying in $((attempt * 15))s" >&2
|
||||
sleep $((attempt * 15))
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
- name: Install pip-audit into the locked env
|
||||
# Pin pip-audit on the current major (2.x) so two consecutive nightly
|
||||
# runs use the same advisory-DB query logic — without this, a silent
|
||||
# bump to 3.x could change strictness without an accompanying PR.
|
||||
run: uv pip install 'pip-audit>=2.7.0,<3'
|
||||
|
||||
- name: pip-audit (locked env)
|
||||
# Hard merge gate: any unresolved advisory fails the job. If a
|
||||
# transient advisory genuinely needs to be ignored, pin the fix in
|
||||
# uv.lock or use pip-audit's --ignore-vuln for a tracked exception.
|
||||
# Audit the exported lock graph instead of the installed environment so
|
||||
# unreleased local package versions do not fail strict collection.
|
||||
run: |
|
||||
set -o pipefail
|
||||
uv export --frozen --extra browser --extra dev --extra markdown --format requirements-txt --no-emit-project \
|
||||
| uv run pip-audit --strict --require-hashes --disable-pip -r /dev/stdin
|
||||
@@ -0,0 +1,380 @@
|
||||
name: Nightly E2E Tests
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Main branch: 6 AM UTC (10 PM PST / 1 AM EST)
|
||||
- cron: '0 6 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
test_filter:
|
||||
description: 'Specific test to run (e.g., tests/e2e/test_downloads.py::TestDownloadReport)'
|
||||
required: false
|
||||
default: ''
|
||||
custom_branch:
|
||||
description: 'Override branch to test (leave empty to test the branch you triggered from)'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Determine which branch to test
|
||||
resolve-branch:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'teng-lin/notebooklm-py'
|
||||
outputs:
|
||||
branch: ${{ steps.resolve.outputs.branch }}
|
||||
is_standard: ${{ steps.resolve.outputs.is_standard }}
|
||||
sha: ${{ steps.target.outputs.sha }}
|
||||
short_sha: ${{ steps.target.outputs.short_sha }}
|
||||
steps:
|
||||
- name: Resolve target branch
|
||||
id: resolve
|
||||
# Route every workflow_dispatch input + GitHub-context value through
|
||||
# the env block so crafted values (e.g. ``"; rm -rf / ; #``) reach
|
||||
# the shell as literal strings, not as inlined script. ``GITHUB_OUTPUT``
|
||||
# is set automatically by the runner.
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
CUSTOM_BRANCH: ${{ inputs.custom_branch }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$EVENT_NAME" = "schedule" ]; then
|
||||
# Scheduled runs test main only. Release branches are manual.
|
||||
TARGET="main"
|
||||
else
|
||||
# Manual: use custom_branch if set, otherwise use triggering branch
|
||||
if [ -n "$CUSTOM_BRANCH" ]; then
|
||||
TARGET="$CUSTOM_BRANCH"
|
||||
else
|
||||
TARGET="$REF_NAME"
|
||||
fi
|
||||
fi
|
||||
echo "branch=$TARGET" >> "$GITHUB_OUTPUT"
|
||||
# Check if it's main or a release branch
|
||||
case "$TARGET" in
|
||||
main|release/*)
|
||||
echo "is_standard=true" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
*)
|
||||
echo "is_standard=false" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
esac
|
||||
echo "Resolved branch: $TARGET"
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
if: steps.resolve.outputs.is_standard == 'true'
|
||||
with:
|
||||
ref: ${{ steps.resolve.outputs.branch }}
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Resolve target commit
|
||||
id: target
|
||||
if: steps.resolve.outputs.is_standard == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SHA=$(git rev-parse HEAD)
|
||||
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "short_sha=$(git rev-parse --short=12 HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved commit: $SHA"
|
||||
|
||||
# Run E2E tests on the resolved branch
|
||||
e2e:
|
||||
name: E2E Tests (${{ needs.resolve-branch.outputs.branch }}@${{ needs.resolve-branch.outputs.short_sha }}/${{ matrix.os }})
|
||||
needs: resolve-branch
|
||||
if: needs.resolve-branch.outputs.is_standard == 'true'
|
||||
runs-on: ${{ matrix.os }}
|
||||
# Secret-bearing job. Two-layer gating:
|
||||
#
|
||||
# 1. ``needs.resolve-branch.outputs.is_standard`` — set by the upstream
|
||||
# ``resolve-branch`` job. Only ``main`` and ``release/*`` branches
|
||||
# (or scheduled cron triggers, which resolve to one of those) flip
|
||||
# this to ``true``; any other branch keeps it ``false`` and the
|
||||
# job-level ``if:`` above skips this whole job (no secret values
|
||||
# land in the runner env). The step-level ``if:`` guards on the
|
||||
# secret-bearing steps below are belt-and-suspenders so the gate
|
||||
# stays visible if the job-level guard is ever loosened.
|
||||
# 2. ``environment: protected-readonly`` (unconditional). The secrets
|
||||
# this job consumes (``NOTEBOOKLM_AUTH_JSON`` and friends) live only
|
||||
# in that environment, so the binding has to be unconditional —
|
||||
# issue #1009 surfaced the scheduled cron failing when the previous
|
||||
# conditional (``workflow_dispatch``-only) form fell back to
|
||||
# repo-level secrets that no longer exist. Add a ``required
|
||||
# reviewers`` rule on the environment if you want to block
|
||||
# workflow_dispatch behind manual approval; scheduled runs would
|
||||
# then queue too, so today this env carries no protection rules.
|
||||
# See docs/development.md → "Workflow secret gates".
|
||||
environment: protected-readonly
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
env:
|
||||
# Pin Playwright's browser install dir to a single workspace-relative
|
||||
# path on every OS — see test.yml for the rationale (sidesteps the
|
||||
# actions/cache@v6 Windows dual-path restore flake).
|
||||
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
|
||||
# Single source of truth for the coverage-floor sentinel path, referenced by
|
||||
# both the E2E step (writes it) and the "Enforce coverage floors" step (gates
|
||||
# on it). Harmless when unset-enforcement steps run — they never write it.
|
||||
E2E_COVERAGE_FLOOR_SENTINEL: ${{ github.workspace }}/.e2e-coverage-floor-failed
|
||||
|
||||
concurrency:
|
||||
group: nightly-${{ needs.resolve-branch.outputs.branch }}-${{ matrix.os }}
|
||||
cancel-in-progress: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.resolve-branch.outputs.sha }}
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Stamp E2E target
|
||||
env:
|
||||
EXPECTED_SHA: ${{ needs.resolve-branch.outputs.sha }}
|
||||
TARGET_BRANCH: ${{ needs.resolve-branch.outputs.branch }}
|
||||
MATRIX_OS: ${{ matrix.os }}
|
||||
shell: bash
|
||||
run: |
|
||||
ACTUAL_SHA=$(git rev-parse HEAD)
|
||||
if [ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]; then
|
||||
echo "::error::Checked out $ACTUAL_SHA, expected $EXPECTED_SHA"
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
echo "### E2E target"
|
||||
echo "- Branch: \`$TARGET_BRANCH\`"
|
||||
echo "- Commit: \`$ACTUAL_SHA\`"
|
||||
echo "- OS: \`$MATRIX_OS\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # @ v7.0.0
|
||||
|
||||
- name: Install dependencies
|
||||
# `uv sync --frozen` uses `uv.lock` for deterministic dep resolution
|
||||
# (same as the contributor workflow in docs/installation.md). Extras
|
||||
# cover the full E2E surface: `browser` = playwright, `dev` = pytest +
|
||||
# plugins, `markdown` = markdownify (used by source-conversion tests).
|
||||
# Retried to ride out transient registry/network blips: a real lockfile
|
||||
# problem fails all 3 attempts identically, so this only absorbs flakes
|
||||
# — it never masks a genuine resolution error.
|
||||
shell: bash
|
||||
run: |
|
||||
# --extra impersonate so the ubuntu curl_cffi smoke step below can run.
|
||||
# --extra mcp so the MCP/CLI live e2e suite (tests/e2e/test_mcp*.py,
|
||||
# test_cli_live.py) actually RUNS instead of importorskip-ping fastmcp.
|
||||
# --extra server so the REST live e2e suite (tests/e2e/test_server_live.py)
|
||||
# RUNS instead of importorskip-ping fastapi.
|
||||
attempt=1; max=3
|
||||
until uv sync --frozen --extra browser --extra dev --extra markdown --extra impersonate --extra mcp --extra server; do
|
||||
if [ "$attempt" -ge "$max" ]; then
|
||||
echo "::error::uv sync failed after $max attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::uv sync attempt $attempt failed; retrying in $((attempt * 15))s" >&2
|
||||
sleep $((attempt * 15))
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
- name: Get Playwright version
|
||||
id: playwright-version
|
||||
shell: bash
|
||||
# `uv pip show` / bare `pip show` after activation can resolve outside
|
||||
# `.venv` when uv has not seeded pip into the project venv, yielding an
|
||||
# empty version that collapses the browser cache key to
|
||||
# `playwright-${{ matrix.os }}--ws`. `importlib.metadata` reads
|
||||
# `.venv`'s actual installed dist-info via the active interpreter, so
|
||||
# the version is always correct.
|
||||
run: |
|
||||
VERSION=$(uv run python -c 'import importlib.metadata as m; print(m.version("playwright"))')
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{ github.workspace }}/.playwright-browsers
|
||||
# ``-ws`` suffix invalidates archives keyed to the prior dual-path
|
||||
# stanza so first restores after this change land on the new path.
|
||||
key: playwright-${{ matrix.os }}-${{ steps.playwright-version.outputs.version }}-ws
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: uv run playwright install chromium
|
||||
|
||||
- name: Install Playwright system dependencies (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: uv run playwright install-deps chromium
|
||||
|
||||
# Fail-fast preflight. Without this, an empty NOTEBOOKLM_AUTH_JSON
|
||||
# (env-binding misconfig, missing repo-level fallback, etc.) lets the
|
||||
# E2E suite proceed; pytest then skips every auth-requiring test
|
||||
# silently and the job lands green with 0 tests run (issue #1009).
|
||||
- name: Verify auth secret is present
|
||||
if: needs.resolve-branch.outputs.is_standard == 'true'
|
||||
env:
|
||||
NOTEBOOKLM_AUTH_JSON: ${{ secrets.NOTEBOOKLM_AUTH_JSON }}
|
||||
NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID: ${{ secrets.NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID }}
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -z "${NOTEBOOKLM_AUTH_JSON:-}" ]; then
|
||||
echo "::error::NOTEBOOKLM_AUTH_JSON resolved to empty. Env binding or secret config is broken — see docs/development.md → Workflow secret gates."
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID:-}" ]; then
|
||||
echo "::error::NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID resolved to empty."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run E2E tests
|
||||
id: e2e
|
||||
# Hard gate: only standard branches (main/release/*, or scheduled cron
|
||||
# runs which resolve to one of those) expose credentials. Any other
|
||||
# branch — including ad-hoc workflow_dispatch on a feature branch —
|
||||
# gets ``is_standard == 'false'`` from resolve-branch and skips here
|
||||
# outright. The job-level ``environment:`` adds the maintainer approval
|
||||
# layer for workflow_dispatch runs that DO resolve as standard.
|
||||
if: needs.resolve-branch.outputs.is_standard == 'true'
|
||||
# Don't fail the job here — the retry step below gets a 10-min cool-down
|
||||
# shot at any failures, and its exit code is what marks the job red.
|
||||
continue-on-error: true
|
||||
env:
|
||||
NOTEBOOKLM_AUTH_JSON: ${{ secrets.NOTEBOOKLM_AUTH_JSON }}
|
||||
NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID: ${{ secrets.NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID }}
|
||||
NOTEBOOKLM_GENERATION_NOTEBOOK_ID: ${{ secrets.NOTEBOOKLM_GENERATION_NOTEBOOK_ID }}
|
||||
# Enforce the live coverage floors on the nightly only (fresh quota): if a
|
||||
# monitored surface (live chat / live generation) produced zero real coverage
|
||||
# because every marked test was rate-limited, record it to the sentinel that
|
||||
# the "Enforce coverage floors" step below gates on. Written (not raised) so
|
||||
# a pure-skip run stays exit 0 and doesn't trip the continue-on-error retry.
|
||||
# The release job (verify-package) leaves this unset → skip-only (#1819).
|
||||
# (E2E_COVERAGE_FLOOR_SENTINEL is defined once at job level.)
|
||||
E2E_ENFORCE_COVERAGE_FLOOR: "1"
|
||||
# Route every workflow_dispatch input + upstream needs.* value through
|
||||
# the env block so crafted values (e.g. ``"; echo PWN``) reach pytest
|
||||
# as literal strings, not as inlined shell. ``TARGET_BRANCH`` is
|
||||
# derived from ``inputs.custom_branch`` upstream; ``TEST_FILTER`` is
|
||||
# the direct workflow_dispatch input (empty for scheduled runs).
|
||||
TEST_FILTER: ${{ inputs.test_filter }}
|
||||
TARGET_BRANCH: ${{ needs.resolve-branch.outputs.branch }}
|
||||
MATRIX_OS: ${{ matrix.os }}
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Testing branch: $TARGET_BRANCH"
|
||||
# Start from a clean coverage-floor sentinel so a stale file from a prior
|
||||
# run on a reused/self-hosted runner can't false-red the enforcement step.
|
||||
rm -f "$E2E_COVERAGE_FLOOR_SENTINEL"
|
||||
# --reruns 1 catches sub-minute network blips; longer chat-throttle
|
||||
# recovery is handled by the cool-down retry step below.
|
||||
if [ -n "$TEST_FILTER" ]; then
|
||||
# Run specific test(s) when filter provided.
|
||||
# ``--`` separator forces $TEST_FILTER to be parsed as positional
|
||||
# file/path args, never as pytest options (e.g. a crafted ``--co``
|
||||
# or ``-k "expr"`` value cannot inject pytest flags).
|
||||
# The coverage floor is a whole-surface guarantee — meaningless for a
|
||||
# single filtered test, and a marked-but-throttled pick would false-red
|
||||
# the run — so disable enforcement for filtered dispatches (codex).
|
||||
unset E2E_ENFORCE_COVERAGE_FLOOR
|
||||
uv run pytest -s -v --tb=short --reruns 1 --reruns-delay 30 -- "$TEST_FILTER"
|
||||
elif [ "$MATRIX_OS" = "ubuntu-latest" ]; then
|
||||
# Linux: run all E2E tests except variants
|
||||
uv run pytest tests/e2e -m "not variants" -s -v --tb=short --reruns 1 --reruns-delay 30
|
||||
else
|
||||
# Windows: run only read-only tests (safer, avoids write operations)
|
||||
uv run pytest tests/e2e -m "readonly and not variants" -s -v --tb=short --reruns 1 --reruns-delay 30
|
||||
fi
|
||||
|
||||
- name: Retry failed E2E tests after 10-min cool-down
|
||||
# Tests still throttled after the cool-down become skips via the
|
||||
# _install_chat_rate_limit_skip fixture in tests/e2e/conftest.py.
|
||||
# Same ``is_standard`` hard gate as the primary E2E step above — the
|
||||
# retry must never expose secrets on a non-standard branch even if a
|
||||
# previous step somehow ran (e.g. re-run with edited workflow inputs).
|
||||
if: |
|
||||
needs.resolve-branch.outputs.is_standard == 'true'
|
||||
&& steps.e2e.outcome == 'failure'
|
||||
env:
|
||||
NOTEBOOKLM_AUTH_JSON: ${{ secrets.NOTEBOOKLM_AUTH_JSON }}
|
||||
NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID: ${{ secrets.NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID }}
|
||||
NOTEBOOKLM_GENERATION_NOTEBOOK_ID: ${{ secrets.NOTEBOOKLM_GENERATION_NOTEBOOK_ID }}
|
||||
# Enforce here too: a marked test that failed on the main run and turns into
|
||||
# a rate-limit skip after the cool-down would otherwise leave no PASS event
|
||||
# for its surface. Appends PASS/SKIP events to the same job-level sentinel
|
||||
# (the main step's rm -f already cleared any stale one). (codex/coderabbit)
|
||||
E2E_ENFORCE_COVERAGE_FLOOR: "1"
|
||||
shell: bash
|
||||
run: |
|
||||
# Missing lastfailed after a failed e2e step means pytest crashed
|
||||
# before writing the cache (import error, OOM, etc.) — that's a real
|
||||
# failure, not a recoverable rate-limit, so fail the job rather than
|
||||
# silently going green.
|
||||
if [ ! -f .pytest_cache/v/cache/lastfailed ]; then
|
||||
echo "::error::No lastfailed cache from previous step; pytest likely crashed before running tests."
|
||||
exit 1
|
||||
fi
|
||||
echo "Initial E2E run failed. Sleeping 600s before retrying failed tests."
|
||||
sleep 600
|
||||
# Pin ``tests/e2e`` so pyproject's ``addopts = --ignore=tests/e2e``
|
||||
# doesn't drop every lastfailed entry; otherwise pytest's default
|
||||
# ``--last-failed-no-failures=all`` would expand to the full non-e2e
|
||||
# suite under NOTEBOOKLM_AUTH_JSON. ``=none`` makes a missing cache
|
||||
# fail fast instead.
|
||||
uv run pytest tests/e2e --last-failed --last-failed-no-failures=none -s -v --tb=short
|
||||
|
||||
- name: curl_cffi transport smoke (live, minimal)
|
||||
# Minimum live coverage of the opt-in curl_cffi transport: the handful of
|
||||
# @pytest.mark.impersonate_smoke e2e tests (one per transport-critical op —
|
||||
# read / ask / upload / download) run with NOTEBOOKLM_TRANSPORT=curl_cffi.
|
||||
# ubuntu-only (the write path exercises the upload-fingerprint case that no
|
||||
# offline test can reach; Windows just needs the read/ask path, already
|
||||
# covered by the httpx legs + the test.yml hermetic matrix). Same is_standard
|
||||
# secret gate as the steps above.
|
||||
if: |
|
||||
needs.resolve-branch.outputs.is_standard == 'true'
|
||||
&& matrix.os == 'ubuntu-latest'
|
||||
env:
|
||||
NOTEBOOKLM_AUTH_JSON: ${{ secrets.NOTEBOOKLM_AUTH_JSON }}
|
||||
NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID: ${{ secrets.NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID }}
|
||||
NOTEBOOKLM_GENERATION_NOTEBOOK_ID: ${{ secrets.NOTEBOOKLM_GENERATION_NOTEBOOK_ID }}
|
||||
NOTEBOOKLM_TRANSPORT: curl_cffi
|
||||
shell: bash
|
||||
run: uv run pytest tests/e2e -m impersonate_smoke -s -v --tb=short --reruns 1 --reruns-delay 30
|
||||
|
||||
- name: Enforce coverage floors
|
||||
# Gate the nightly on the live coverage floors independently of the
|
||||
# continue-on-error main run + --last-failed retry (which can't re-attempt
|
||||
# throttled generation — those are skips, not failures). The main e2e step
|
||||
# records a breach to the sentinel; fail here if any surface produced zero
|
||||
# real coverage (every marked live-chat / live-generation test rate-limited).
|
||||
# ``always()`` so a breach recorded before an unrelated later failure still
|
||||
# surfaces. The release job never sets the sentinel, so this is nightly-only.
|
||||
if: ${{ always() && needs.resolve-branch.outputs.is_standard == 'true' }}
|
||||
shell: bash
|
||||
run: |
|
||||
sentinel="$E2E_COVERAGE_FLOOR_SENTINEL"
|
||||
if [ ! -f "$sentinel" ]; then
|
||||
echo "Coverage floors satisfied (no monitored surface emitted a skip)."
|
||||
exit 0
|
||||
fi
|
||||
# Reconcile PASS/SKIP events accumulated across the main run + retry: a
|
||||
# surface breaches only if it was SKIP-ped somewhere and PASSed nowhere.
|
||||
passed="$(awk -F'\t' '$1=="PASS"{print $2}' "$sentinel" | sort -u)"
|
||||
skipped="$(awk -F'\t' '$1=="SKIP"{print $2}' "$sentinel" | sort -u)"
|
||||
breach="$(comm -23 <(printf '%s\n' "$skipped" | sed '/^$/d') \
|
||||
<(printf '%s\n' "$passed" | sed '/^$/d'))"
|
||||
if [ -n "$breach" ]; then
|
||||
echo "::error::E2E coverage floor breached — surface(s) rate-limited with zero real coverage:"
|
||||
printf ' %s\n' "$breach"
|
||||
exit 1
|
||||
fi
|
||||
echo "Coverage floors satisfied (every skipped surface also had a passing test)."
|
||||
@@ -0,0 +1,95 @@
|
||||
# Publish the remote MCP server image to Docker Hub on a release tag.
|
||||
#
|
||||
# Opt-in: only runs on the canonical repo AND when the `DOCKERHUB_IMAGE`
|
||||
# repository variable is set (e.g. `youruser/notebooklm-mcp`). Configure once:
|
||||
# - Variables → DOCKERHUB_IMAGE = <namespace>/notebooklm-mcp
|
||||
# - Secrets → DOCKERHUB_USERNAME, DOCKERHUB_TOKEN (a Docker Hub access token)
|
||||
# Forks and unconfigured repos skip it entirely (no failing release job).
|
||||
#
|
||||
# Builds deploy/Dockerfile from the tagged source (context = repo root, same as
|
||||
# `docker compose`), multi-arch (amd64 + arm64). `:latest` only moves on a final
|
||||
# release — PEP 440 pre-releases (v0.8.0a1/b1/rc1) publish their version tag only.
|
||||
name: Publish MCP image to Docker Hub
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish-image:
|
||||
name: Build and push notebooklm-mcp
|
||||
runs-on: ubuntu-latest
|
||||
# `release` environment gates the Docker Hub secrets (maintainer-approved,
|
||||
# same as publish.yml). The actor pin restricts publishing to the
|
||||
# maintainer's own tag push; the repo + var checks skip forks/unconfigured
|
||||
# repos so a release never fails on a missing Docker Hub config.
|
||||
environment: release
|
||||
if: ${{ github.repository == 'teng-lin/notebooklm-py' && github.actor == 'teng-lin' && vars.DOCKERHUB_IMAGE != '' }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
# Full history so ALL tags are present — we compare against every released
|
||||
# version to decide whether :latest moves (never backward to a maintenance
|
||||
# release). `.dockerignore` excludes `.git`, so this never bloats the image.
|
||||
fetch-depth: 0
|
||||
|
||||
# Pin Python so `tomllib` (3.11+) in the next step never depends on the
|
||||
# runner's default python3 — mirrors publish.yml.
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Derive image tags (and validate tag == pyproject version)
|
||||
id: meta
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
TOML_VERSION=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
|
||||
if [ "$VERSION" != "$TOML_VERSION" ]; then
|
||||
echo "Error: tag version ($VERSION) doesn't match pyproject.toml ($TOML_VERSION)" >&2
|
||||
exit 1
|
||||
fi
|
||||
IMAGE="${{ vars.DOCKERHUB_IMAGE }}"
|
||||
TAGS="${IMAGE}:${VERSION}"
|
||||
# :latest must point at the HIGHEST released version and never move
|
||||
# backward. Two gates: (1) a final release only — bare X.Y.Z, no PEP 440
|
||||
# pre-release suffix (a/b/rc); (2) it's the max stable tag — maintenance
|
||||
# releases on an older line are tagged directly (e.g. v0.7.4 after v0.8.0),
|
||||
# so those keep :latest where it is.
|
||||
if echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
HIGHEST=$(git tag --list 'v[0-9]*' | sed 's/^v//' \
|
||||
| grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n1)
|
||||
if [ "$VERSION" = "$HIGHEST" ]; then
|
||||
TAGS="${TAGS},${IMAGE}:latest"
|
||||
else
|
||||
echo "Not the highest stable tag (highest=$HIGHEST) — publishing version tag only, :latest unchanged."
|
||||
fi
|
||||
fi
|
||||
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
|
||||
echo "Publishing: ${TAGS}"
|
||||
|
||||
- uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # @ v4.2.0
|
||||
- uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # @ v4.2.0
|
||||
|
||||
- uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # @ v4.4.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # @ v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: deploy/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: org.opencontainers.image.source=https://github.com/teng-lin/notebooklm-py
|
||||
@@ -0,0 +1,155 @@
|
||||
# Build the release bundles — the Claude Desktop `.mcpb` and the
|
||||
# Claude-uploadable skill archive (`notebooklm-skill.zip`, for chat / Cowork
|
||||
# Settings -> Capabilities) — and attach them to the GitHub Release.
|
||||
#
|
||||
# Why `release: published` (not `push: tags`)?
|
||||
# The release process (docs/releasing.md) creates the GitHub Release by hand
|
||||
# AFTER the tag is pushed and PyPI has published — there is no release to
|
||||
# attach an asset to at tag-push time, and a workflow that created one would
|
||||
# collide with the maintainer's manual `gh release create`. Reacting to
|
||||
# `release: published` fires exactly when that manual step completes, so
|
||||
# `gh release upload` always has a target.
|
||||
#
|
||||
# Stable AND pre-releases both ship a bundle. The launcher (`run_server.py`)
|
||||
# pins its exact version from the bundled `manifest.json` for a `vX.Y.ZaN`
|
||||
# bundle (an explicit `==` pre-release pin resolves under uv's default resolver
|
||||
# — no `--prerelease` flag, which would loosen transitive deps too), so a
|
||||
# pre-release bundle launches that pinned pre-release instead of silently
|
||||
# resolving the latest *stable* server. A stable bundle stays unpinned.
|
||||
#
|
||||
# Two jobs by design (token isolation, mirrors publish.yml): the `build` job
|
||||
# runs the third-party npm packer (and the pipx-built `skill package` CLI)
|
||||
# with NO release-write token, uploads the bundles as workflow artifacts; the
|
||||
# minimal `attach` job carries `contents: write` and runs only first-party
|
||||
# download + `gh release upload`. Splitting across jobs means nothing the
|
||||
# packer does (e.g. persisting a fake `gh` onto `$GITHUB_PATH`, or
|
||||
# `$GITHUB_ENV`) can reach the write token — a same-job
|
||||
# `env: GITHUB_TOKEN: ""` on the pack step would not stop that.
|
||||
name: Attach release bundles (.mcpb, skill zip)
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
# Serialize per-tag so a rare double-publish can't race two uploads onto the
|
||||
# same asset (mirrors publish.yml / publish-docker.yml). Never cancel a run
|
||||
# mid-upload.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
# Top-level scope is read-only (the default-permissive GITHUB_TOKEN blast
|
||||
# radius is the thing check_workflow_permissions.py guards against). Only the
|
||||
# `attach` job below elevates to `contents: write`.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Pack the release bundles
|
||||
runs-on: ubuntu-latest
|
||||
# Only the canonical repo ships the bundle (forks' manifest URLs point
|
||||
# here anyway); both stable and pre-releases (see the version note above).
|
||||
if: ${{ github.repository == 'teng-lin/notebooklm-py' }}
|
||||
# No contents:write here: this job runs the third-party mcpb packer, so it
|
||||
# stays read-only. Even a compromised packer only sees a read-only token.
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
# Pack the extension from the tagged source, not the default branch,
|
||||
# so the bundle matches exactly what was released.
|
||||
ref: ${{ github.event.release.tag_name }}
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Guard — manifest version matches the release tag and pyproject
|
||||
# Defence in depth: the .mcpb we attach must carry the released
|
||||
# version. The tag (vX.Y.Z), pyproject.toml, and the bundle manifest
|
||||
# must all agree, or we abort before packing a mislabelled bundle.
|
||||
# Derive the version from the release's tag_name (not GITHUB_REF): on a
|
||||
# `release` event GITHUB_REF can reflect the release target branch
|
||||
# rather than the tag, whereas tag_name is unambiguous — and it matches
|
||||
# the checkout/upload steps.
|
||||
env:
|
||||
TAG_NAME: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
TAG_VERSION="${TAG_NAME#v}"
|
||||
TOML_VERSION=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
|
||||
MANIFEST_VERSION=$(python3 -c "import json; print(json.load(open('desktop-extension/manifest.json'))['version'])")
|
||||
echo "tag=$TAG_VERSION pyproject=$TOML_VERSION manifest=$MANIFEST_VERSION"
|
||||
if [ "$TAG_VERSION" != "$TOML_VERSION" ] || [ "$TAG_VERSION" != "$MANIFEST_VERSION" ]; then
|
||||
echo "::error::version mismatch — tag=$TAG_VERSION pyproject=$TOML_VERSION manifest=$MANIFEST_VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build the .mcpb bundle
|
||||
# `pack <source-dir> <output>` zips manifest.json + run_server.py and
|
||||
# validates the manifest against the DXT schema. `--yes` skips the npx
|
||||
# install prompt on a cold runner. The CLI is version-pinned (not
|
||||
# `@latest`) so the released bundle is reproducible from the tag and a
|
||||
# compromised/breaking upstream publish can't drift silently — bump
|
||||
# deliberately, like the SHA-pinned actions.
|
||||
run: npx --yes @anthropic-ai/mcpb@2.1.2 pack desktop-extension notebooklm-mcp.mcpb
|
||||
|
||||
- name: Build the skill archive
|
||||
# `pipx run --spec .` builds the tagged checkout into an isolated venv
|
||||
# (pipx is preinstalled on ubuntu-latest) and runs the `notebooklm`
|
||||
# console script — same read-only job as the mcpb packer, so the
|
||||
# pip-installed dependency tree never sees a release-write token.
|
||||
run: pipx run --spec . notebooklm skill package --output notebooklm-skill.zip
|
||||
|
||||
- name: Upload the bundle as a workflow artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # @ v7
|
||||
with:
|
||||
name: notebooklm-mcp-mcpb
|
||||
path: notebooklm-mcp.mcpb
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload the skill archive as a workflow artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # @ v7
|
||||
with:
|
||||
name: notebooklm-skill-zip
|
||||
path: notebooklm-skill.zip
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
attach:
|
||||
name: Attach the bundles to the release
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
# Minimal write-scoped job: only first-party download + `gh release upload`
|
||||
# run here — no checkout, no node, no third-party code — so the
|
||||
# release-write token has no attacker-controllable co-tenant (mirrors the
|
||||
# split in publish.yml). Skipped automatically when `build` is skipped.
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # @ v8.0.1
|
||||
with:
|
||||
name: notebooklm-mcp-mcpb
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # @ v8.0.1
|
||||
with:
|
||||
name: notebooklm-skill-zip
|
||||
|
||||
- name: Attach the bundles to the release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
# This job has no checkout, so `gh` has no local git remote to infer
|
||||
# the repo from — give it the repo explicitly or the upload fails.
|
||||
GH_REPO: ${{ github.repository }}
|
||||
# Route tag_name through env (not inline `${{ }}` in run:) so a crafted
|
||||
# tag can't inject shell into this contents:write step.
|
||||
TAG_NAME: ${{ github.event.release.tag_name }}
|
||||
# `--clobber` makes re-runs idempotent (replaces an existing asset of
|
||||
# the same name instead of erroring).
|
||||
run: gh release upload "$TAG_NAME" notebooklm-mcp.mcpb notebooklm-skill.zip --clobber
|
||||
@@ -0,0 +1,122 @@
|
||||
name: Publish to PyPI
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
name: Build wheel and run release smoke
|
||||
runs-on: ubuntu-latest
|
||||
# Smoke install pulls `[browser,dev,markdown]` extras (pytest, playwright,
|
||||
# mypy, ruff, markdownify, transitive deps). A compromised dep here is a
|
||||
# supply-chain concern but cannot mint a Trusted Publishing OIDC token,
|
||||
# because this job is not granted `id-token: write`. The publish job
|
||||
# below carries that scope and runs nothing except the trusted PyPA
|
||||
# action. See issue #820 for the threat model.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Validate tag matches version
|
||||
run: |
|
||||
TAG_VERSION=${GITHUB_REF#refs/tags/v}
|
||||
TOML_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
|
||||
if [ "$TAG_VERSION" != "$TOML_VERSION" ]; then
|
||||
echo "Error: Tag version ($TAG_VERSION) doesn't match pyproject.toml ($TOML_VERSION)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Version validated: $TOML_VERSION"
|
||||
|
||||
- name: Install build tools
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install build==1.5.0
|
||||
|
||||
- name: Build package
|
||||
run: |
|
||||
python -m build
|
||||
|
||||
- name: Upload distribution artifacts
|
||||
# Capture pristine wheel + sdist BEFORE any third-party code (smoke
|
||||
# install, playwright, pytest) runs against `dist/`. If we uploaded
|
||||
# after the smoke install, a compromised dev/test dep could mutate
|
||||
# `dist/` post-build, and the publish job would upload tampered bytes
|
||||
# — attested with the project's trusted OIDC identity. Uploading here
|
||||
# snapshots the build output before any attacker-controlled code has
|
||||
# touched the runner. Smoke install/test below still runs against
|
||||
# `dist/` for behavior validation but can no longer influence what
|
||||
# the publish job receives.
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # @ v7
|
||||
with:
|
||||
name: notebooklm-py-dist
|
||||
path: dist/
|
||||
if-no-files-found: error
|
||||
# One-day retention is intentional: publish consumes this artifact
|
||||
# immediately. See issue #829 for the recovery trade-off.
|
||||
retention-days: 1
|
||||
|
||||
- name: Install built wheel + canonical contributor extras in a clean venv
|
||||
# Canonical contributor extras (`[browser,dev,markdown]`, equivalent to
|
||||
# `[all]`) match the install path documented in `docs/installation.md`,
|
||||
# so the release smoke exercises the same surface area users will see.
|
||||
# Plain `[dev]` skipped playwright + markdownify, masking packaging bugs
|
||||
# in `[browser]`/`[markdown]` until users hit them post-release.
|
||||
run: |
|
||||
python -m venv smoke-venv
|
||||
WHEEL=$(ls dist/notebooklm_py-*.whl)
|
||||
smoke-venv/bin/pip install "${WHEEL}[browser,dev,markdown]"
|
||||
smoke-venv/bin/python -c "import notebooklm; print(notebooklm.__version__)"
|
||||
|
||||
- name: Install Playwright browser for smoke
|
||||
# `[browser]` only installs the playwright Python package; the chromium
|
||||
# binary + Linux system libs are needed for the unit-test smoke
|
||||
# (tests/unit/test_windows_compatibility.py drives sync_playwright()).
|
||||
run: |
|
||||
smoke-venv/bin/playwright install chromium
|
||||
smoke-venv/bin/playwright install-deps chromium
|
||||
|
||||
- name: Run unit tests against wheel
|
||||
run: smoke-venv/bin/pytest tests/unit -q --no-cov -x
|
||||
|
||||
publish:
|
||||
name: Publish to PyPI
|
||||
needs: build-and-test
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
# Minimum-scope publish job: only the trusted PyPA action runs here. No
|
||||
# `actions/checkout`, no Python install, no third-party dependencies in
|
||||
# the runner environment — so the OIDC token request that `id-token:
|
||||
# write` enables has no co-tenant code that could exfiltrate it.
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Download distribution artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # @ v8.0.1
|
||||
with:
|
||||
name: notebooklm-py-dist
|
||||
path: dist/
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # @ v1.14.0
|
||||
with:
|
||||
# PEP 740 attestations: pypa/gh-action-pypi-publish generates an
|
||||
# in-toto attestation for the uploaded artifacts and publishes it
|
||||
# via PyPI's Trusted Publishing flow (OIDC, no API token).
|
||||
attestations: true
|
||||
@@ -0,0 +1,424 @@
|
||||
name: RPC Health Check
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Main branch: 7 AM UTC daily (1 hour after main nightly E2E tests)
|
||||
- cron: '0 7 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
custom_branch:
|
||||
description: 'Override branch to test (leave empty to test the branch you triggered from)'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Determine which branch to test
|
||||
resolve-branch:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'teng-lin/notebooklm-py'
|
||||
outputs:
|
||||
branch: ${{ steps.resolve.outputs.branch }}
|
||||
is_standard: ${{ steps.resolve.outputs.is_standard }}
|
||||
steps:
|
||||
- name: Resolve target branch
|
||||
id: resolve
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
CUSTOM_BRANCH: ${{ inputs.custom_branch }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$EVENT_NAME" = "schedule" ]; then
|
||||
# Scheduled runs test main only. Release branches are manual.
|
||||
TARGET="main"
|
||||
else
|
||||
if [ -n "$CUSTOM_BRANCH" ]; then
|
||||
TARGET="$CUSTOM_BRANCH"
|
||||
else
|
||||
TARGET="$REF_NAME"
|
||||
fi
|
||||
fi
|
||||
echo "branch=$TARGET" >> "$GITHUB_OUTPUT"
|
||||
case "$TARGET" in
|
||||
main|release/*)
|
||||
echo "is_standard=true" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
*)
|
||||
echo "is_standard=false" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
esac
|
||||
echo "Resolved branch: $TARGET"
|
||||
|
||||
health-check:
|
||||
name: RPC Health Check (${{ needs.resolve-branch.outputs.branch }})
|
||||
needs: resolve-branch
|
||||
if: needs.resolve-branch.outputs.is_standard == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
concurrency:
|
||||
group: rpc-health-${{ needs.resolve-branch.outputs.branch }}
|
||||
cancel-in-progress: true
|
||||
# Secret-bearing job. ``NOTEBOOKLM_AUTH_JSON`` and friends live only in
|
||||
# the ``protected-readonly`` GitHub Environment (issue #1009: relying on
|
||||
# repo-level fallback for the scheduled-cron branch broke when those
|
||||
# secrets were migrated env-only). Binding the env unconditionally is
|
||||
# what gives every trigger — scheduled cron, workflow_dispatch from a
|
||||
# maintainer, etc. — access to the same secret values. Workflow_dispatch
|
||||
# is still hard-gated by ``needs.resolve-branch.outputs.is_standard``
|
||||
# above so a feature-branch dispatch never reaches this job at all.
|
||||
# Add a ``required reviewers`` rule on the environment if you want to
|
||||
# block workflow_dispatch behind manual approval; scheduled runs would
|
||||
# then queue too, so today this env carries no protection rules.
|
||||
# See docs/development.md → "Workflow secret gates".
|
||||
environment: protected-readonly
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.resolve-branch.outputs.branch }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # @ v7.0.0
|
||||
|
||||
- name: Install dependencies
|
||||
# `uv sync --frozen` reproduces the lockfile-pinned dep tree. The RPC
|
||||
# health script (`scripts/check_rpc_health.py`) only needs the core
|
||||
# runtime deps — no `[browser]`/`[dev]`/`[markdown]` extras required.
|
||||
# Retried to ride out transient registry/network blips: a real lockfile
|
||||
# problem fails all 3 attempts identically, so this only absorbs flakes
|
||||
# — it never masks a genuine resolution error.
|
||||
shell: bash
|
||||
run: |
|
||||
attempt=1; max=3
|
||||
until uv sync --frozen; do
|
||||
if [ "$attempt" -ge "$max" ]; then
|
||||
echo "::error::uv sync failed after $max attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::uv sync attempt $attempt failed; retrying in $((attempt * 15))s" >&2
|
||||
sleep $((attempt * 15))
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
# Fail-fast preflight. The script itself rejects empty
|
||||
# NOTEBOOKLM_AUTH_JSON downstream, but checking at the workflow layer
|
||||
# surfaces ``::error::`` annotations linked to the secret-config
|
||||
# misconfig before the ``continue-on-error`` step swallows the exit
|
||||
# code (issue #1009).
|
||||
- name: Verify auth secret is present
|
||||
env:
|
||||
NOTEBOOKLM_AUTH_JSON: ${{ secrets.NOTEBOOKLM_AUTH_JSON }}
|
||||
NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID: ${{ secrets.NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID }}
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -z "${NOTEBOOKLM_AUTH_JSON:-}" ]; then
|
||||
echo "::error::NOTEBOOKLM_AUTH_JSON resolved to empty. Env binding or secret config is broken — see docs/development.md → Workflow secret gates."
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID:-}" ]; then
|
||||
echo "::error::NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID resolved to empty."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run RPC Health Check
|
||||
id: health
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
env:
|
||||
NOTEBOOKLM_AUTH_JSON: ${{ secrets.NOTEBOOKLM_AUTH_JSON }}
|
||||
NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID: ${{ secrets.NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID }}
|
||||
NOTEBOOKLM_GENERATION_NOTEBOOK_ID: ${{ secrets.NOTEBOOKLM_GENERATION_NOTEBOOK_ID }}
|
||||
TARGET_BRANCH: ${{ needs.resolve-branch.outputs.branch }}
|
||||
run: |
|
||||
echo "Testing branch: $TARGET_BRANCH"
|
||||
set +e
|
||||
uv run python scripts/check_rpc_health.py --full 2>&1 | tee health-report.txt
|
||||
exit_code=${PIPESTATUS[0]}
|
||||
echo "exit_code=${exit_code}" >> "$GITHUB_OUTPUT"
|
||||
exit "$exit_code"
|
||||
|
||||
- name: Scrub secrets from health-report.txt
|
||||
# Defence-in-depth: the script itself avoids printing credential-shaped
|
||||
# data, but raw response bodies and exception tracebacks can still
|
||||
# surface session/CSRF/cookie material on regressions. We re-run the
|
||||
# report through ``notebooklm._logging.scrub_secrets`` before any
|
||||
# downstream consumer reads it (Add Summary, Create Issue on …,
|
||||
# Upload Report). Idempotent — safe to re-apply on subsequent runs.
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
if [ ! -f health-report.txt ]; then
|
||||
echo "::warning::health-report.txt not produced; skipping scrub."
|
||||
exit 0
|
||||
fi
|
||||
uv run python - <<'PY'
|
||||
from pathlib import Path
|
||||
from notebooklm._logging import scrub_secrets
|
||||
|
||||
report = Path("health-report.txt")
|
||||
original = report.read_text(encoding="utf-8", errors="replace")
|
||||
scrubbed = scrub_secrets(original)
|
||||
if scrubbed != original:
|
||||
report.write_text(scrubbed, encoding="utf-8")
|
||||
# ``abs`` because replacement tokens (``***``) may be longer
|
||||
# OR shorter than the redacted secret depending on the
|
||||
# pattern, so the raw delta can be negative; the magnitude
|
||||
# is what's interesting for sanity-checking the scrub.
|
||||
print(
|
||||
f"Scrubbed health-report.txt "
|
||||
f"(delta {abs(len(original) - len(scrubbed))} chars)."
|
||||
)
|
||||
else:
|
||||
print("No secret-shaped substrings detected in health-report.txt.")
|
||||
PY
|
||||
|
||||
- name: Add Summary
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
echo "## RPC Health Check Results" >> "$GITHUB_STEP_SUMMARY"
|
||||
grep -A 10 "^SUMMARY$" health-report.txt >> "$GITHUB_STEP_SUMMARY" || echo "No summary found" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Bundle-drift monitor (capture_rpc_registry.py). NOT a PR gate — it depends
|
||||
# on Google's live external JS bundle, which can rotate at any time — so it
|
||||
# rides the nightly/issue-filing track here. ``--check`` gates id rotation
|
||||
# (ABSENT), ``--check-enums`` gates studio enum CHANGED/STALE (a selectable
|
||||
# format renumbered/retired — the #1597 class). NEW/UNPARSED, quota codes and
|
||||
# proto assertions print for visibility but never fail. ``continue-on-error``
|
||||
# so a drift hit files an issue rather than red-failing the canary outright.
|
||||
- name: Run bundle drift monitor
|
||||
id: bundle
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
env:
|
||||
NOTEBOOKLM_AUTH_JSON: ${{ secrets.NOTEBOOKLM_AUTH_JSON }}
|
||||
run: |
|
||||
set +e
|
||||
uv run python scripts/capture_rpc_registry.py --check --check-enums 2>&1 \
|
||||
| tee bundle-drift-report.txt
|
||||
exit_code=${PIPESTATUS[0]}
|
||||
echo "exit_code=${exit_code}" >> "$GITHUB_OUTPUT"
|
||||
exit "$exit_code"
|
||||
|
||||
- name: Scrub secrets from bundle-drift-report.txt
|
||||
# The bundle is public CDN content, but the discovery homepage read is
|
||||
# authenticated and a transport error could echo a request URL; scrub
|
||||
# before any downstream consumer reads the file (same posture as the
|
||||
# health-report scrub above). Idempotent.
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
if [ ! -f bundle-drift-report.txt ]; then
|
||||
echo "::warning::bundle-drift-report.txt not produced; skipping scrub."
|
||||
exit 0
|
||||
fi
|
||||
uv run python - <<'PY'
|
||||
from pathlib import Path
|
||||
from notebooklm._logging import scrub_secrets
|
||||
|
||||
report = Path("bundle-drift-report.txt")
|
||||
original = report.read_text(encoding="utf-8", errors="replace")
|
||||
scrubbed = scrub_secrets(original)
|
||||
if scrubbed != original:
|
||||
report.write_text(scrubbed, encoding="utf-8")
|
||||
print(f"Scrubbed bundle-drift-report.txt (delta {abs(len(original) - len(scrubbed))} chars).")
|
||||
else:
|
||||
print("No secret-shaped substrings detected in bundle-drift-report.txt.")
|
||||
PY
|
||||
|
||||
- name: Check for existing bundle drift issue
|
||||
if: steps.bundle.outputs.exit_code != '0'
|
||||
id: dup_bundle
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if ! count=$(gh issue list --repo "$GITHUB_REPOSITORY" \
|
||||
--state open --label automated --label rpc-breakage \
|
||||
--search 'in:title "Studio enum / RPC drift detected"' \
|
||||
--json number --jq 'length'); then
|
||||
echo "::warning::Bundle drift dedup probe failed; allowing issue creation"
|
||||
count=0
|
||||
fi
|
||||
echo "open=${count}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create Issue on bundle drift
|
||||
if: |
|
||||
steps.bundle.outputs.exit_code != '0'
|
||||
&& steps.dup_bundle.outputs.open == '0'
|
||||
uses: peter-evans/create-issue-from-file@fca9117c27cdc29c6c4db3b86c48e4115a786710 # @ v6.0.0
|
||||
with:
|
||||
title: "Studio enum / RPC drift detected"
|
||||
content-filepath: bundle-drift-report.txt
|
||||
labels: rpc-breakage, automated
|
||||
|
||||
- name: Upload bundle drift report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: rpc-bundle-drift-report
|
||||
path: bundle-drift-report.txt
|
||||
retention-days: 30
|
||||
|
||||
# Each issue-creating step is paired with a dedup probe that counts
|
||||
# open issues sharing the same title + automated label. Without this,
|
||||
# back-to-back failing runs (manual reruns, cron + workflow_dispatch)
|
||||
# farm one fresh issue per run instead of letting the open one
|
||||
# accumulate context.
|
||||
- name: Check for existing RPC Mismatch issue
|
||||
if: steps.health.outputs.exit_code == '1'
|
||||
id: dup_mismatch
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if ! count=$(gh issue list --repo "$GITHUB_REPOSITORY" \
|
||||
--state open --label automated --label rpc-breakage \
|
||||
--search 'in:title "RPC ID Mismatch Detected"' \
|
||||
--json number --jq 'length'); then
|
||||
echo "::warning::RPC mismatch dedup probe failed; allowing issue creation"
|
||||
count=0
|
||||
fi
|
||||
echo "open=${count}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create Issue on RPC Mismatch
|
||||
if: |
|
||||
steps.health.outputs.exit_code == '1'
|
||||
&& steps.dup_mismatch.outputs.open == '0'
|
||||
uses: peter-evans/create-issue-from-file@fca9117c27cdc29c6c4db3b86c48e4115a786710 # @ v6.0.0
|
||||
with:
|
||||
title: "RPC ID Mismatch Detected"
|
||||
content-filepath: health-report.txt
|
||||
labels: bug, rpc-breakage, automated
|
||||
|
||||
- name: Check for existing Auth Failure issue
|
||||
if: steps.health.outputs.exit_code == '2'
|
||||
id: dup_auth
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if ! count=$(gh issue list --repo "$GITHUB_REPOSITORY" \
|
||||
--state open --label automated \
|
||||
--search 'in:title "RPC Health Check: Authentication Failure"' \
|
||||
--json number --jq 'length'); then
|
||||
echo "::warning::Auth failure dedup probe failed; allowing issue creation"
|
||||
count=0
|
||||
fi
|
||||
echo "open=${count}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create Issue on Auth Failure
|
||||
if: |
|
||||
steps.health.outputs.exit_code == '2'
|
||||
&& steps.dup_auth.outputs.open == '0'
|
||||
uses: peter-evans/create-issue-from-file@fca9117c27cdc29c6c4db3b86c48e4115a786710 # @ v6.0.0
|
||||
with:
|
||||
title: "RPC Health Check: Authentication Failure"
|
||||
content-filepath: health-report.txt
|
||||
labels: bug, automated
|
||||
|
||||
- name: Check for existing non-transient ERROR issue
|
||||
if: steps.health.outputs.exit_code == '3'
|
||||
id: dup_error
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if ! count=$(gh issue list --repo "$GITHUB_REPOSITORY" \
|
||||
--state open --label automated --label rpc-error \
|
||||
--search 'in:title "RPC Health Check: Non-transient ERROR detected"' \
|
||||
--json number --jq 'length'); then
|
||||
echo "::warning::Non-transient ERROR dedup probe failed; allowing issue creation"
|
||||
count=0
|
||||
fi
|
||||
echo "open=${count}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Extract failing methods for ERROR issue
|
||||
if: |
|
||||
steps.health.outputs.exit_code == '3'
|
||||
&& steps.dup_error.outputs.open == '0'
|
||||
id: error_details
|
||||
shell: bash
|
||||
run: |
|
||||
# Pull the affected-methods list out of the RESULT line emitted by the script.
|
||||
affected=$(grep -m1 "non-transient ERROR detected in methods:" health-report.txt \
|
||||
| sed -E 's/.*methods: //' || true)
|
||||
if [ -z "$affected" ]; then
|
||||
affected="(see report)"
|
||||
fi
|
||||
{
|
||||
echo "## Non-transient ERROR detected"
|
||||
echo ""
|
||||
echo "**Affected methods:** ${affected}"
|
||||
echo ""
|
||||
echo "**Commit:** ${GITHUB_SHA}"
|
||||
echo ""
|
||||
echo "Full report attached below (rate-limit / \`RESOURCE_EXHAUSTED\` errors"
|
||||
echo "are filtered out — anything listed here is a real failure that needs"
|
||||
echo "investigation: timeouts, parse failures, unexpected HTTP errors)."
|
||||
echo ""
|
||||
echo "---"
|
||||
echo ""
|
||||
cat health-report.txt
|
||||
} > error-issue-body.md
|
||||
|
||||
- name: Create Issue on Non-transient ERROR
|
||||
if: |
|
||||
steps.health.outputs.exit_code == '3'
|
||||
&& steps.dup_error.outputs.open == '0'
|
||||
uses: peter-evans/create-issue-from-file@fca9117c27cdc29c6c4db3b86c48e4115a786710 # @ v6.0.0
|
||||
with:
|
||||
title: "RPC Health Check: Non-transient ERROR detected"
|
||||
content-filepath: error-issue-body.md
|
||||
labels: rpc-error, bug, automated
|
||||
|
||||
# Exit code 4 = the ``sqTeoe`` cohort tripwire flipped (GetArtifactCustomization
|
||||
# Choices returned non-null). Our account migrated to the new studio-customization
|
||||
# surface; the VideoStyle / format codes in rpc/types.py must be re-captured.
|
||||
# GATED-null is the expected steady state and never reaches this branch.
|
||||
- name: Check for existing cohort-flip issue
|
||||
if: steps.health.outputs.exit_code == '4'
|
||||
id: dup_cohort
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if ! count=$(gh issue list --repo "$GITHUB_REPOSITORY" \
|
||||
--state open --label automated --label rpc-breakage \
|
||||
--search 'in:title "Studio customization cohort flipped"' \
|
||||
--json number --jq 'length'); then
|
||||
echo "::warning::Cohort-flip dedup probe failed; allowing issue creation"
|
||||
count=0
|
||||
fi
|
||||
echo "open=${count}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create Issue on cohort flip
|
||||
if: |
|
||||
steps.health.outputs.exit_code == '4'
|
||||
&& steps.dup_cohort.outputs.open == '0'
|
||||
uses: peter-evans/create-issue-from-file@fca9117c27cdc29c6c4db3b86c48e4115a786710 # @ v6.0.0
|
||||
with:
|
||||
title: "Studio customization cohort flipped — re-capture VideoStyle codes"
|
||||
content-filepath: health-report.txt
|
||||
labels: rpc-breakage, automated
|
||||
|
||||
- name: Upload Report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: rpc-health-report
|
||||
path: health-report.txt
|
||||
retention-days: 30
|
||||
|
||||
- name: Fail if health check failed
|
||||
if: steps.health.outcome == 'failure'
|
||||
run: |
|
||||
echo "RPC Health Check failed (exit code: ${{ steps.health.outputs.exit_code }}). See report above."
|
||||
exit 1
|
||||
@@ -0,0 +1,316 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
custom_branch:
|
||||
description: 'Branch/ref to test (leave empty to use the triggering ref). Use this to run the full gate against a release branch that conflicts with main and so cannot run via pull_request.'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
concurrency:
|
||||
# Include the dispatch input so a manual run against a release branch gets its
|
||||
# own group instead of sharing (and cancelling) the push/PR run on the
|
||||
# triggering ref. Empty for push/pull_request, so their behavior is unchanged.
|
||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.custom_branch }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
name: Code Quality
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
# Honor the workflow_dispatch custom_branch input; falls back to the
|
||||
# triggering ref for push/pull_request (input is empty there).
|
||||
ref: ${{ inputs.custom_branch || github.ref }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
# Retry to ride out transient registry/network blips during dep
|
||||
# resolution: a single PyPI/GitHub hiccup was hard-failing the leg.
|
||||
# `uv sync` has no internal retry across the full resolve+download.
|
||||
# A real lockfile problem fails all 3 attempts identically, so this
|
||||
# only absorbs flakes — it never masks a genuine resolution error.
|
||||
attempt=1; max=3
|
||||
until uv sync --frozen --extra browser --extra dev --extra markdown; do
|
||||
if [ "$attempt" -ge "$max" ]; then
|
||||
echo "::error::uv sync failed after $max attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::uv sync attempt $attempt failed; retrying in $((attempt * 15))s" >&2
|
||||
sleep $((attempt * 15))
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
- name: Assert core imports without optional adapter extras
|
||||
# This job uses the canonical lean install (browser+dev+markdown, no
|
||||
# `mcp`/`server`). Since the test matrix now installs those extras, this
|
||||
# is the remaining per-PR guard that the core package still imports
|
||||
# without fastmcp/fastapi present — a stray top-level `import fastmcp` in
|
||||
# non-adapter code would ImportError here, catching the regression before
|
||||
# it reaches the release-time smoke test in publish.yml.
|
||||
#
|
||||
# `notebooklm_cli` (not `cli.grouped`) is imported because it is the
|
||||
# console-script entrypoint that builds the full command tree via
|
||||
# `cli.add_command(...)`, including `cli/mcp_cmd.py` — the most likely
|
||||
# place a stray `import fastmcp` would land. `cli.grouped` only defines
|
||||
# the Click Group subclass and traverses none of the command modules.
|
||||
# `client` + `artifacts` cover the primary public Python-API surface
|
||||
# (`from notebooklm import NotebookLMClient`). Since the lean install has
|
||||
# neither fastmcp nor fastapi, a stray top-level import of either from any
|
||||
# core/`_app` module reachable here fails the step. The `notebooklm-server`
|
||||
# adapter is intentionally NOT imported: it legitimately requires fastapi
|
||||
# (so it can't load on the lean install) and is exercised by the `test`
|
||||
# matrix instead — `server` is not wired into the CLI command tree.
|
||||
run: uv run python -c "import notebooklm, notebooklm.notebooklm_cli, notebooklm.client, notebooklm.artifacts"
|
||||
|
||||
- name: Run pre-commit checks
|
||||
run: uv run pre-commit run --all-files
|
||||
|
||||
- name: Run type checking
|
||||
run: uv run mypy src/notebooklm --ignore-missing-imports
|
||||
|
||||
- name: Assert workflow permissions are scoped
|
||||
run: uv run python scripts/check_workflow_permissions.py
|
||||
|
||||
- name: Assert workflow secret-bearing jobs are gated
|
||||
# Companion to the permissions check: prevents a new
|
||||
# ``${{ secrets.NAME }}`` reference from landing without picking up
|
||||
# a job-level ``environment:`` declaration or a step-level
|
||||
# ``is_standard`` guard. See docs/development.md →
|
||||
# "Workflow secret gates".
|
||||
run: uv run python scripts/check_workflow_secret_gates.py
|
||||
|
||||
- name: Assert third-party actions in privileged workflows are SHA-pinned
|
||||
# Supply-chain gate: every third-party action (non ``actions/*``) in
|
||||
# publish / testpypi-publish / claude / rpc-health / nightly /
|
||||
# verify-package must use a 40-char commit SHA, not a floating
|
||||
# ``@v1`` / ``@release/v1`` / branch ref. Dependabot bumps the
|
||||
# SHAs weekly via the grouped ``actions`` updates in
|
||||
# ``.github/dependabot.yml``.
|
||||
run: uv run python scripts/check_action_pinning.py
|
||||
|
||||
- name: Assert no deprecation targets the shipping version
|
||||
# Release gate: a DeprecationWarning must never name the version in
|
||||
# pyproject.toml as its removal target. Lapsed shims are allowlisted
|
||||
# inside the script with a tracking issue.
|
||||
run: uv run python scripts/check_deprecation_targets.py
|
||||
|
||||
- name: Assert coverage thresholds match
|
||||
run: uv run python scripts/check_coverage_thresholds.py
|
||||
|
||||
- name: Assert CI install matches CONTRIBUTING.md
|
||||
run: uv run python scripts/check_ci_install_parity.py
|
||||
|
||||
- name: Assert repo-structure map (docs/architecture.md) is fresh
|
||||
run: uv run python scripts/check_claude_md_freshness.py
|
||||
|
||||
- name: Assert doc module references are fresh
|
||||
# Companion to the CLAUDE.md freshness gate: guards the rest of the docs
|
||||
# against stale module paths. Every relative link into src/notebooklm/
|
||||
# must resolve, and every inline module ref in the live docs must point at
|
||||
# a real module (rare historical mentions are allowlisted, shrink-only).
|
||||
run: uv run python scripts/check_docs_module_refs.py
|
||||
|
||||
- name: Audit public API compatibility
|
||||
# ``--check-stale`` also fails when an allowlist entry matches no break
|
||||
# against the baseline (it is already in the baseline). This forces the
|
||||
# allowlist to be pruned at each release boundary instead of silently
|
||||
# accumulating cruft. See docs/releasing.md → prune-allowlist-at-release.
|
||||
run: uv run python scripts/audit_public_api_compat.py --check-stale
|
||||
|
||||
- name: Assert per-method RPC coverage
|
||||
# Static check: each RPCMethod member must have at least one test
|
||||
# reference AND at least one cassette body containing its RPC id.
|
||||
# Pre-existing gaps are grandfathered via PREEXISTING_GAPS inside the
|
||||
# script — that set is a one-way ratchet and must not grow.
|
||||
run: uv run python tests/scripts/check_method_coverage.py
|
||||
|
||||
- name: Verify e2e test fixtures
|
||||
run: uv run pytest tests/e2e --collect-only -q
|
||||
|
||||
test:
|
||||
name: Test (${{ matrix.os }}, Python ${{ matrix.python-version }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
needs: quality
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
env:
|
||||
# Pin Playwright's browser install dir to a single workspace-relative
|
||||
# path on every OS. The previous dual-path cache stanza (``~/.cache``
|
||||
# + ``~/AppData/Local``) intermittently failed the cache restore on
|
||||
# Windows with ``actions/cache@v5`` — the missing Linux path tripped
|
||||
# the post-restore validation on a cache hit. Pinning the path here
|
||||
# lets the cache stanza below carry exactly one entry on every OS.
|
||||
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
# Honor the workflow_dispatch custom_branch input; falls back to the
|
||||
# triggering ref for push/pull_request (input is empty there).
|
||||
ref: ${{ inputs.custom_branch || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
# Retry to ride out transient registry/network blips during dep
|
||||
# resolution: a single PyPI/GitHub hiccup on one matrix entry was
|
||||
# hard-failing the whole leg while the other 14 passed. `uv sync`
|
||||
# has no internal retry across the full resolve+download. A real
|
||||
# lockfile problem fails all 3 attempts identically, so this only
|
||||
# absorbs flakes — it never masks a genuine resolution error.
|
||||
#
|
||||
# `mcp` + `server` + `impersonate` are installed here (on top of the
|
||||
# canonical browser+dev+markdown set) so the MCP, server, and curl_cffi
|
||||
# suites (`tests/unit/mcp`, `tests/integration/mcp_vcr`, `tests/server`,
|
||||
# `tests/unit/test_curl_cffi_transport_poc.py`) run in the main matrix
|
||||
# instead of being importorskip'd — that gives
|
||||
# `src/notebooklm/{mcp,server}/**` and `_curl_cffi_transport.py` real
|
||||
# coverage in coverage.json (no 0% blind spot) across the full
|
||||
# 3.10–3.14 × 3-OS matrix, which also proves curl_cffi's native wheels
|
||||
# resolve on every OS/Python. `cookies` is deliberately excluded:
|
||||
# rookiepy fails on Python 3.13/3.14 (see check_ci_install_parity.py).
|
||||
#
|
||||
# Tradeoff: the deleted MCP/server jobs each enforced a *scoped*
|
||||
# `--cov=src/notebooklm/{mcp,server} --cov-fail-under=90`. mcp/server (and
|
||||
# the curl_cffi adapter) are now covered only by the global aggregate gate
|
||||
# below — no per-subpackage floor backfills them, so a subpackage could
|
||||
# regress within the aggregate's headroom. Add entries to
|
||||
# `[tool.notebooklm.per_file_coverage_floors]` if that backstop is
|
||||
# wanted back.
|
||||
attempt=1; max=3
|
||||
until uv sync --frozen --extra browser --extra dev --extra markdown --extra mcp --extra server --extra impersonate; do
|
||||
if [ "$attempt" -ge "$max" ]; then
|
||||
echo "::error::uv sync failed after $max attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::uv sync attempt $attempt failed; retrying in $((attempt * 15))s" >&2
|
||||
sleep $((attempt * 15))
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
- name: Assert curl_cffi imports (native wheel; no silent skip)
|
||||
# The matrix installs --extra impersonate, so curl_cffi MUST import on every
|
||||
# OS/Python. It's a native wheel: if it installs but fails to import, the
|
||||
# importorskip'd curl_cffi suites would silently skip in the coverage run
|
||||
# rather than fail. This makes a broken native import a hard error.
|
||||
shell: bash
|
||||
run: uv run python -c "import curl_cffi"
|
||||
|
||||
- name: Get Playwright version
|
||||
id: playwright-version
|
||||
shell: bash
|
||||
run: |
|
||||
echo "version=$(uv pip show playwright | grep '^Version:' | cut -d' ' -f2)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v6
|
||||
# Tolerate transient cache-backend failures: a miss already falls
|
||||
# through to the install step below, and a backend hiccup should be
|
||||
# treated the same. Without this, a 29s backend flake on a single
|
||||
# matrix entry hard-fails the job and skips the entire test run.
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: ${{ github.workspace }}/.playwright-browsers
|
||||
# ``-ws`` suffix invalidates archives keyed to the prior dual-path
|
||||
# stanza so first restores after this change land on the new path.
|
||||
key: playwright-${{ matrix.os }}-${{ steps.playwright-version.outputs.version }}-ws
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: uv run playwright install chromium
|
||||
|
||||
- name: Install Playwright system dependencies (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
if ! timeout 180s uv run playwright install-deps chromium; then
|
||||
echo "::warning::Playwright system dependency installation timed out or failed."
|
||||
echo "::warning::Continuing so the non-browser test matrix can run."
|
||||
fi
|
||||
|
||||
- name: Assert cassettes are sanitized
|
||||
# Runs on all matrix entries (ubuntu, macos, windows × 5 Python versions).
|
||||
# An earlier bash-only gate was replaced by a portable Python invocation
|
||||
# so the check runs uniformly across the cross-platform test matrix.
|
||||
#
|
||||
# ``--strict`` flips the repair-allowlist from "best-effort suppressor"
|
||||
# to "must be empty" — the phase-2 cassette cleanup is done, so any
|
||||
# entry sneaking back in is a CI failure (P1-5).
|
||||
# ``--recursive`` extends the scan from ``tests/cassettes/*.yaml`` to
|
||||
# ``tests/cassettes/**/*.yaml`` so a recorder can't smuggle a leak
|
||||
# into a nested folder like ``tests/cassettes/gzip_coverage/`` (P1-5).
|
||||
run: uv run python tests/scripts/check_cassettes_clean.py --strict --recursive
|
||||
|
||||
- name: Check fixtures for credential leaks
|
||||
# The cassette guard above is scoped to ``tests/cassettes/*.yaml``. Golden
|
||||
# RPC fixtures live under ``tests/fixtures/`` as ``.json`` (and one
|
||||
# captured ``.html`` page), which embed the same WIZ_global_data shapes —
|
||||
# a Google API key smuggled into a golden HTML fixture would otherwise
|
||||
# slip past CI entirely (the GET_INTERACTIVE_HTML.json class). ``--secrets
|
||||
# -only`` scans .json/.html/.yaml for high-severity credential shapes
|
||||
# (Google auth tokens + API keys) without tripping on the intentional
|
||||
# placeholder content (``"Scrubbed ..."`` names, test emails) that fills
|
||||
# those fixtures.
|
||||
run: uv run python tests/scripts/check_cassettes_clean.py --secrets-only --recursive tests/fixtures
|
||||
|
||||
- name: Run tests with coverage
|
||||
# ``-n auto --dist loadgroup`` parallelizes the ~11k-test suite across the
|
||||
# runner's cores (pytest-xdist is already a dev dep). ``loadgroup`` honors
|
||||
# ``@pytest.mark.xdist_group`` — the isolation escape hatch for tests touching
|
||||
# process-global state (tests/integration/concurrency/conftest.py); unmarked
|
||||
# tests distribute like ``load``. The suite is parallel-safe via per-test
|
||||
# ``NOTEBOOKLM_HOME`` tmp isolation, and pytest-cov merges per-worker coverage
|
||||
# automatically, so the coverage.json + per-file floors below are unaffected.
|
||||
#
|
||||
# Emit coverage.json so the per-file floor check below can read it without
|
||||
# re-running the suite. ``term-missing`` is kept so build logs still show the
|
||||
# missing-lines summary.
|
||||
run: uv run pytest -n auto --dist loadgroup --cov=src/notebooklm --cov-report=term-missing --cov-report=json:coverage.json --cov-fail-under=90
|
||||
|
||||
- name: Assert per-file coverage floors
|
||||
# Linux-only because ``coverage.json`` on Windows records backslash
|
||||
# paths (e.g. ``src\notebooklm\cli\doctor.py``) that won't match the
|
||||
# forward-slash keys in ``[tool.notebooklm.per_file_coverage_floors]``
|
||||
# in pyproject.toml. macOS uses forward slashes and would also work,
|
||||
# but a single OS is enough — the floors are platform-independent.
|
||||
if: runner.os == 'Linux'
|
||||
run: uv run python scripts/check_coverage_thresholds.py --coverage-json coverage.json
|
||||
@@ -0,0 +1,129 @@
|
||||
name: Publish to TestPyPI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
name: Build wheel and run release smoke
|
||||
runs-on: ubuntu-latest
|
||||
# Smoke install pulls `[browser,dev,markdown]` extras (pytest, playwright,
|
||||
# mypy, ruff, markdownify, transitive deps). A compromised dep here is a
|
||||
# supply-chain concern but cannot mint a Trusted Publishing OIDC token,
|
||||
# because this job is not granted `id-token: write`. The publish job
|
||||
# below carries that scope and runs nothing except the trusted PyPA
|
||||
# action. See issue #820 for the threat model.
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Get version from pyproject.toml
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Publishing version: $VERSION"
|
||||
|
||||
- name: Install build tools
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install build==1.5.0
|
||||
|
||||
- name: Build package
|
||||
run: python -m build
|
||||
|
||||
- name: Upload distribution artifacts
|
||||
# Capture pristine wheel + sdist BEFORE any third-party code (smoke
|
||||
# install, playwright, pytest) runs against `dist/`. If we uploaded
|
||||
# after the smoke install, a compromised dev/test dep could mutate
|
||||
# `dist/` post-build, and the publish job would upload tampered bytes
|
||||
# — attested with the project's trusted OIDC identity. Uploading here
|
||||
# snapshots the build output before any attacker-controlled code has
|
||||
# touched the runner. Smoke install/test below still runs against
|
||||
# `dist/` for behavior validation but can no longer influence what
|
||||
# the publish job receives.
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # @ v7
|
||||
with:
|
||||
name: notebooklm-py-dist
|
||||
path: dist/
|
||||
if-no-files-found: error
|
||||
# One-day retention is intentional: publish consumes this artifact
|
||||
# immediately. See issue #829 for the recovery trade-off.
|
||||
retention-days: 1
|
||||
|
||||
- name: Install built wheel + canonical contributor extras in a clean venv
|
||||
# Canonical contributor extras (`[browser,dev,markdown]`, equivalent to
|
||||
# `[all]`) match the install path documented in `docs/installation.md`,
|
||||
# so the release smoke exercises the same surface area users will see.
|
||||
# Plain `[dev]` skipped playwright + markdownify, masking packaging bugs
|
||||
# in `[browser]`/`[markdown]` until users hit them post-release.
|
||||
run: |
|
||||
python -m venv smoke-venv
|
||||
WHEEL=$(ls dist/notebooklm_py-*.whl)
|
||||
smoke-venv/bin/pip install "${WHEEL}[browser,dev,markdown]"
|
||||
smoke-venv/bin/python -c "import notebooklm; print(notebooklm.__version__)"
|
||||
|
||||
- name: Install Playwright browser for smoke
|
||||
# `[browser]` only installs the playwright Python package; the chromium
|
||||
# binary + Linux system libs are needed for the unit-test smoke
|
||||
# (tests/unit/test_windows_compatibility.py drives sync_playwright()).
|
||||
run: |
|
||||
smoke-venv/bin/playwright install chromium
|
||||
smoke-venv/bin/playwright install-deps chromium
|
||||
|
||||
- name: Run unit tests against wheel
|
||||
run: smoke-venv/bin/pytest tests/unit -q --no-cov -x
|
||||
|
||||
publish:
|
||||
name: Publish to TestPyPI
|
||||
needs: build-and-test
|
||||
runs-on: ubuntu-latest
|
||||
environment: testpypi
|
||||
# Minimum-scope publish job: only the trusted PyPA action runs here. No
|
||||
# `actions/checkout`, no Python install, no third-party dependencies in
|
||||
# the runner environment — so the OIDC token request that `id-token:
|
||||
# write` enables has no co-tenant code that could exfiltrate it.
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Download distribution artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # @ v8.0.1
|
||||
with:
|
||||
name: notebooklm-py-dist
|
||||
path: dist/
|
||||
|
||||
- name: Upload to TestPyPI
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # @ v1.14.0
|
||||
with:
|
||||
repository-url: https://test.pypi.org/legacy/
|
||||
# PEP 740 attestations: pypa/gh-action-pypi-publish generates an
|
||||
# in-toto attestation for the uploaded artifacts and publishes it
|
||||
# via PyPI's Trusted Publishing flow (OIDC, no API token).
|
||||
attestations: true
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "## Published to TestPyPI" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Version:** ${{ needs.build-and-test.outputs.version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Package:** https://test.pypi.org/project/notebooklm-py/${{ needs.build-and-test.outputs.version }}/" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Next Step:** Run the **Verify Package** workflow with source=testpypi" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -0,0 +1,158 @@
|
||||
name: Verify Generated Artifacts
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run at 8 AM UTC daily (2 hours after nightly e2e tests)
|
||||
- cron: '0 8 * * *'
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Verify Artifacts
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'teng-lin/notebooklm-py'
|
||||
# Secret-bearing job. ``NOTEBOOKLM_AUTH_JSON`` and friends live only in
|
||||
# the ``protected-readonly`` GitHub Environment (issue #1009), so the
|
||||
# env binding is unconditional — every trigger sees the same secret
|
||||
# values. Add a ``required reviewers`` rule on the environment if you
|
||||
# want to block workflow_dispatch behind manual approval; scheduled
|
||||
# runs would queue too, so the env carries no protection rules today.
|
||||
# See docs/development.md → "Workflow secret gates".
|
||||
environment: protected-readonly
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # @ v7.0.0
|
||||
|
||||
- name: Install dependencies
|
||||
# `uv sync --frozen` reproduces the lockfile-pinned dep tree. The
|
||||
# inline verification script below only touches the public client API
|
||||
# (no playwright / pytest / markdownify), so no extras are needed.
|
||||
run: uv sync --frozen
|
||||
|
||||
# Fail-fast preflight. Without this, ``NotebookLMClient.from_storage()``
|
||||
# below would die with a confusing "no storage" error when the secret
|
||||
# resolves empty (issue #1009).
|
||||
- name: Verify auth secret is present
|
||||
env:
|
||||
NOTEBOOKLM_AUTH_JSON: ${{ secrets.NOTEBOOKLM_AUTH_JSON }}
|
||||
NOTEBOOKLM_GENERATION_NOTEBOOK_ID: ${{ secrets.NOTEBOOKLM_GENERATION_NOTEBOOK_ID }}
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -z "${NOTEBOOKLM_AUTH_JSON:-}" ]; then
|
||||
echo "::error::NOTEBOOKLM_AUTH_JSON resolved to empty. Env binding or secret config is broken — see docs/development.md → Workflow secret gates."
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${NOTEBOOKLM_GENERATION_NOTEBOOK_ID:-}" ]; then
|
||||
echo "::error::NOTEBOOKLM_GENERATION_NOTEBOOK_ID resolved to empty."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify artifacts exist
|
||||
env:
|
||||
NOTEBOOKLM_AUTH_JSON: ${{ secrets.NOTEBOOKLM_AUTH_JSON }}
|
||||
NOTEBOOKLM_GENERATION_NOTEBOOK_ID: ${{ secrets.NOTEBOOKLM_GENERATION_NOTEBOOK_ID }}
|
||||
run: |
|
||||
uv run python -c "
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from notebooklm import NotebookLMClient
|
||||
|
||||
# Type ID to display name mapping
|
||||
TYPE_NAMES = {
|
||||
1: 'Audio',
|
||||
2: 'Report', # Study Guide, Briefing Doc, Blog Post
|
||||
3: 'Video',
|
||||
4: 'Quiz/Flashcards',
|
||||
5: 'Mind Map',
|
||||
7: 'Infographic',
|
||||
8: 'Slide Deck',
|
||||
9: 'Data Table',
|
||||
}
|
||||
|
||||
# Expected type IDs from generation tests
|
||||
# Note: Type 2 is Reports (study guide), Type 4 is Quiz+Flashcards
|
||||
EXPECTED_TYPES = {1, 2, 3, 4, 5, 7, 8, 9}
|
||||
|
||||
async def verify():
|
||||
async with await NotebookLMClient.from_storage() as client:
|
||||
nb_id = os.environ['NOTEBOOKLM_GENERATION_NOTEBOOK_ID']
|
||||
print(f'Checking notebook: {nb_id}')
|
||||
|
||||
# List artifacts
|
||||
artifacts = await client.artifacts.list(nb_id)
|
||||
print(f'\nTotal artifacts: {len(artifacts)}')
|
||||
|
||||
# Group by type and status
|
||||
by_type = {}
|
||||
for a in artifacts:
|
||||
key = a._artifact_type
|
||||
if key not in by_type:
|
||||
by_type[key] = []
|
||||
by_type[key].append(a)
|
||||
|
||||
print('\nArtifacts by type:')
|
||||
for t in sorted(by_type.keys()):
|
||||
items = by_type[t]
|
||||
type_name = TYPE_NAMES.get(t, f'Unknown({t})')
|
||||
print(f' {type_name} (type {t}): {len(items)}')
|
||||
for a in items:
|
||||
status = a.status_str
|
||||
variant_info = f', variant={a._variant}' if a._variant else ''
|
||||
print(f' - {a.title} ({status}{variant_info})')
|
||||
|
||||
# Check expected types
|
||||
found = set(by_type.keys())
|
||||
missing = EXPECTED_TYPES - found
|
||||
|
||||
print(f'\nExpected types: {len(EXPECTED_TYPES)}')
|
||||
print(f'Found types: {len(found)}')
|
||||
|
||||
if missing:
|
||||
missing_names = [TYPE_NAMES.get(t, str(t)) for t in missing]
|
||||
print(f'\nWARNING: Missing artifact types: {missing_names}')
|
||||
|
||||
# Check for completed artifacts
|
||||
completed = sum(1 for a in artifacts if a.is_completed)
|
||||
processing = sum(1 for a in artifacts if a.is_processing)
|
||||
failed = sum(1 for a in artifacts if a.is_failed)
|
||||
|
||||
print(f'\nStatus summary:')
|
||||
print(f' Completed: {completed}')
|
||||
print(f' Processing: {processing}')
|
||||
print(f' Failed: {failed}')
|
||||
|
||||
# List notes
|
||||
notes = await client.notes.list(nb_id)
|
||||
print(f'\nTotal notes: {len(notes)}')
|
||||
for n in notes[:10]:
|
||||
print(f' - {n.title or \"(untitled)\"}')
|
||||
if len(notes) > 10:
|
||||
print(f' ... and {len(notes) - 10} more')
|
||||
|
||||
# Fail if too many failures or no artifacts at all
|
||||
if len(artifacts) == 0:
|
||||
print('\nERROR: No artifacts found!')
|
||||
sys.exit(1)
|
||||
if failed > len(artifacts) // 2:
|
||||
print(f'\nERROR: Too many failed artifacts ({failed}/{len(artifacts)})')
|
||||
sys.exit(1)
|
||||
|
||||
print('\nVerification complete!')
|
||||
|
||||
asyncio.run(verify())
|
||||
"
|
||||
@@ -0,0 +1,199 @@
|
||||
name: Verify Package
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
source:
|
||||
description: 'Package source'
|
||||
required: true
|
||||
default: 'testpypi'
|
||||
type: choice
|
||||
options:
|
||||
- testpypi
|
||||
- pypi
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Verify from ${{ inputs.source }}
|
||||
runs-on: ubuntu-latest
|
||||
# Secret-bearing job (steps below reference secrets.NOTEBOOKLM_AUTH_JSON
|
||||
# + secrets.NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID for the live E2E pass). The
|
||||
# workflow is workflow_dispatch-only, so every run is human-initiated;
|
||||
# this `protected-readonly` environment requires a maintainer to approve
|
||||
# the dispatch before any secret is exposed. Non-maintainer dispatches
|
||||
# block at the approval prompt rather than acquiring tokens.
|
||||
# See docs/development.md → "Workflow secret gates".
|
||||
environment: protected-readonly
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # @ v7.0.0
|
||||
|
||||
- name: Get version from pyproject.toml
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Sync locked deps + non-cookies extras
|
||||
# Pull every runtime + tooling dep from `uv.lock` (the same lockfile
|
||||
# contributors install from) so the verify step exercises the full
|
||||
# non-cookies dep tree rather than whatever pip happens to resolve at
|
||||
# smoke time. `cookies` stays excluded because rookiepy has Python
|
||||
# 3.13+ install issues.
|
||||
# The published wheel itself is force-reinstalled in the next step.
|
||||
run: >
|
||||
uv sync --frozen
|
||||
--extra browser
|
||||
--extra dev
|
||||
--extra markdown
|
||||
--extra mcp
|
||||
--extra server
|
||||
|
||||
- name: Install published wheel from TestPyPI (--no-deps)
|
||||
if: inputs.source == 'testpypi'
|
||||
shell: bash
|
||||
run: |
|
||||
# --no-deps proves the wheel was actually uploaded to TestPyPI — the
|
||||
# previous `--extra-index-url https://pypi.org/simple/` fallback would
|
||||
# silently succeed by resolving an older published version from PyPI
|
||||
# when the TestPyPI upload itself was broken or missing.
|
||||
# --reinstall replaces the editable install left behind by `uv sync`
|
||||
# with the actual built wheel under test. --no-cache + --only-binary
|
||||
# ensure we test the freshly-uploaded wheel, never a cached sdist.
|
||||
#
|
||||
# `uv pip install --python .venv/bin/python` is load-bearing: a
|
||||
# `source .venv/bin/activate && pip install …` chain falls back to
|
||||
# the runner's system pip when uv has not seeded pip into `.venv`,
|
||||
# which then writes outside the venv and leaves the editable install
|
||||
# in place — masking a broken/missing TestPyPI upload.
|
||||
uv pip install --python .venv/bin/python \
|
||||
--no-deps --reinstall --no-cache --only-binary=:all: \
|
||||
--index-url https://test.pypi.org/simple/ \
|
||||
"notebooklm-py==${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: Install published wheel from PyPI (--no-deps)
|
||||
if: inputs.source == 'pypi'
|
||||
shell: bash
|
||||
run: |
|
||||
# Symmetric with the TestPyPI step: --no-deps + --reinstall swaps the
|
||||
# locked editable install for the actual PyPI wheel without
|
||||
# disturbing the dep tree resolved from `uv.lock`. See the TestPyPI
|
||||
# step above for why `uv pip install --python .venv/bin/python` is
|
||||
# used instead of activating the venv and calling bare `pip`.
|
||||
uv pip install --python .venv/bin/python \
|
||||
--no-deps --reinstall --no-cache --only-binary=:all: \
|
||||
"notebooklm-py==${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: Verify version
|
||||
shell: bash
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
INSTALLED=$(python -c "from notebooklm import __version__; print(__version__)")
|
||||
EXPECTED="${{ steps.version.outputs.version }}"
|
||||
if [ "$INSTALLED" != "$EXPECTED" ]; then
|
||||
echo "Version mismatch: installed=$INSTALLED expected=$EXPECTED"
|
||||
exit 1
|
||||
fi
|
||||
echo "Version verified: $INSTALLED"
|
||||
|
||||
- name: Verify CLI
|
||||
shell: bash
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
notebooklm --version
|
||||
notebooklm --help
|
||||
|
||||
- name: Verify imports
|
||||
shell: bash
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
python -c "from notebooklm import NotebookLMClient, Notebook, Source, Artifact"
|
||||
echo "Core imports verified"
|
||||
|
||||
- name: Install Playwright browsers
|
||||
shell: bash
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
# Test deps come from the non-cookies extras installed via
|
||||
# `uv sync --frozen` above. Just need to provision the Chromium browser
|
||||
# binary + Linux system libs.
|
||||
playwright install chromium
|
||||
playwright install-deps chromium
|
||||
|
||||
- name: Run unit tests
|
||||
shell: bash
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
pytest tests/unit -v
|
||||
|
||||
- name: Run integration tests
|
||||
shell: bash
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
pytest tests/integration -v
|
||||
|
||||
- name: Skip E2E tests (fork)
|
||||
if: github.repository != 'teng-lin/notebooklm-py'
|
||||
run: |
|
||||
echo "::warning::E2E tests skipped - secrets not available in fork repositories"
|
||||
echo "## E2E Tests Skipped" >> $GITHUB_STEP_SUMMARY
|
||||
echo "E2E tests were not run because this workflow is executing on a fork." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Run E2E tests
|
||||
id: e2e
|
||||
if: github.repository == 'teng-lin/notebooklm-py'
|
||||
# Don't fail the job here — the retry step below gets a 10-min cool-down
|
||||
# shot at any failures, and its exit code is what marks the job red.
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
env:
|
||||
NOTEBOOKLM_AUTH_JSON: ${{ secrets.NOTEBOOKLM_AUTH_JSON }}
|
||||
NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID: ${{ secrets.NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID }}
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
if [ -z "$NOTEBOOKLM_AUTH_JSON" ]; then
|
||||
echo "::error::NOTEBOOKLM_AUTH_JSON secret is not configured"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID" ]; then
|
||||
echo "::error::NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID secret is not configured"
|
||||
exit 1
|
||||
fi
|
||||
# --reruns 1 --reruns-delay 30 catches sub-minute network blips;
|
||||
# longer chat-throttle recovery is handled by the cool-down retry below.
|
||||
pytest tests/e2e -m "not variants" --reruns 1 --reruns-delay 30 -v
|
||||
|
||||
- name: Retry failed E2E tests after 10-min cool-down
|
||||
# Tests still throttled after the cool-down become skips via the
|
||||
# _install_chat_rate_limit_skip fixture in tests/e2e/conftest.py.
|
||||
if: steps.e2e.outcome == 'failure'
|
||||
shell: bash
|
||||
env:
|
||||
NOTEBOOKLM_AUTH_JSON: ${{ secrets.NOTEBOOKLM_AUTH_JSON }}
|
||||
NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID: ${{ secrets.NOTEBOOKLM_READ_ONLY_NOTEBOOK_ID }}
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
# Missing lastfailed after a failed e2e step means pytest crashed
|
||||
# before writing the cache (import error, OOM, etc.) — fail the job
|
||||
# rather than silently going green.
|
||||
if [ ! -f .pytest_cache/v/cache/lastfailed ]; then
|
||||
echo "::error::No lastfailed cache from previous step; pytest likely crashed before running tests."
|
||||
exit 1
|
||||
fi
|
||||
echo "Initial E2E run failed. Sleeping 600s before retrying failed tests."
|
||||
sleep 600
|
||||
uv run pytest tests/e2e --last-failed --last-failed-no-failures=none -s -v --tb=short
|
||||
Reference in New Issue
Block a user