diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json index b3666727..8b5bd468 100644 --- a/.github/.release-please-manifest.json +++ b/.github/.release-please-manifest.json @@ -1,33 +1,4 @@ { - ".": "0.4.0", - "packages/deepctl-core": "0.3.0", - "packages/deepctl-shared-utils": "0.1.13", - "packages/deepctl-telemetry": "0.0.7", - "packages/deepctl-cmd-api": "0.0.2", - "packages/deepctl-cmd-login": "0.2.0", - "packages/deepctl-cmd-projects": "0.2.0", - "packages/deepctl-cmd-transcribe": "0.1.12", - "packages/deepctl-cmd-usage": "0.2.1", - "packages/deepctl-cmd-debug": "0.1.13", - "packages/deepctl-cmd-debug-audio": "0.1.14", - "packages/deepctl-cmd-debug-browser": "0.1.12", - "packages/deepctl-cmd-debug-network": "0.1.12", - "packages/deepctl-cmd-debug-probe": "0.0.2", - "packages/deepctl-cmd-debug-toolkit": "0.2.0", - "packages/deepctl-cmd-ffprobe": "0.0.3", - "packages/deepctl-cmd-mcp": "0.1.15", - "packages/deepctl-cmd-update": "0.2.7", - "packages/deepctl-cmd-plugin": "0.1.14", - "packages/deepctl-cmd-skills": "0.0.9", - "packages/deepctl-cmd-init": "0.1.0", - "packages/deepctl-cmd-models": "0.1.1", - "packages/deepctl-cmd-speak": "0.0.5", - "packages/deepctl-cmd-keys": "0.1.0", - "packages/deepctl-cmd-read": "0.1.0", - "packages/deepctl-cmd-listen": "0.0.14", - "packages/deepctl-cmd-requests": "0.1.0", - "packages/deepctl-cmd-billing": "0.1.0", - "packages/deepctl-cmd-members": "0.1.0", - "packages/deepctl-cmd-completion": "0.0.3", + ".": "0.4.1", "packages/deepctl-plugin-example": "0.1.12" } diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index b0b920e0..60ba77b4 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -117,7 +117,7 @@ body: attributes: label: CLI version description: Output of `dg --version` - placeholder: "deepctl, version 0.2.2" + placeholder: "dg, version 0.5.0" validations: required: true diff --git a/.github/release-please-config.json b/.github/release-please-config.json index fd5be2c1..63a6038d 100644 --- a/.github/release-please-config.json +++ b/.github/release-please-config.json @@ -10,161 +10,135 @@ "component": "deepctl", "include-component-in-tag": false, "make-latest": true, + "exclude-paths": [ + "packages/deepctl-plugin-example" + ], "extra-files": [ - "src/deepctl/__init__.py" + "src/deepctl/__init__.py", + { + "type": "generic", + "path": "pyproject.toml" + }, + "packages/deepctl-shared-utils/src/deepctl_shared_utils/__init__.py", + "packages/deepctl-telemetry/src/deepctl_telemetry/__init__.py", + { + "type": "generic", + "path": "packages/deepctl-cmd-api/pyproject.toml" + }, + { + "type": "generic", + "path": "packages/deepctl-cmd-billing/pyproject.toml" + }, + { + "type": "generic", + "path": "packages/deepctl-cmd-completion/pyproject.toml" + }, + { + "type": "generic", + "path": "packages/deepctl-cmd-debug/pyproject.toml" + }, + { + "type": "generic", + "path": "packages/deepctl-cmd-debug-audio/pyproject.toml" + }, + { + "type": "generic", + "path": "packages/deepctl-cmd-debug-browser/pyproject.toml" + }, + { + "type": "generic", + "path": "packages/deepctl-cmd-debug-network/pyproject.toml" + }, + { + "type": "generic", + "path": "packages/deepctl-cmd-debug-probe/pyproject.toml" + }, + { + "type": "generic", + "path": "packages/deepctl-cmd-debug-toolkit/pyproject.toml" + }, + { + "type": "generic", + "path": "packages/deepctl-cmd-ffprobe/pyproject.toml" + }, + { + "type": "generic", + "path": "packages/deepctl-cmd-init/pyproject.toml" + }, + { + "type": "generic", + "path": "packages/deepctl-cmd-keys/pyproject.toml" + }, + { + "type": "generic", + "path": "packages/deepctl-cmd-listen/pyproject.toml" + }, + { + "type": "generic", + "path": "packages/deepctl-cmd-login/pyproject.toml" + }, + { + "type": "generic", + "path": "packages/deepctl-cmd-mcp/pyproject.toml" + }, + { + "type": "generic", + "path": "packages/deepctl-cmd-members/pyproject.toml" + }, + { + "type": "generic", + "path": "packages/deepctl-cmd-models/pyproject.toml" + }, + { + "type": "generic", + "path": "packages/deepctl-cmd-plugin/pyproject.toml" + }, + { + "type": "generic", + "path": "packages/deepctl-cmd-projects/pyproject.toml" + }, + { + "type": "generic", + "path": "packages/deepctl-cmd-read/pyproject.toml" + }, + { + "type": "generic", + "path": "packages/deepctl-cmd-requests/pyproject.toml" + }, + { + "type": "generic", + "path": "packages/deepctl-cmd-skills/pyproject.toml" + }, + { + "type": "generic", + "path": "packages/deepctl-cmd-speak/pyproject.toml" + }, + { + "type": "generic", + "path": "packages/deepctl-cmd-transcribe/pyproject.toml" + }, + { + "type": "generic", + "path": "packages/deepctl-cmd-update/pyproject.toml" + }, + { + "type": "generic", + "path": "packages/deepctl-cmd-usage/pyproject.toml" + }, + { + "type": "generic", + "path": "packages/deepctl-core/pyproject.toml" + }, + { + "type": "generic", + "path": "packages/deepctl-shared-utils/pyproject.toml" + }, + { + "type": "generic", + "path": "packages/deepctl-telemetry/pyproject.toml" + } ] }, - "packages/deepctl-core": { - "component": "deepctl-core", - "include-component-in-tag": true, - "make-latest": false - }, - "packages/deepctl-shared-utils": { - "component": "deepctl-shared-utils", - "include-component-in-tag": true, - "make-latest": false, - "extra-files": [ - "src/deepctl_shared_utils/__init__.py" - ] - }, - "packages/deepctl-telemetry": { - "component": "deepctl-telemetry", - "include-component-in-tag": true, - "make-latest": false, - "extra-files": [ - "src/deepctl_telemetry/__init__.py" - ] - }, - "packages/deepctl-cmd-api": { - "component": "deepctl-cmd-api", - "include-component-in-tag": true, - "make-latest": false - }, - "packages/deepctl-cmd-login": { - "component": "deepctl-cmd-login", - "include-component-in-tag": true, - "make-latest": false - }, - "packages/deepctl-cmd-projects": { - "component": "deepctl-cmd-projects", - "include-component-in-tag": true, - "make-latest": false - }, - "packages/deepctl-cmd-transcribe": { - "component": "deepctl-cmd-transcribe", - "include-component-in-tag": true, - "make-latest": false - }, - "packages/deepctl-cmd-usage": { - "component": "deepctl-cmd-usage", - "include-component-in-tag": true, - "make-latest": false - }, - "packages/deepctl-cmd-debug": { - "component": "deepctl-cmd-debug", - "include-component-in-tag": true, - "make-latest": false - }, - "packages/deepctl-cmd-debug-audio": { - "component": "deepctl-cmd-debug-audio", - "include-component-in-tag": true, - "make-latest": false - }, - "packages/deepctl-cmd-debug-browser": { - "component": "deepctl-cmd-debug-browser", - "include-component-in-tag": true, - "make-latest": false - }, - "packages/deepctl-cmd-debug-network": { - "component": "deepctl-cmd-debug-network", - "include-component-in-tag": true, - "make-latest": false - }, - "packages/deepctl-cmd-debug-probe": { - "component": "deepctl-cmd-debug-probe", - "include-component-in-tag": true, - "make-latest": false - }, - "packages/deepctl-cmd-debug-toolkit": { - "component": "deepctl-cmd-debug-toolkit", - "include-component-in-tag": true, - "make-latest": false - }, - "packages/deepctl-cmd-ffprobe": { - "component": "deepctl-cmd-ffprobe", - "include-component-in-tag": true, - "make-latest": false - }, - "packages/deepctl-cmd-mcp": { - "component": "deepctl-cmd-mcp", - "include-component-in-tag": true, - "make-latest": false - }, - "packages/deepctl-cmd-update": { - "component": "deepctl-cmd-update", - "include-component-in-tag": true, - "make-latest": false - }, - "packages/deepctl-cmd-plugin": { - "component": "deepctl-cmd-plugin", - "include-component-in-tag": true, - "make-latest": false - }, - "packages/deepctl-cmd-skills": { - "component": "deepctl-cmd-skills", - "include-component-in-tag": true, - "make-latest": false - }, - "packages/deepctl-cmd-init": { - "component": "deepctl-cmd-init", - "include-component-in-tag": true, - "make-latest": false - }, - "packages/deepctl-cmd-models": { - "component": "deepctl-cmd-models", - "include-component-in-tag": true, - "make-latest": false - }, - "packages/deepctl-cmd-speak": { - "component": "deepctl-cmd-speak", - "include-component-in-tag": true, - "make-latest": false - }, - "packages/deepctl-cmd-keys": { - "component": "deepctl-cmd-keys", - "include-component-in-tag": true, - "make-latest": false - }, - "packages/deepctl-cmd-read": { - "component": "deepctl-cmd-read", - "include-component-in-tag": true, - "make-latest": false - }, - "packages/deepctl-cmd-listen": { - "component": "deepctl-cmd-listen", - "include-component-in-tag": true, - "make-latest": false - }, - "packages/deepctl-cmd-requests": { - "component": "deepctl-cmd-requests", - "include-component-in-tag": true, - "make-latest": false - }, - "packages/deepctl-cmd-billing": { - "component": "deepctl-cmd-billing", - "include-component-in-tag": true, - "make-latest": false - }, - "packages/deepctl-cmd-members": { - "component": "deepctl-cmd-members", - "include-component-in-tag": true, - "make-latest": false - }, - "packages/deepctl-cmd-completion": { - "component": "deepctl-cmd-completion", - "include-component-in-tag": true, - "make-latest": false - }, "packages/deepctl-plugin-example": { "component": "deepctl-plugin-example", "include-component-in-tag": true, diff --git a/.github/workflows/competitive-bench.yml b/.github/workflows/competitive-bench.yml new file mode 100644 index 00000000..3ed7cf5f --- /dev/null +++ b/.github/workflows/competitive-bench.yml @@ -0,0 +1,177 @@ +name: Competitive bench + +# Benchmark harness in bench/ (README there). Every measured process runs in a +# container with --network none; no job uses a secret or a Deepgram key. +# - pull requests touching hot paths: base and head timed interleaved in one job +# - weekly: dg plus every pinned competitor +# - workflow_call with a ref, for a release job to call later; nothing calls it +# yet (bench/README.md, "Release gating") +# - weekly A/A (base vs base) feeds the rule that ends report-only timing gates +# Deterministic gates block as soon as their `since` version is reached; timing +# gates stay report-only until bench/gates.json marks them blocking. + +on: + pull_request: + paths: + - "src/**" + - "packages/**" + - "launcher/**" + - "uv.lock" + - "**/pyproject.toml" + - "bench/**" + - ".github/workflows/competitive-bench.yml" + schedule: + - cron: "17 5 * * 1" + workflow_dispatch: + # For a release job to call before publishing (not wired yet). A tag pushed by + # release-please with GITHUB_TOKEN starts no workflow, so a `push: tags` + # trigger would never fire. + workflow_call: + inputs: + ref: + description: Commit to benchmark (the release commit) + type: string + required: true + +permissions: + contents: read + +concurrency: + group: competitive-bench-${{ github.event_name }}-${{ github.ref }}-${{ inputs.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + DEEPCTL_TELEMETRY_DISABLED: "1" + BENCH_RUNNER_LABEL: ubuntu-24.04-arm + +jobs: + selftest: + name: Harness self-test + runs-on: ubuntu-24.04-arm + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + + - name: Unit tests + run: | + py=$(python3 -c 'import json; print(json.load(open("bench/competitors.json"))["images"]["python"])') + docker run --rm -v "$PWD/bench:/bench:ro" -e PYTHONDONTWRITEBYTECODE=1 "$py" \ + sh -c 'pip -q install pytest==8.3.3 && cd /tmp && python -m pytest -q -p no:cacheprovider /bench/tests' + + - name: Fake-CLI self-test + run: bench/selftest.sh + + pr: + name: Base vs head (paired) + if: github.event_name == 'pull_request' + needs: selftest + runs-on: ubuntu-24.04-arm + timeout-minutes: 60 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 + with: + fetch-depth: 0 + persist-credentials: false + + # The harness and gates come from the base commit, so a PR can't loosen the + # gates it is judged by. HEAD is the merge commit; its first parent is the + # base the PR actually merges onto. + - name: Harness from the base commit + run: | + base=$(git rev-parse HEAD^1) + echo "BASE_SHA=$base" >> "$GITHUB_ENV" + echo "HEAD_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV" + if git cat-file -e "$base:bench/bench.py" 2>/dev/null; then + git worktree add --detach "$RUNNER_TEMP/base" "$base" + echo "BENCH_DIR=$RUNNER_TEMP/base/bench" >> "$GITHUB_ENV" + else + # Bootstrap only: the PR that adds bench/, or a base branch cut before it. + echo "BENCH_DIR=$PWD/bench" >> "$GITHUB_ENV" + fi + + - name: Paired run, one automatic re-run on failure + run: | + set +e + for attempt in 1 2; do + out="$PWD/bench/out/pair-$attempt" + python3 "$BENCH_DIR/bench.py" pair --base "$BASE_SHA" --head "$HEAD_SHA" --out "$out" + python3 "$BENCH_DIR/check.py" pair "$out/pair.json" --json "$out/check.json" --summary "$GITHUB_STEP_SUMMARY" + rc=$? + [ "$rc" -eq 0 ] && break + echo "attempt $attempt failed; re-running once" >> "$GITHUB_STEP_SUMMARY" + done + exit "$rc" + + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: always() + with: + name: bench-pr-${{ github.event.pull_request.number }} + path: | + bench/out/pair-*/pair.json + bench/out/pair-*/check.json + retention-days: 30 + + full: + name: dg and competitors + if: github.event_name != 'pull_request' + needs: selftest + runs-on: ubuntu-24.04-arm + timeout-minutes: 240 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + + - name: Run + run: python3 bench/bench.py run --ref HEAD --out bench/out/full + + - name: Check + run: | + base="bench/baselines/$BENCH_RUNNER_LABEL.json" + args="" + [ -f "$base" ] && args="--baseline $base" + # shellcheck disable=SC2086 + python3 bench/check.py bench/out/full/results.json $args --json bench/out/full/check.json --summary "$GITHUB_STEP_SUMMARY" + + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: always() + with: + name: bench-full-${{ github.run_id }} + path: | + bench/out/full/results.json + bench/out/full/check.json + retention-days: 90 + + aa: + name: A/A (base vs base) + if: (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && inputs.ref == '' + needs: selftest + runs-on: ubuntu-24.04-arm + timeout-minutes: 60 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 + with: + persist-credentials: false + + - name: Paired run of HEAD against itself + run: | + set +e + for attempt in 1 2; do + out="bench/out/aa-$attempt" + python3 bench/bench.py pair --base HEAD --head HEAD --skip-deterministic --out "$out" + python3 bench/check.py pair "$out/pair.json" --json "$out/check.json" --summary "$GITHUB_STEP_SUMMARY" + python3 -c 'import json,sys; r=json.load(open(sys.argv[1]))["pair"]; sys.exit(1 if any(x["status"]=="RED" for x in r) else 0)' "$out/check.json" && break + done + # A red A/A after the re-run counts as a false positive toward ending report-only (bench/README.md); recorded, never failing. + exit 0 + + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: always() + with: + name: bench-aa-${{ github.run_id }} + path: bench/out/aa-*/check.json + retention-days: 400 diff --git a/.github/workflows/publish-on-release.yml b/.github/workflows/publish-on-release.yml deleted file mode 100644 index 673795d3..00000000 --- a/.github/workflows/publish-on-release.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Publish to PyPI on release - -# Fires when a human publishes a GitHub release with `gh release create -# v0.X.Y` (or via the GitHub UI) to recover from a stuck release-please -# state. Typical trigger: release-please aborts with `untagged, merged -# release PRs outstanding` because a previous merged release PR didn't -# get tagged, and `autorelease: pending` is stuck on it. -# -# Releases created by GITHUB_TOKEN (release-please's normal flow) do NOT -# trigger this workflow because GitHub Actions intentionally blocks that -# loop, so the standard release-please path keeps using release.yml's -# existing publish job. This workflow ONLY fires on human-driven -# recovery releases, so there is no double-publish race. -# -# Intentionally NO workflow_dispatch trigger. Manual deploys outside a -# real release-published event have shipped landing pages and packages -# that pointed at unreleased versions. Production publish must always -# be anchored to a real GitHub release artifact. - -on: - release: - types: [published] - -permissions: - contents: read - -jobs: - publish: - name: Build and publish to PyPI - runs-on: ubuntu-latest - # Only fire on root-package releases (v0.2.4, v1.0.0, …). - # Sub-package tags look like deepctl-cmd-listen-v0.0.3, which we skip; - # release-please's normal flow handles those via release.yml. - if: startsWith(github.event.release.tag_name, 'v') - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 - with: - ref: ${{ github.event.release.tag_name }} - - - name: Set up Python - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 - with: - python-version: "3.11" - - - name: Install build dependencies - run: pip install build twine - - - name: Build all packages - run: make build - - - name: Verify built packages - run: make verify-packages - - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 - with: - password: ${{ secrets.PYPI_API_TOKEN }} - packages-dir: dist/ - skip-existing: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c061010..af67ace9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,12 +4,36 @@ on: push: branches: - main + # A dispatch with no tag runs release-please, like a push. A dispatch + # with a tag rebuilds and publishes that existing release instead: the + # way to recover when a release was tagged but never reached PyPI. workflow_dispatch: + inputs: + tag: + description: >- + Existing root release tag (vX.Y.Z) to rebuild and publish. + Leave empty to run release-please. + type: string + required: false + default: "" + index: + description: >- + Where a tag dispatch publishes. testpypi is the dry run of the + trusted-publishing setup and advertises nothing. + type: choice + options: + - pypi + - testpypi + default: pypi permissions: contents: write pull-requests: write +# Keep CI from sending telemetry from anything this workflow runs. +env: + DEEPCTL_TELEMETRY_DISABLED: "1" + jobs: release-please: name: Release Please @@ -26,24 +50,56 @@ jobs: token: ${{ github.token }} config-file: .github/release-please-config.json manifest-file: .github/.release-please-manifest.json + # Only a push or a plain dispatch cuts a release. A tag dispatch + # republishes an existing tag, and a TestPyPI dry run must not + # create a real release. + if: ${{ github.event_name == 'push' || (inputs.tag == '' && inputs.index != 'testpypi') }} + - name: Refuse a TestPyPI dry run without a tag + if: ${{ github.event_name == 'workflow_dispatch' && inputs.index == 'testpypi' && inputs.tag == '' }} + run: | + echo "::error::a TestPyPI dry run needs a tag to publish" + exit 1 + + # The release PR's re-pin runs as two jobs so that no third-party code ever + # runs next to a write credential: + # sync-release-pr no token, no secrets. Runs uv and the lockstep check, + # and hands the resulting change over as a patch artifact. + # push-release-pr mints the release App token and runs no Python and no + # uv: it applies the patch, checks which files it touches, + # commits and pushes. + # A release-PR job that must run after the re-pin goes here as + # `needs: [release-please, push-release-pr]`. Benchmark gates report from the + # PR's own workflows, not from this file. sync-release-pr: - name: Sync lockfile and floors on the release PR + name: Re-pin lockstep versions on the release PR needs: release-please if: ${{ needs.release-please.outputs.prs_created == 'true' && needs.release-please.outputs.pr }} runs-on: ubuntu-latest - # This job pushes to the release branch. Two commits landing on main in - # quick succession would otherwise run two of these against the same - # branch and the loser fails on a non-fast-forward push, reddening the - # release workflow for no real reason. Serialize instead of cancelling: - # the second run still has work to do once the first one's commit lands. + permissions: + contents: read + # Two commits landing on main in quick succession would otherwise compute + # two patches against the same branch. Serialize instead of cancelling. concurrency: group: sync-release-pr cancel-in-progress: false + outputs: + changed: ${{ steps.patch.outputs.changed }} + base: ${{ steps.patch.outputs.base }} + env: + BRANCH: ${{ fromJSON(needs.release-please.outputs.pr).headBranchName }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 + - name: Refuse anything but a release-please branch + run: | + case "${BRANCH}" in + release-please--*) ;; + *) echo "::error::refusing to re-pin ${BRANCH}: not a release-please branch"; exit 1 ;; + esac + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ fromJSON(needs.release-please.outputs.pr).headBranchName }} + ref: ${{ env.BRANCH }} + persist-credentials: false - name: Set up Python uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 @@ -52,46 +108,198 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 - - # release-please bumps every pyproject version but updates neither - # uv.lock (CI's `uv sync --locked` then fails all jobs at the install - # step -- the 0.2.28/0.3.0 wall, twice) nor the root dependency floors - # (without which `dg update` on pip delivers the wrapper and skips the - # sub-packages the changelog advertises). Both are mechanical - # consequences of the version bumps, so regenerate them here. - # - # Caveat: this push uses GITHUB_TOKEN, which does not retrigger the - # PR's checks. The routine hand-edit of the release notes retriggers - # them; for an untouched release PR, re-run checks from the UI. If - # releases ever need to go out unattended, switch this push to a - # dedicated PAT or GitHub App token. - - name: Regenerate uv.lock and pin root floors + with: + version: "0.13.6" + # No cache: nothing this job writes may be restored by another job. + enable-cache: false + + # release-please moves every lockstep version and marked pin. --fix + # catches anything it could not (a package added since, an unmarked + # pin), uv.lock follows the new versions (CI's `uv sync --locked` fails + # every job without it), and the plain check proves the result. The + # check's one dependency is installed hash-pinned. + - name: Re-pin and regenerate uv.lock run: | set -euo pipefail - uv run python scripts/check_dependency_floors.py --fix + printf '%s\n' 'packaging==26.3 --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c' > "${RUNNER_TEMP}/check-requirements.txt" + uv venv -q "${RUNNER_TEMP}/check" + uv pip install -q --python "${RUNNER_TEMP}/check/bin/python" --require-hashes --only-binary :all: -r "${RUNNER_TEMP}/check-requirements.txt" + "${RUNNER_TEMP}/check/bin/python" scripts/check_dependency_floors.py --fix uv lock - uv run python scripts/check_dependency_floors.py + "${RUNNER_TEMP}/check/bin/python" scripts/check_dependency_floors.py - - name: Commit and push if changed + - name: Write the re-pin as a patch + id: patch run: | set -euo pipefail - if git diff --quiet; then - echo "uv.lock and floors already in sync" + echo "base=$(git rev-parse HEAD)" >> "${GITHUB_OUTPUT}" + git add pyproject.toml packages/*/pyproject.toml uv.lock + # Anything else the re-pin touched means release-please and the + # check disagree about the tree; stop rather than push half of it. + if ! git diff --quiet; then + echo "::error::re-pin changed files outside the pyprojects and uv.lock:" + git diff --name-only + exit 1 + fi + if git diff --cached --quiet; then + echo "versions, pins and uv.lock already in sync" + echo "changed=false" >> "${GITHUB_OUTPUT}" exit 0 fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add uv.lock pyproject.toml - git commit -m "chore: sync uv.lock and root dependency floors with release-please bumps" - git push + git diff --cached --no-renames --binary > repin.patch + git diff --cached --stat + echo "changed=true" >> "${GITHUB_OUTPUT}" + + - name: Hand the patch to the push job + if: ${{ steps.patch.outputs.changed == 'true' }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: repin-patch + path: repin.patch + retention-days: 1 + + push-release-pr: + name: Push the re-pin with the release App + needs: [release-please, sync-release-pr] + if: ${{ needs.sync-release-pr.outputs.changed == 'true' }} + runs-on: ubuntu-latest + # The App's private key is a secret of the `release` environment, whose + # deployment branches are limited to main: a workflow on any other branch + # can't read it. + environment: release + permissions: + contents: read + concurrency: + group: push-release-pr + cancel-in-progress: false + env: + BRANCH: ${{ fromJSON(needs.release-please.outputs.pr).headBranchName }} + BASE: ${{ needs.sync-release-pr.outputs.base }} + steps: + - name: Refuse anything but a release-please branch + run: | + case "${BRANCH}" in + release-please--*) ;; + *) echo "::error::refusing to push to ${BRANCH}: not a release-please branch"; exit 1 ;; + esac + + # A token for this repository only, with contents: write and nothing + # else. Commits pushed with an App token trigger the PR's workflows, so + # the required checks (Lockstep versions among them) run on the release + # PR's final head. GITHUB_TOKEN pushes trigger nothing. + - name: Mint the release App token + id: app-token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + permission-contents: write + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.BRANCH }} + persist-credentials: false + + - name: Fetch the patch + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: repin-patch + path: ${{ runner.temp }}/repin + + - name: Apply the patch and check what it touches + run: | + set -euo pipefail + if [ "$(git rev-parse HEAD)" != "${BASE}" ]; then + echo "::error::${BRANCH} moved since the patch was computed; the next release-please run re-pins it" + exit 1 + fi + git apply --check "${RUNNER_TEMP}/repin/repin.patch" + git apply --index "${RUNNER_TEMP}/repin/repin.patch" + # Only in-place edits of existing regular files, at exact paths: + # renames are listed as a delete plus an add, so neither a rename, + # a new file, a deletion, a symlink nor a mode change gets through. + git diff --cached --no-renames --raw | while read -r om nm _ _ st path; do + if [ "${om#:} ${nm} ${st}" != "100644 100644 M" ]; then + echo "::error::${path}: only in-place edits of existing files are allowed (${om#:} ${nm} ${st})"; exit 1 + fi + if ! [[ "${path}" =~ ^(pyproject\.toml|uv\.lock|packages/[^/]+/pyproject\.toml)$ ]]; then + echo "::error::the patch touches ${path}"; exit 1 + fi + done + + # The token reaches git only through GIT_CONFIG_* environment entries: + # never in a URL, an argument or .git/config, and hooks are off. + - name: Commit and push with the release App token + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + APP_SLUG: ${{ steps.app-token.outputs.app-slug }} + run: | + set -euo pipefail + BOT_ID=$(GH_TOKEN="${APP_TOKEN}" gh api "/users/${APP_SLUG}%5Bbot%5D" --jq .id) + git -c core.hooksPath=/dev/null \ + -c user.name="${APP_SLUG}[bot]" \ + -c user.email="${BOT_ID}+${APP_SLUG}[bot]@users.noreply.github.com" \ + commit -m "chore: re-pin lockstep versions and regenerate uv.lock" + AUTH=$(printf 'x-access-token:%s' "${APP_TOKEN}" | base64 -w0) + echo "::add-mask::${AUTH}" + GIT_CONFIG_COUNT=2 \ + GIT_CONFIG_KEY_0="http.https://github.com/.extraheader" \ + GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${AUTH}" \ + GIT_CONFIG_KEY_1="core.hooksPath" \ + GIT_CONFIG_VALUE_1="/dev/null" \ + git push origin "HEAD:refs/heads/${BRANCH}" build: name: Build packages needs: release-please - if: ${{ needs.release-please.outputs.release_created }} + if: >- + ${{ needs.release-please.outputs.release_created == 'true' || + (github.event_name == 'workflow_dispatch' && inputs.tag != '') }} runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 + with: + persist-credentials: false + # A new release builds the tag release-please just created, not + # whatever the run's branch points at. A recovery checks out its + # tag below, and needs the branch history and its tags for that. + ref: ${{ (github.event_name != 'workflow_dispatch' || inputs.tag == '') && needs.release-please.outputs.tag_name || '' }} + fetch-depth: ${{ (github.event_name == 'workflow_dispatch' && inputs.tag != '') && '0' || '1' }} + + # Only a root release tag that is already a published GitHub release + # and is in the history of the branch this run was started on. The + # publish environments accept only main, release/* and next/*. + - name: Check out the recovery tag + if: ${{ github.event_name == 'workflow_dispatch' && inputs.tag != '' }} + env: + TAG: ${{ inputs.tag }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + # Checked before TAG is printed anywhere. + if ! [[ "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::the tag input is not a root release tag (vX.Y.Z)"; exit 1 + fi + case "${GITHUB_REF}" in + refs/heads/main | refs/heads/release/* | refs/heads/next/*) ;; + *) echo "::error::start a recovery from main, release/* or next/*, not ${GITHUB_REF}"; exit 1 ;; + esac + if ! git rev-parse -q --verify "refs/tags/${TAG}^{commit}" >/dev/null; then + echo "::error::no tag ${TAG}"; exit 1 + fi + if ! git merge-base --is-ancestor "refs/tags/${TAG}^{commit}" "${GITHUB_SHA}"; then + echo "::error::${TAG} is not in the history of ${GITHUB_REF_NAME}"; exit 1 + fi + if ! DRAFT=$(gh release view "${TAG}" --repo "${GITHUB_REPOSITORY}" --json isDraft --jq .isDraft); then + echo "::error::${TAG} has no GitHub release"; exit 1 + fi + if [ "${DRAFT}" != "false" ]; then + echo "::error::${TAG}'s GitHub release is still a draft"; exit 1 + fi + git checkout --detach "refs/tags/${TAG}^{commit}" + echo "Rebuilding ${TAG} at $(git rev-parse HEAD)" - name: Set up Python uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 @@ -107,6 +315,20 @@ jobs: - name: Verify built packages run: make verify-packages + # A tag name can move; the version inside the build can't. Publish + # only if the tree that was built carries the tag's version. + - name: Check the build matches the release tag + if: ${{ startsWith(inputs.tag || needs.release-please.outputs.tag_name, 'v') }} + env: + TAG: ${{ inputs.tag || needs.release-please.outputs.tag_name }} + run: | + set -euo pipefail + if ! compgen -G "dist/deepctl-${TAG#v}-*.whl" >/dev/null; then + echo "::error::the build has no deepctl ${TAG#v} wheel; ${TAG} does not point at that release" + ls dist/ + exit 1 + fi + - name: Upload artifacts uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: @@ -117,8 +339,12 @@ jobs: name: Test installation needs: build runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 + with: + persist-credentials: false - name: Set up Python uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 @@ -218,9 +444,8 @@ jobs: needs: [release-please, publish, verify-published] # Re-assert latest on the root package tag (vX.Y.Z) after PyPI publish so # users clicking "latest" land on a tag whose artifact is actually - # installable. Also re-asserts after all sub-package releases since - # release-please processes them sequentially and a sub-package release - # created after the root tag can steal the pointer. + # installable. The plugin example's own release (its only other + # component) is created with make-latest false. if: | needs.release-please.outputs.release_created == 'true' && startsWith(needs.release-please.outputs.tag_name, 'v') @@ -244,10 +469,62 @@ jobs: uses: ./.github/workflows/web-production.yml secrets: inherit + # Trusted Publishing: each of the 31 PyPI projects names this file and + # the pypi environment as its publisher, and PyPI swaps the job's OIDC + # token for a short-lived upload token. There is no API token to read. + # The pypi environment deploys only from main, release/* and next/*. publish: name: Publish to PyPI needs: [build, test] + if: ${{ github.event_name == 'push' || inputs.index == 'pypi' }} + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/deepctl/ + permissions: + id-token: write + steps: + - name: Download artifacts + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: dist + path: dist/ + + # The root package goes last: it pins every sub-package ==, so it must + # never be on the index before them. "deepctl-" sorts before + # "deepctl_", so one upload of dist/ would send it first. + - name: Hold the root package back + run: | + set -euo pipefail + mkdir root-dist + mv dist/deepctl-[0-9]* root-dist/ + ls root-dist/ + + - name: Publish the sub-packages to PyPI + uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 + with: + packages-dir: dist/ + skip-existing: true + + - name: Publish the root package to PyPI + uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 + with: + packages-dir: root-dist/ + skip-existing: true + + # The same upload against TestPyPI, from its own environment so that its + # OIDC token can never match a production PyPI publisher. Nothing + # downstream needs this job, so a dry run advertises nothing. + publish-testpypi: + name: Publish to TestPyPI + needs: [build, test] + if: ${{ github.event_name == 'workflow_dispatch' && inputs.index == 'testpypi' }} runs-on: ubuntu-latest + environment: + name: testpypi + url: https://test.pypi.org/project/deepctl/ + permissions: + id-token: write steps: - name: Download artifacts uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 @@ -255,13 +532,28 @@ jobs: name: dist path: dist/ - - name: Publish to PyPI + # Root package last, as in publish. + - name: Hold the root package back + run: | + set -euo pipefail + mkdir root-dist + mv dist/deepctl-[0-9]* root-dist/ + ls root-dist/ + + - name: Publish the sub-packages to TestPyPI uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 with: - password: ${{ secrets.PYPI_API_TOKEN }} + repository-url: https://test.pypi.org/legacy/ packages-dir: dist/ skip-existing: true + - name: Publish the root package to TestPyPI + uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 + with: + repository-url: https://test.pypi.org/legacy/ + packages-dir: root-dist/ + skip-existing: true + bump-brew-formula: name: Bump Homebrew formula needs: [release-please, publish, verify-published] diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 44f3d6ed..c81b71d0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,9 +36,13 @@ jobs: - name: Run tests run: | - uv run pytest tests packages/deepctl-core/tests packages/deepctl-shared-utils/tests packages/deepctl-telemetry/tests packages/deepctl-cmd-api/tests packages/deepctl-cmd-login/tests packages/deepctl-cmd-projects/tests packages/deepctl-cmd-transcribe/tests packages/deepctl-cmd-usage/tests packages/deepctl-cmd-debug/tests packages/deepctl-cmd-debug-audio/tests packages/deepctl-cmd-debug-browser/tests packages/deepctl-cmd-debug-network/tests packages/deepctl-cmd-debug-probe/tests packages/deepctl-cmd-ffprobe/tests packages/deepctl-cmd-mcp/tests packages/deepctl-cmd-update/tests packages/deepctl-cmd-plugin/tests packages/deepctl-cmd-skills/tests packages/deepctl-cmd-init/tests packages/deepctl-cmd-models/tests packages/deepctl-cmd-speak/tests packages/deepctl-cmd-keys/tests packages/deepctl-cmd-read/tests packages/deepctl-cmd-listen/tests packages/deepctl-cmd-requests/tests packages/deepctl-cmd-billing/tests packages/deepctl-cmd-members/tests packages/deepctl-cmd-completion/tests packages/deepctl-plugin-example/tests --cov --cov-report=term-missing -v + uv run pytest tests packages/deepctl-core/tests packages/deepctl-shared-utils/tests packages/deepctl-telemetry/tests packages/deepctl-cmd-api/tests packages/deepctl-cmd-login/tests packages/deepctl-cmd-projects/tests packages/deepctl-cmd-transcribe/tests packages/deepctl-cmd-usage/tests packages/deepctl-cmd-debug/tests packages/deepctl-cmd-debug-audio/tests packages/deepctl-cmd-debug-browser/tests packages/deepctl-cmd-debug-network/tests packages/deepctl-cmd-debug-toolkit/tests packages/deepctl-cmd-debug-probe/tests packages/deepctl-cmd-ffprobe/tests packages/deepctl-cmd-mcp/tests packages/deepctl-cmd-update/tests packages/deepctl-cmd-plugin/tests packages/deepctl-cmd-skills/tests packages/deepctl-cmd-init/tests packages/deepctl-cmd-models/tests packages/deepctl-cmd-speak/tests packages/deepctl-cmd-keys/tests packages/deepctl-cmd-read/tests packages/deepctl-cmd-listen/tests packages/deepctl-cmd-requests/tests packages/deepctl-cmd-billing/tests packages/deepctl-cmd-members/tests packages/deepctl-cmd-completion/tests packages/deepctl-plugin-example/tests --cov --cov-report=term-missing -v + # Outside pytest, so conftest.py's telemetry opt-out doesn't apply. - name: Test CLI installation + env: + DEEPCTL_TELEMETRY_DISABLED: "1" + DEEPCTL_TELEMETRY_DSN: "http://k@127.0.0.1:9/1" run: | uv run python -c "from deepctl.main import main; print('Import successful')" uv run deepctl --version @@ -69,8 +73,8 @@ jobs: - name: Type check run: uv run mypy src/ packages/*/src scripts/ - floors: - name: Dependency floors + lockstep: + name: Lockstep versions runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 @@ -83,10 +87,12 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 - # Root floors must equal workspace versions or `pip install --upgrade - # deepctl` (what `dg update` runs) silently skips the packages whose - # fixes the release advertises. See scripts/check_dependency_floors.py. - - name: Check dependency floors + # Every first-party package ships at the root's version: every version, + # every first-party pin `==` it, every package's `deepctl==` back-edge, + # and root lists every package. A mismatch lets pip pair packages from + # two releases. The release PR has to pass it like any other. + # See scripts/check_dependency_floors.py. + - name: Check lockstep versions and pins run: make floors-check build-test: @@ -112,6 +118,39 @@ jobs: - name: Verify packages run: make verify-packages + lowest-direct: + name: Lowest direct dependencies / Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.12"] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: ${{ matrix.python-version }} + + - name: Install uv + uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + with: + version: "0.13.6" + + - name: Build every package + run: uv build --all-packages --wheel --out-dir dist + + # One environment: the whole lockstep closure with every third-party + # dependency at the lowest version the 30 pyprojects together allow. + # The CLI's --help tree and the unit tests run in it; floors that sit + # below what the closure resolves are listed in the log. + - name: Install the closure at its floors, then run the CLI and unit tests + env: + DEEPCTL_TELEMETRY_DISABLED: "1" + DEEPCTL_TELEMETRY_DSN: "http://k@127.0.0.1:9/1" + run: tests/upgrade/lowest_direct.sh dist python + # Single rollup so branch protection needs exactly one required check. # Requiring the matrix contexts directly means editing repo settings every # time a Python version or OS is added or dropped, and a required context @@ -123,7 +162,7 @@ jobs: # cancelled and skipped prerequisites are failures here too. all-checks: name: All checks - needs: [test, lint, floors, build-test] + needs: [test, lint, lockstep, lowest-direct, build-test] if: always() runs-on: ubuntu-latest steps: @@ -131,7 +170,8 @@ jobs: run: | echo "test: ${{ needs.test.result }}" echo "lint: ${{ needs.lint.result }}" - echo "floors: ${{ needs.floors.result }}" + echo "lockstep: ${{ needs.lockstep.result }}" + echo "lowest-direct: ${{ needs.lowest-direct.result }}" echo "build-test: ${{ needs.build-test.result }}" - name: Fail unless every prerequisite succeeded diff --git a/.gitignore b/.gitignore index 77c72295..2fd60e1f 100644 --- a/.gitignore +++ b/.gitignore @@ -164,3 +164,8 @@ tmp_community-turbo/ tmp_deepgram-cli/ # Local Netlify folder .netlify + +# The command manifest, assembled at build time from src/deepctl/_manifest.d/ +src/deepctl/_manifest.json +# Help specs, copied from the fragments at build time +src/deepctl/_help/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 833d5c79..c30f008e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,34 +56,45 @@ dg new-command Or manually create a package under `packages/` following the existing pattern. Every new package needs updates to: -- `packages//pyproject.toml` — package metadata and entry point +- `packages//pyproject.toml` — package metadata and entry point, at the root version - Root `pyproject.toml` — dependency + `[tool.uv.sources]` -- `.github/release-please-config.json` — component definition -- `.github/.release-please-manifest.json` — initial version +- `.github/release-please-config.json` — the package's `pyproject.toml` in the root component's `extra-files` - `.github/workflows/test.yml` — test path -Then run `make readmes` to update all READMEs. - -The root `pyproject.toml` dependency is not optional bookkeeping — it is the -delivery manifest. `pip install --upgrade deepctl` (what `dg update` runs) -only installs what root depends on, so a published package missing from that -list never reaches anyone. `make floors-check` fails on both that omission and -a floor left below the workspace version; run `make floors-fix` to pin floors. +Then run `make floors-fix` (pins and back-edge) and `make readmes`. + +Every first-party package ships at one version (lockstep). The root +`pyproject.toml` pins each package `==` that version and is the delivery +manifest: `pip install --upgrade deepctl` (what `dg update` runs) only installs +what root depends on. Every package pins its first-party dependencies `==` the +same version and declares `deepctl==` it too (the back-edge), so upgrading any +one package moves the whole set or the resolver refuses. Each of those pin +lines carries `# x-release-please-version` so release-please moves it. +`make floors-check` fails on any version or pin that differs, a missing +back-edge, or a package root does not list; `make floors-fix` repairs them. +`deepctl-plugin-example` is the exception: it is the third-party fixture and +versions on its own. The per-package `packages/*/CHANGELOG.md` files stop at +each package's last separate release; the root `CHANGELOG.md` covers every +package from the first lockstep release on. ### Releasing Releases are driven by release-please. Two things are worth knowing before you run one: -**Land your release-notes edits as a commit, not a PR description edit.** -`sync-release-pr` regenerates `uv.lock` and the root dependency floors on the -release branch automatically, but it pushes with `GITHUB_TOKEN`, which by -design retriggers nothing — so the PR's checks still reflect the bot's first -commit and stay red on content that is now correct. Editing `CHANGELOG.md` on -the branch is a commit and retriggers them. Editing the PR *description* does -not: that fires `pull_request: edited`, which is outside the default trigger -types. If a release ever needs to go out unattended, switch that push to a -dedicated PAT or GitHub App token. +**One release PR, one version, one tag.** release-please bumps every +package's version and every marked pin. The release workflow then re-pins the +release branch in two jobs: `sync-release-pr` runs `make floors-fix` and +`uv lock` with no credentials and hands over a patch, and `push-release-pr` +applies it and pushes with the release GitHub App's token, so the required +checks run on the release PR's final head (a `GITHUB_TOKEN` push would +retrigger nothing). A release PR is only green once that push has landed. If +the App isn't set up (the `RELEASE_APP_CLIENT_ID` variable and the +`RELEASE_APP_PRIVATE_KEY` secret of the `release` environment, which only +`main` may deploy to), `push-release-pr` fails at minting the token rather +than leaving the checks stale. Land release-notes edits as a commit on the +branch, not as a PR description edit: a description edit fires +`pull_request: edited`, which is outside the default trigger types. **Nothing advertises a release until it is installable.** `verify-published` polls PyPI until `pip install deepctl==X` resolves its full closure, then diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..241d15cd --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +# The command manifest fragments are build input for the sdist. +graft src/deepctl/_manifest.d diff --git a/Makefile b/Makefile index 49b13b83..aff388c0 100644 --- a/Makefile +++ b/Makefile @@ -143,15 +143,15 @@ verify-packages: ## Verify built packages with twine twine check dist/* readmes: ## Generate sub-package READMEs from pyproject.toml metadata - python3 scripts/generate_readmes.py + uv run python scripts/generate_readmes.py readmes-check: ## Check sub-package READMEs are up to date - python3 scripts/generate_readmes.py --check + uv run python scripts/generate_readmes.py --check -floors-check: ## Check intra-workspace dependency floors (root must pin workspace versions) +floors-check: ## Check lockstep versions: every package at the root version, every first-party pin == uv run python scripts/check_dependency_floors.py -floors-fix: ## Pin root dependency floors to the current workspace versions +floors-fix: ## Set every package version, first-party pin and back-edge to the root version uv run python scripts/check_dependency_floors.py --fix # =================================================================== diff --git a/README.md b/README.md index 1ef3cbd7..919b99e9 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ dg requests --status failed --endpoint listen # Debug failed requests # Hit any endpoint directly dg api /v1/projects --jq '.projects[0].name' -# MCP server for AI editors (Claude Code, Cursor, etc.) +# MCP server (stdio) that lets AI editors search Deepgram's docs dg mcp ``` @@ -268,8 +268,11 @@ dg debug probe --port 3100 # Live stream audio analysis ### MCP Server -Give AI coding assistants (Claude Code, Cursor, etc.) Deepgram documentation -search over stdio. +`dg mcp` runs a local MCP server over stdio for AI coding assistants (Claude +Code, Cursor, etc.). It has one tool, `search_deepgram_knowledge_sources`, +which searches Deepgram's documentation. It uses your API key from `dg login` +or `DEEPGRAM_API_KEY`; don't put the key in the MCP config. SSE and HTTP +transports aren't supported. ```bash dg mcp # Start MCP server (stdio) @@ -406,9 +409,13 @@ CI=1 dg listen recording.wav ``` Also recognised: `--agent-friendly` (alias intended for AI coding tools — same -effect plus JSON metadata output), and the auto-detected env vars -`CLAUDECODE`, `CLAUDE_CODE_ENTRYPOINT`, `CODEX_SANDBOX`, and Aider's -`OR_APP_NAME` / `OR_SITE_URL`. +effect plus JSON metadata output). dg also detects AI coding agents from the +variables they set (`AI_AGENT`, `CLAUDECODE`, `CODEX_SANDBOX`, `GEMINI_CLI` +and others). `dg help agent-mode` prints the full table with its sources, +what your shell matched, and how to turn it off. `DG_AGENT=1` forces agent +mode and `DG_AGENT=0` turns it off for output and prompts; it doesn't change +the config file rules for agent sessions (see `dg help agent-mode`). +`NO_COLOR` only turns color off. ## Plugins @@ -423,8 +430,11 @@ dg plugin remove # Remove Create your own — see the [plugin example](packages/deepctl-plugin-example). -A plugin can't replace a built-in command such as `login` or `debug audio`. -dg runs the built-in and prints a warning that names the plugin's package. +A plugin can't replace a built-in command such as `login` or `debug audio` +(or its hidden alias `debug-audio`). dg runs the built-in without loading the +plugin. The warning that names the plugin's package shows whenever dg looks +for plugins: on `dg --help`, `dg plugin list` and command names dg doesn't +know. Use `dg plugin list` to see refused or shadowing plugins. A plugin whose entry point is named like a built-in command is skipped before it's imported, so none of its code runs; give the entry point a different name. To use a plugin's version anyway, list the command in the user config file @@ -519,7 +529,7 @@ cli/ │ ├── deepctl-cmd-keys/ # API keys management command for deepctl │ ├── deepctl-cmd-listen/ # Listen (live speech-to-text) command for deepctl │ ├── deepctl-cmd-login/ # Login command for deepctl -│ ├── deepctl-cmd-mcp/ # MCP server for deepctl: Deepgram documentation search over stdio +│ ├── deepctl-cmd-mcp/ # Local stdio MCP server for deepctl: search Deepgram's documentation from AI editors │ ├── deepctl-cmd-members/ # Members management command for deepctl │ ├── deepctl-cmd-models/ # Models command for deepctl │ ├── deepctl-cmd-plugin/ # Plugin management command for deepctl @@ -560,7 +570,7 @@ cli/ | `deepctl listen` | Listen (live speech-to-text) command for deepctl | | `deepctl login` | Login command for deepctl | | `deepctl logout` | Login command for deepctl | -| `deepctl mcp` | MCP server for deepctl: Deepgram documentation search over stdio | +| `deepctl mcp` | Local stdio MCP server for deepctl: search Deepgram's documentation from AI editors | | `deepctl members` | Members management command for deepctl | | `deepctl models` | Models command for deepctl | | `deepctl plugin` | Plugin management command for deepctl | @@ -595,7 +605,7 @@ cli/ | [`deepctl-cmd-keys`](packages/deepctl-cmd-keys) | API keys management command for deepctl | | [`deepctl-cmd-listen`](packages/deepctl-cmd-listen) | Listen (live speech-to-text) command for deepctl | | [`deepctl-cmd-login`](packages/deepctl-cmd-login) | Login command for deepctl | -| [`deepctl-cmd-mcp`](packages/deepctl-cmd-mcp) | MCP server for deepctl: Deepgram documentation search over stdio | +| [`deepctl-cmd-mcp`](packages/deepctl-cmd-mcp) | Local stdio MCP server for deepctl: search Deepgram's documentation from AI editors | | [`deepctl-cmd-members`](packages/deepctl-cmd-members) | Members management command for deepctl | | [`deepctl-cmd-models`](packages/deepctl-cmd-models) | Models command for deepctl | | [`deepctl-cmd-plugin`](packages/deepctl-cmd-plugin) | Plugin management command for deepctl | @@ -617,8 +627,8 @@ cli/ Merging [conventional commits](https://www.conventionalcommits.org/) to `main` triggers [release-please](https://github.com/googleapis/release-please) to open -a release PR. Merging that PR creates tags and publishes all changed packages to -PyPI. Each package is versioned independently. +a release PR. Every first-party package is versioned in lockstep: merging that +PR creates one tag and publishes every package at the same version to PyPI. ## Requirements diff --git a/bench/.gitignore b/bench/.gitignore new file mode 100644 index 00000000..33e08786 --- /dev/null +++ b/bench/.gitignore @@ -0,0 +1,2 @@ +out/ +!lib/ diff --git a/bench/README.md b/bench/README.md new file mode 100644 index 00000000..43451007 --- /dev/null +++ b/bench/README.md @@ -0,0 +1,110 @@ +# Benchmark harness + +Measures `dg` against a pinned set of other CLIs and MCP servers, and gates +the results against `gates.json`. Every measured process runs in a Docker +container started with `--network none`; the only privileged container is a +helper that drops the page cache before cold rows. Docker is the only +requirement. Cold rows refuse to start the privileged helper in CI unless the +runner is GitHub-hosted. The orchestrator (`bench.py`) needs Python 3.9+ and only drives Docker. + +```sh +bench/run.sh # dg at HEAD plus every pinned competitor +bench/run.sh --subjects dg --ref v0.4.0 # dg only, any git ref +bench/run.sh --subjects dg,voice --quick # 10 runs per timing row instead of 30 +python3 bench/check.py bench/out//results.json [--baseline bench/baselines/