diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 33265c9..3579b3f 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -25,7 +25,7 @@ body: attributes: label: da-cli version description: Output of `da --version` - placeholder: "0.3.0" + placeholder: "0.1.0" validations: required: true - type: input diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e32763e..cb73839 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,6 +98,13 @@ jobs: # dropped flag, or a moved file. Link checkers miss these because # they are not links. run: python3 tools/check_doc_references.py + - name: Version is consistent everywhere it appears + # dacli.__version__ is the source of truth; pyproject derives from it + # and the release workflow refuses a tag that disagrees. This asserts + # the hand-maintained links in between — CITATION.cff, the CHANGELOG + # section, the generated CLI reference, and any prose quoting a + # version in sample output. + run: python3 tools/check_version_sync.py - name: Generated docs are current # The CLI reference is generated from build_parser(). If someone # adds a flag without running `make docs`, this fails — which is diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2ac3b76..9f8086f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,18 +69,12 @@ jobs: cache: pip cache-dependency-path: pyproject.toml - - name: Tag must match dacli.__version__ - # The version is `dynamic` from dacli.__version__, so nothing - # otherwise couples it to the tag. Without this check you can ship - # v0.5.0 containing a package that reports 0.4.0, and PyPI will - # cheerfully accept it. - run: | - v=$(python -c 'import dacli; print(dacli.__version__)') - if [ "v$v" != "$GITHUB_REF_NAME" ]; then - echo "::error::tag $GITHUB_REF_NAME does not match dacli.__version__ ($v)" - exit 1 - fi - echo "::notice::tag $GITHUB_REF_NAME matches dacli.__version__" + - name: Tag and package version must agree + # The same check CI runs on every push, plus the tag. Sharing it means + # one definition of "in sync" rather than two that can drift. Without + # it you can ship a tag whose wheel reports a different version — and + # PyPI will not let you re-upload a filename to correct it. + run: python3 tools/check_version_sync.py --tag "$GITHUB_REF_NAME" - name: Build sdist and wheel run: | diff --git a/AGENTS.md b/AGENTS.md index 86c459c..b7f341a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,7 +114,7 @@ da sync watched --full # paranoid full-walk of every watched artist ``` **Auto-bootstrap:** if you already have content on disk but the index -is empty (e.g. first run after upgrading from v0.2.x), the next sync +is empty (e.g. the first run after a fresh install), the next sync imports everything from disk before running. One-time cost, ~10 sec per 10 k items. diff --git a/CHANGELOG.md b/CHANGELOG.md index 18fdf9d..063e209 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,336 +5,65 @@ All notable changes to da-cli are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -Version headings are not linked to release diffs yet: no release has been -tagged, so the `v0.1.0`/`v0.2.0`/`v0.3.0` refs a comparison URL needs do -not exist. The release checklist in -[CONTRIBUTING.md](CONTRIBUTING.md#releases) adds the link definitions -along with the first tag. +The version in `dacli/constants.py` is the single source of truth: +`pyproject.toml` reads it dynamically, the release workflow refuses a tag +that disagrees with it, and PyPI therefore always matches the tag. ## Unreleased -### Added - -- **Auto-recover-on-401**: a new `authed_http_json(url, cfg, state)` - helper wraps `http_json` with on-401-refresh-and-retry-once logic. - Recovers automatically from server-side grant revocation — DA - invalidates a token mid-session (e.g. after a recent re-auth - rotated the refresh_token chain), the cached `expires_at` says it's - still locally valid, the API call returns 401, the helper forces a - fresh token-endpoint exchange and retries. Only if the retry also - 401s does it exit asking for `da auth`. Wired into the two main - sync HTTP calls (browse/deviantsyouwatch, gallery/all). -- `access_token()` gained a `force_refresh: bool = False` keyword to - bypass the cached-token-still-fresh check. -- **CI perf floor**: `da bench --json` runs as a `smoke` step; - fails the build if `items_per_sec` drops below 500. A typical - dev laptop clears 2,500+ items/sec — the floor catches - genuine regressions (accidental O(n²), missed cache, etc) without - flapping on CI noise. -- **Multi-process command lock** — `cmd_sync_feed/artist/watched` - acquire an exclusive POSIX `flock(2)` advisory lock on - `~/.local/state/da-cli/.sync.lock` before doing any work. If - another process (e.g. the launchd 03:00 fire colliding with a - manual run) holds the lock, the second invocation logs - `skipping: another "da sync" is already running` and exits 0. - Cron+manual overlap is now safe — no double-walks of the same - feed page, no state.json races. - - New `_cmd_lock(name)` context manager and `CommandLockedError` - exception. Distinct names ("sync", "bench", etc.) don't conflict. - - 5 tests in `TestCommandLock` (test_integration.py) cover - acquire/release, recursive-acquire-rejected, distinct-name - independence, and that `cmd_sync_*` exits cleanly when locked. - - `cmd_sync_watched` now calls `_cmd_sync_artist_impl` (the - unlocked inner function) for its per-artist iterations — the - outer command already holds the lock, so re-acquiring would - fail. -- **`da diagnose --json`** — machine-readable mode for the - health-check command. Emits a stable schema: - `{timestamp, overall: {status, warnings, criticals}, findings: - [{level, section, message}], exit_code}`. Pipe to `jq` for - monitoring or alerting cron jobs. Suppresses the human-decoration - output (no banner, no `✓/⚠/✗` markers) so stdout is one valid - JSON object. -- **`da diagnose`** — end-to-end self-test that prints a categorized - report of config, auth, index, last-sync, schedule, and TLS-cert - status. Exits 0 if all OK, 1 on warnings, 2 on critical failures - (suitable for shell pipelines and CI). -- **`da bench`** — synthetic feed-sync against a fully mocked HTTP - layer. Measures CLI overhead (parsing, index ops, file IO, - thread-pool dispatch) without touching the network. Args: - `--pages N`, `--per-page N`, `--concurrency N`, `--json`. Reports - pages/sec, items/sec, total elapsed. Stable JSON schema for - perf-regression CI gating. -- **Bounded-concurrency image downloads** — sync feed/artist/watched - now download images in parallel using a thread pool (default 4 - workers per page, configurable via `--concurrency N` / config - field `concurrency`, clamped to [1, 16]). Each worker keeps its - own per-image jitter sleep, so per-image rate limiting is - preserved while page-level wall time drops by ~Nx. Page-level - metadata fetch + feed page fetch remain sequential (they're - rate-limited by DA more strictly than the image CDN). 5 new - tests in `TestConcurrentDownloads` (test_integration.py): result - ordering, index correctness under contention, sequential - fallback at concurrency=1, actual parallelism via thread - inspection, and bounds clamping. -- **Per-run sync summary** persisted to `state.json` under - `last_sync`: `kind`, `started_at`, `ended_at`, `duration_s`, - `totals`, `stop_reason`, plus per-mode extras (artist for - `artist`, via for `watched`, etc.). Read by `da diagnose`. - Survives across runs. -- **`da auth` paste-back mode**: when `--redirect-uri` is - non-loopback (DA now requires HTTPS for non-localhost redirects), - the CLI prints the authorization URL, you authorize in a browser, - and paste back the URL DA redirects you to. The `code` is - extracted from the URL and exchanged for tokens. Force the flow - on a loopback URI with the new `--paste` flag. -- **`tests/test_faults.py`** — comprehensive fault-injection suite - (44 tests). Covers retryable HTTP errors (5xx + network/timeout), - permanent HTTP errors (4xx fail-fast, 429 caller-policy), - exponential-backoff verification, malformed responses - (invalid/empty JSON), image-download retry symmetry, and - per-deviation failure tolerance in concurrent sync (one bad - image doesn't poison its 23 siblings). -- **`tests/test_integration.py`** — comprehensive end-to-end test - suite exercising every CLI command through the argparse → handler - pipeline with HTTP mocked at the `urlopen` level. Covers the - full parser surface (45 invocation shapes), config round-trip, - sync feed/artist/watched happy paths and edge cases (early-stop, - metadata-skip on all-known pages, 429 handling), every search - endpoint (live + deprecated), user/watch/deviation/daily, - index commands, full auth lifecycle (logout/refresh/auto-refresh), - whoami 403 graceful degradation, cross-command round-trip, and - KeyboardInterrupt → exit(130). -- **Open-source readiness**: `CODE_OF_CONDUCT.md`, - `SECURITY.md` at repo root, `.github/ISSUE_TEMPLATE/`, - `.github/PULL_REQUEST_TEMPLATE.md`, `.github/CODEOWNERS`, - `renovate.json` (for automated dev-dep updates), `CITATION.cff`, - a Python 3.10–3.14 matrix in CI, and a `[project.scripts]` - entry point so `pip install da-cli` puts `da` on PATH. -- **Top-tier OSS polish**: - `ARCHITECTURE.md` (vertical-slice map of the 3,000-line dacli.py), - `Makefile` (developer-convenience targets), `.editorconfig`, - `.gitattributes` (LF-only + sdist export-ignore), - `.git-blame-ignore-revs` (format-pass commits excluded from blame), - `.pre-commit-config.yaml` (ruff/mypy/markdownlint/gitleaks), - `.markdownlint.yaml` + `.prettierrc.yaml`, - `AGENTS.md` (renamed from `SKILL.md` per the 2024-2026 convention), - `examples/` (4 executable recipes), - `.github/workflows/ci.yml` now matrix-tests on Linux across - Python 3.10–3.14. -- **Auto-auth research**: documented (now in ADR 0006) - why "store password for daily token" is technically impossible on - DA's OAuth API (`unsupported_grant_type`) and why the 3-month - re-auth ceiling is DA-imposed. `da diagnose` now surfaces the - remaining refresh-token TTL (WARN ≤14 days, FAIL ≤3 days) so - operators aren't surprised by a dead token at 03:00. -- **Exception hierarchy**: `DacliError` (umbrella) + `ConfigError` / - `AuthError` / `HttpError` / `SyncError` subclasses + - `CommandLockedError` re-parented under `DacliError`. Wrappers can - now `except dacli.AuthError` instead of parsing exit codes. -- **`__all__`** declared (45 entries) — the public surface is now - machine-declared; SLF001 lint catches `_private` leaks. -- **`mature_content_param()`** helper replaces 11 inline copies of - `'true' if X.mature else 'false'`. -- **Named constants** for every magic number: HTTP_TIMEOUT_*_S, - HTTP_RETRY_DEFAULT, AUTH_LISTENER_TIMEOUT_S, TOKEN_REFRESH_SKEW_S, - METADATA_BATCH_SIZE, GALLERY_PAGE_CAP, FEED_PAGE_CAP, JITTER_FLOOR_S, - JITTER_MAX_PCT, CONCURRENCY_MIN/MAX, REFRESH_TOKEN_TTL_DAYS, - REFRESH_TOKEN_WARN_DAYS, REFRESH_TOKEN_CRIT_DAYS, etc. -- **Self-signed cert hardening**: RSA 2048 → 3072 (NIST SP 800-57 - 2026 floor), validity 3650d → 825d (macOS notary limit), explicit - `-sha256`. -- **SQLite perf pragmas**: added `synchronous=NORMAL`, `temp_store=MEMORY`, - `cache_size=-65536` (64 MiB), `mmap_size=268435456` (256 MiB). -- **`--unmask` security**: `da config get --unmask` now writes - to stderr so piping stdout to a file can't silently capture the - raw secret. - -### Changed - -- **HTTP retry contract tightened**: - - Only 5xx (500, 502, 503, 504) and network/timeout errors retry. - 4xx including 404, 401, 403, 422 fail fast (they're permanent - for this request shape; retrying just delays the inevitable). - - 429 still fails fast — caller-policy, not http-layer policy. - `cmd_sync_feed` already breaks its loop on 429. - - Backoff is now **exponential with ±10% jitter**: ~base, ~2·base, - ~4·base. Avoids thundering-herd retries when many concurrent - workers hit the same transient failure. - - `http_bytes` (image CDN) follows the same contract. - - New constant `RETRYABLE_HTTP_CODES = {500, 502, 503, 504}` and - helper `_retry_backoff(attempt, base)`. -- **Default `redirect_uri` is now `https://localhost:8765/`** (was - plain HTTP). DA's developer-dashboard whitelist UI rejects HTTP - entries — you'd never be able to whitelist the old default. -- The loopback listener now terminates TLS using a self-signed cert - generated on first use (`openssl req -x509 …`) and stored at - `~/.local/state/da-cli/loopback-{cert,key}.pem` (mode 0600). - Browsers will show a one-time "connection not private" warning - the first time `da auth` runs — click through it. The cert never - leaves your machine and is only used for the localhost callback. - Requires `openssl` on PATH (preinstalled on macOS). -- `install_schedule.sh` now builds a stable-path `.app` bundle at - `~/Applications/da-sync.app` and the launchd plist invokes the - bundle's executable. This lets users grant macOS Full Disk Access - to the bundle (a path you control) instead of the brew-versioned - Python binary, which moves on every `brew upgrade`. Required when - the destination is on `/Volumes/` or any other TCC-protected path - — without it the launchd job hangs in `mkdir` because there's no - UI to surface the permission prompt. -- `install_schedule.sh uninstall` now also removes the bundle. -- README documents the FDA grant step. - -### Removed - -- **`da search popular`** and **`da search newest`** are now - deprecation stubs (exit 2 with an actionable error). DA retired - `/browse/popular` and `/browse/newest` — every variant returns - `HTTP 404 "Api endpoint not found."`. The deprecation message - points at the live alternatives: `da search topic`, - `da search tag`, `da daily`. - -### Fixed - -- **`da sync watched --time-budget` now bounds the whole run**, not each - artist. It previously handed the full budget to every artist in turn, - so `--time-budget 300` across 200 watched artists could still be - running many hours later — for a scheduled job, the flag's entire - purpose defeated. Each artist is now given whatever remains, and once - less than `MIN_ARTIST_BUDGET_S` is left the rest are skipped and - recorded as not attempted. -- **A sync stopped by the clock no longer records `stop_reason: - "complete"`.** Both walks initialise the reason to `"complete"` and - every early exit overwrites it, so a run truncated by the time budget - was indistinguishable from a finished one. It now records - `"time budget exhausted"`. -- **`da diagnose` no longer reports every last sync as `ok`.** The level - was hardcoded, so a truncated walk, an HTTP 429 abort and a clean pass - all looked identical to a monitor — a nightly job that never finished - appeared healthy indefinitely. Anything that is not a clean finish, or - that had per-item failures, is now a warning (never a failure: each of - these resolves itself on the next run). -- **`da sync watched` no longer exits 0 when artists fail.** It exits 1 - on partial failure and 2 when every artist failed, matching the - documented `da sync ... || notify` pattern. -- **`da search user`** now uses POST against `/user/whois` with - `usernames[]=` form fields, matching DA's actual API. The previous - GET request returned HTTP 400. `http_post_json` gained an optional - `token` parameter so the handler can attach the Bearer token. -- Auth listener now uses `SO_REUSEADDR` to avoid `Address already - in use` if a prior run is still in `TIME_WAIT`. -- Auth listener is bound BEFORE `webbrowser.open` is invoked so a - fast browser redirect can't race the bind. -- `da auth` validates the redirect URI's scheme and host before - binding (rejects unparsable URIs cleanly instead of falling into - paste mode). -- **A single stalled connection no longer wedges the `da auth` listener.** - The loopback server was a single-threaded `TCPServer`, so a client that - opened a socket and sent nothing held the only slot and the real OAuth - callback was never accepted — `da auth` waited out its full timeout and - failed. Browsers open speculative preconnects, so this was reachable in - ordinary use. The server now threads, each connection carries - `AUTH_CONNECTION_TIMEOUT_S`, and the TLS handshake happens in the - per-connection worker rather than on the accept path (wrapping the - listening socket put a blocking handshake back in `accept()`). -- **A corrupt loopback cert is now replaced instead of used.** - `_ensure_self_signed_cert` returned any pair that merely existed, so a - truncated or stray-written cert made `da auth` fail with a bare - `[SSL] PEM lib` forever, with nothing to suggest that deleting two - files would fix it. The pair is now load-tested and regenerated when - unusable, and a generated-but-unloadable pair reports what to do. -- **The test suite no longer writes into the real state directory.** - `LOOPBACK_CERT` / `LOOPBACK_KEY` are derived from `STATE_DIR` at import - time, so redirecting `STATE_DIR` alone left them aimed at - `~/.local/state/da-cli`. Two tests wrote 4-byte stubs there, which then - triggered the `[SSL] PEM lib` failure above on the developer's own - machine. - -## 0.3.0 — 2026-04-26 - -### Added - -- **Synced-deviation index** (`~/.local/state/da-cli/index.db`, - SQLite/WAL, mode 0600). Primary key on `deviationid`, secondary - index on `(artist, synced_at DESC)`. O(1) membership tests and - bulk filtering — replaces per-deviation disk stats. -- **Per-artist early-stop** in `sync artist`: gallery walks now - exit as soon as they hit a known deviationid (gallery is returned - reverse-chronologically, so subsequent pages are guaranteed dups). - A no-change second-day run is **one API call**, not the full gallery. -- **Page-level skip** in `sync feed`: if every id on a page is - already in the index, the metadata batch is skipped entirely. -- **Auto-bootstrap**: if the index is empty but the destination has - content, the next sync walks the disk to populate the index - (one-time cost, idempotent). -- `da index show` — print row count, top artists, db size. -- `da index rebuild` — walk destination and re-import. Idempotent. -- `--full` flag on `sync artist` and `sync watched` — disable the - early-stop for paranoid backfills or after content rotation. - -### Changed - -- `_save_one()` now consults the index first (O(1)) before any disk - stat. Disk hit triggers a lazy backfill into the index. -- Default `install_schedule.sh` cadence is now **daily at 03:00** - (StartCalendarInterval), with `DA_HOUR`/`DA_MINUTE` overrides. - `DA_INTERVAL_SECONDS` still works for fixed-interval scheduling. -- Default jitter on the scheduled `sync feed` run is now `0.4` - (was 0). Smooths the request cadence. -- `__version__` bumped to `0.3.0`. - -## 0.2.0 — 2026-04-26 - -### Added - -- `--jitter` flag (and `DA_JITTER` env / `jitter` config field) to add - human-like fuzziness to API and image-download delays. Range: - `0.0`–`0.95`; defaults to `0` (deterministic). -- `sync watched --via-feed` mode: discover watched artists by walking - `/browse/deviantsyouwatch` instead of `/user/friends/{me}`. Lets users - with `browse`-scope tokens sync their watch list without re-auth for - `user` scope. -- `sync watched --feed-max `: cap the feed-discovery walk at N - deviations (default 2000). -- `daily [YYYY-MM-DD]`: pull a single day's Daily Deviations. -- `auth logout`: deletes the local state file. -- `config get [--unmask]` and `config unset` for round-tripping the - config store from the CLI. -- `whoami` now degrades gracefully when the token lacks `user` scope. -- Atomic file writes (tmp → rename, fsync) for `state.json`, - `config.json`, and `description.json`. -- Dedup-aware folder resolution: title collisions append the - `deviationid` suffix instead of overwriting. -- Comprehensive test suite (158 tests, 90%+ coverage) under `tests/`. -- Strict `ruff` + `mypy` configuration in `pyproject.toml`. -- Documentation: `README.md`, `docs/explanation/security.md`, this changelog, - `CONTRIBUTING.md`. -- CI workflow at `.github/workflows/ci.yml` for GitHub Actions. - -### Changed - -- `log()` now uses `flush=True`, so background runs under `nohup` / - launchd produce real-time log output instead of full-buffered chunks. -- `safe_filename()` now collapses runs of unsafe chars to a single - underscore and strips leading/trailing underscores. -- Config priority is now: CLI flag > env var > macOS Keychain (secrets) - > `~/.config/da-cli/config.json`. Previously, secrets-on-disk took - precedence over Keychain. - -### Fixed - -- `cmd_whoami` no longer crashes on a 403 from the `whoami` endpoint - when the token has only `browse` scope. -- Half-written `image.{ext}.part` files are now treated as in-progress - rather than complete; sync restarts the download instead of skipping. +## 0.1.0 — 2026-07-30 -## 0.1.0 — 2026-04-22 +First public release. ### Added -- Initial release: `auth`, `whoami`, `refresh`, `sync feed`, - `sync artist`, `sync watched`, `search popular|newest|tag|user`, - `user profile`, `watch list`, `deviation show`, `config show|set`. -- OAuth 2.1 + PKCE flow with loopback redirect. -- macOS Keychain integration for `client_secret` storage. -- XDG-compatible config and state paths. +- **`da sync`** — three ways to walk DeviantArt and save what you have not + got yet. `sync feed` follows your watch feed from the top and stops at + the checkpoint the previous run left, so a quiet day costs one API call. + `sync artist` walks one gallery newest-first. `sync watched` discovers + everyone you watch and runs the artist walk for each under one shared + time budget. +- **Resumable walks.** Each artist's position is checkpointed in + `state.json`, so a run cut short by its time budget resumes where it + stopped rather than starting over. +- **A local SQLite index** of what has been downloaded, so re-runs do not + re-fetch. Self-healing: a row whose folder has gone is dropped, and + `da index rebuild` reconstructs the whole index from disk without + re-downloading anything. +- **OAuth 2.1 with PKCE**, against a loopback HTTPS listener with a + self-signed certificate generated on first run. The `client_secret` is + optional — DeviantArt's own guidance for desktop apps is a public client + with PKCE and no secret. Where one is used on macOS it lives in the + Keychain, not on disk. +- **Scheduled syncs** — `install_schedule.sh` writes a launchd agent on + macOS; a systemd user timer is documented for Linux. +- **`da search` / `da user` / `da deviation` / `da daily`** — read-only + browse helpers for tags, topics, daily deviations, profiles and + metadata. Thirteen commands accept `--json` for scripting. +- **`da diagnose`** — one command that checks every layer that can quietly + break an unattended run: config, destination writability and free space, + token expiry and scope, index drift, and whether the launchd job is + loaded. +- **`da auth status`** — a small JSON object plus an exit code, for cron + and monitoring wrappers. +- **Zero runtime dependencies.** The whole tool is the Python 3.10+ + standard library. The CI `artifact` job installs the built wheel into a + clean virtualenv and imports every submodule, so the claim is tested + rather than asserted. +- **Typed.** Ships `py.typed`; `mypy` runs in CI. + +### Security + +- Credentials never land in a world-readable file: config, state, the + index and both lock files are created `0600`, and the loopback TLS key + is generated inside a `0700` directory so it is never briefly readable. +- The OAuth flow generates and verifies a `state` parameter + (RFC 6749 §10.12) and accepts a callback on the expected path only. +- Debug output (`-v`) passes every URL through a redactor covering + `access_token`, `client_secret`, `code` and `token` — the last because + the image CDN signs every content URL with a live JWT. +- `da config show` masks secrets; `--unmask` writes to stderr so + redirecting stdout cannot capture the value. + +See [SECURITY.md](SECURITY.md) for the threat model, including what this +explicitly does not protect against. diff --git a/CITATION.cff b/CITATION.cff index 35976bf..b6c1a7f 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -11,11 +11,8 @@ authors: alias: FZ2000 type: software license: MIT -version: "0.3.0" -# date-released is deliberately omitted: it is not a required CFF field -# and no release has ever been tagged (`git tag` is empty), so a date -# here would assert a release that does not exist. Add it with the -# first real tag — see CONTRIBUTING.md#releases. +version: "0.1.0" +date-released: "2026-07-30" repository-code: "https://github.com/FZ2000/da-cli" url: "https://github.com/FZ2000/da-cli" keywords: diff --git a/README.md b/README.md index 974b6a1..d8382eb 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Sync your DeviantArt gallery to a local folder from the command line — a backup of the art you watch, kept current. **Zero runtime dependencies**: the whole tool is the Python 3.10+ standard library. A local SQLite index means a re-run costs one API call when nothing new was posted, and `launchd` (macOS) or a systemd timer (Linux) keeps it running unattended. Plus search and browse helpers. > **New to da-cli?** Follow the **[Setup Guide](docs/getting-started.md)** — it walks you through everything from install to first sync in about 10 minutes, with screenshots. -> **Status: Beta (v0.3.x)** — core sync + search flow is stable. macOS Keychain integration is production-ready; Linux Secret Service support is planned. See [CHANGELOG.md](CHANGELOG.md) for details. +> **Status: Beta** — the sync and search flows are stable and covered by 868 tests. macOS Keychain integration is production-ready; Linux Secret Service support is planned. See [CHANGELOG.md](CHANGELOG.md). ## Documentation diff --git a/SECURITY.md b/SECURITY.md index 23e7f9a..5524a20 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,13 +2,19 @@ ## Supported versions -Security fixes are applied to the latest released version on the `main` -branch only. +Security fixes land on `main` and go out in the next release. There is no +backport branch: this is a single-maintainer project, and promising +backports it cannot deliver would be worse than saying so. -| Version | Supported | -| ------- | ------------------ | -| latest main | :white_check_mark: | -| older tags | :x: | +| Version | Supported | +| --- | --- | +| `main` | ✅ fixes land here first | +| latest release | ✅ | +| any earlier release | ❌ upgrade | + +Practically: if you are on the latest release or on `main`, report it and +it will be fixed. If you are on an older one, the first step is to +upgrade — the fix will not be backported. ## Reporting a vulnerability diff --git a/dacli/constants.py b/dacli/constants.py index 0de14f6..135dc18 100644 --- a/dacli/constants.py +++ b/dacli/constants.py @@ -11,7 +11,7 @@ value, or the patch will not reach it. """ -__version__ = "0.3.0" +__version__ = "0.1.0" import os import random diff --git a/docs/getting-started.md b/docs/getting-started.md index d592325..0f0afd5 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -73,10 +73,10 @@ installed: ~/.local/share/da-cli/dacli/ ~/.local/bin/da -> ~/.local/share/da-cli/da -da-cli 0.3.0 +da-cli 0.1.0 ``` -**If you see `da-cli 0.3.0`** — installation worked. Move to Step 3. +**If you see a version number** — installation worked. Move to Step 3. **If you see "da: command not found"** — your system doesn't know where to find `da`. Fix it by adding the install directory to your diff --git a/docs/reference/cli.md b/docs/reference/cli.md index ca46a5f..9a5d5bf 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -3,7 +3,7 @@ # Command reference -Every `da` command and flag, generated from `build_parser()` in the `dacli` package (version 0.3.0). +Every `da` command and flag, generated from `build_parser()` in the `dacli` package (version 0.1.0). For settings that live in a config file rather than on the command line, see [configuration](configuration.md). For what each exit code means, see [exit codes](exit-codes.md). diff --git a/tools/check_version_sync.py b/tools/check_version_sync.py new file mode 100644 index 0000000..51ee29f --- /dev/null +++ b/tools/check_version_sync.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""One version, asserted everywhere it appears. + +`dacli.__version__` is the single source of truth. Everything downstream is +supposed to derive from it: + + dacli/constants.py __version__ + -> pyproject.toml version = { attr = "dacli.__version__" } + -> the built wheel and sdist + -> what PyPI shows + and, at release time, the git tag `v{__version__}` + +That chain is only as good as its weakest hand-maintained link, and there +are several: CITATION.cff carries a literal, CHANGELOG.md needs a matching +section, docs/reference/cli.md embeds it (generated, but the generated file +is committed), and prose in README/docs quotes it in sample output. + +Every one of those was wrong at some point before this check existed — +`docs/reference/cli.md` said 0.3.0 while the package said something else, +and the issue-template placeholder had drifted too. A mismatch between the +tag and the package is the one that actually hurts: it puts a wheel on PyPI +reporting a version nobody can find on GitHub, and PyPI will not let you +re-upload the same filename to fix it. + + python3 tools/check_version_sync.py # files only + python3 tools/check_version_sync.py --tag v0.1.0 # also check a tag + +The release workflow runs the `--tag` form against the pushed tag before it +builds anything. +""" + +from __future__ import annotations + +import argparse +import pathlib +import re +import subprocess +import sys + +REPO = pathlib.Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO)) + +import dacli # noqa: E402 (needs the sys.path line above) + +VERSION = dacli.__version__ + + +def problems() -> list[str]: + out: list[str] = [] + + # The version must look like a version, or every comparison below is + # comparing typos. + if not re.fullmatch(r"\d+\.\d+\.\d+([abrc]|rc|\.post|\.dev)?\d*", VERSION): + out.append(f"dacli.__version__ is not a PEP 440 release version: {VERSION!r}") + + # pyproject must DERIVE the version, never restate it. A literal here is + # the classic way the wheel and the package disagree. + pyproject = (REPO / "pyproject.toml").read_text() + if not re.search(r'version\s*=\s*\{\s*attr\s*=\s*"dacli\.__version__"\s*\}', pyproject): + out.append("pyproject.toml does not read the version from dacli.__version__") + if re.search(rf'^\s*version\s*=\s*"{re.escape(VERSION)}"', pyproject, re.MULTILINE): + out.append("pyproject.toml hardcodes the version; it must stay dynamic") + + # CITATION.cff is a literal by necessity — GitHub's citation widget reads + # it statically — so it has to be checked rather than derived. + cff = (REPO / "CITATION.cff").read_text() + m = re.search(r'^version:\s*"?([^"\n]+)"?', cff, re.MULTILINE) + if not m: + out.append("CITATION.cff has no version field") + elif m.group(1).strip() != VERSION: + out.append(f"CITATION.cff version is {m.group(1).strip()!r}, expected {VERSION!r}") + + # A release with no changelog entry is a release nobody can read. + changelog = (REPO / "CHANGELOG.md").read_text() + if not re.search(rf"^## \[?{re.escape(VERSION)}\]?[^\n]*$", changelog, re.MULTILINE): + out.append(f"CHANGELOG.md has no '## {VERSION}' section") + + # Generated, but committed — so it can be stale in exactly the way the + # generator exists to prevent. + cli_doc = REPO / "docs" / "reference" / "cli.md" + if cli_doc.exists(): + found = re.search(r"\(version ([^)]+)\)", cli_doc.read_text()) + if found and found.group(1) != VERSION: + out.append( + f"docs/reference/cli.md says version {found.group(1)}; run tools/gen_cli_docs.py" + ) + + # Any other file quoting a DIFFERENT release version in prose. Scoped to + # `da-cli ` and `v` so it cannot trip on the pinned versions + # of third-party tools, which are unrelated and legitimately differ. + tracked = subprocess.run( + ["git", "-C", str(REPO), "ls-files"], capture_output=True, text=True, check=False + ).stdout.split() + if not tracked: + out.append("git listed no files — not a work tree, so nothing was scanned") + for rel in tracked: + if rel.startswith(("tests/integration/cassettes/", "CHANGELOG.md")): + # Cassettes record a request as it was made at capture time; that + # is a historical artifact, and the replay matcher ignores the + # User-Agent header anyway. CHANGELOG legitimately names old ones. + continue + p = REPO / rel + if not p.is_file() or p.suffix in {".png", ".jpg", ".gif", ".db"}: + continue + try: + body = p.read_text() + except (UnicodeDecodeError, OSError): + continue + for i, line in enumerate(body.splitlines(), 1): + out.extend( + f"{rel}:{i}: quotes da-cli {other}, but the package is {VERSION}" + for other in re.findall(r"\bda-cli[ /]v?(\d+\.\d+\.\d+)\b", line) + if other != VERSION + ) + return out + + +def check_tag(tag: str) -> list[str]: + """The tag and the package must agree, or PyPI gets an unfindable wheel.""" + if tag != f"v{VERSION}": + return [f"tag {tag!r} does not match dacli.__version__ (expected 'v{VERSION}')"] + return [] + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--tag", help="also assert this git tag matches the package version") + args = ap.parse_args() + + found = problems() + if args.tag: + found += check_tag(args.tag) + + for f in found: + print(f" {f}") + if found: + print(f"\n{len(found)} version-sync problem(s).", file=sys.stderr) + return 1 + scope = f"{VERSION} (tag {args.tag} ok)" if args.tag else VERSION + print(f"version {scope} is consistent across pyproject, CITATION.cff, CHANGELOG and docs") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())