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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:30:13 +08:00
commit 09e9f3545f
1231 changed files with 649127 additions and 0 deletions
+65
View File
@@ -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"'
+40
View File
@@ -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
+68
View File
@@ -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
+380
View File
@@ -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)."
+95
View File
@@ -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
+155
View File
@@ -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
+122
View File
@@ -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
+424
View File
@@ -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
+316
View File
@@ -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.103.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
+129
View File
@@ -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
+158
View File
@@ -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())
"
+199
View File
@@ -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