Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 6 additions & 12 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
381 changes: 55 additions & 326 deletions CHANGELOG.md

Large diffs are not rendered by default.

7 changes: 2 additions & 5 deletions CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 12 additions & 6 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion dacli/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
value, or the patch will not reach it.
"""

__version__ = "0.3.0"
__version__ = "0.1.0"

import os
import random
Expand Down
4 changes: 2 additions & 2 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
145 changes: 145 additions & 0 deletions tools/check_version_sync.py
Original file line number Diff line number Diff line change
@@ -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 <x.y.z>` and `v<x.y.z>` 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())
Loading