Building and releasing
Requirements
Section titled “Requirements”| Tool | Version |
|---|---|
| Python | 3.12 or newer |
| uv | latest |
| Node.js | 20 or newer — only for the desktop app |
| npm | 10 or newer — only for the desktop app |
Setting up
Section titled “Setting up”git clone https://github.com/elshadaghazade/diskgoupcd diskgoupuv syncuv sync creates the virtual environment and installs the runtime
dependencies plus the development group:
| Group | Packages |
|---|---|
| Runtime | typer rich psutil platformdirs send2trash watchdog jinja2 |
| Development | pytest pytest-cov ruff mypy pyinstaller and the type stubs |
Then check it works:
uv run diskgoup --versionWithout uv
Section titled “Without uv”python3.12 -m venv .venvsource .venv/bin/activatepip install -e ".[dev]"pip install -e . installs the package in editable mode and puts the
diskgoup command on your path.
Running the tests
Section titled “Running the tests”uv run pytestThe suite is configured in pyproject.toml:
[tool.pytest.ini_options]testpaths = ["tests"]addopts = "-q --strict-markers"markers = ["slow: long-running tests"]--strict-markers means an unknown marker is an error rather than a
warning — a typo in @pytest.mark.slow fails instead of silently running
the test unmarked.
With coverage:
uv run pytest --cov=diskgoup --cov-report=term-missingThe slow tests are marked and can be skipped:
uv run pytest -m "not slow"Linting and type checking
Section titled “Linting and type checking”uv run ruff check .uv run ruff format .uv run mypyThe configuration is strict:
| Tool | Setting |
|---|---|
ruff |
line length 100, target py312, rules E F W I N UP B C4 SIM RUF ANN |
mypy |
strict = true over the diskgoup package |
ruff’s bugbear integration knows about Typer:
[tool.ruff.lint.flake8-bugbear]extend-immutable-calls = ["typer.Argument", "typer.Option"]Without it, B008 would flag every typer.Option(...) default as a
“function call in a default argument” — which is exactly what Typer’s
decorators are.
Building the binary
Section titled “Building the binary”uv run pyinstaller diskgoup.specThe result is a single self-contained executable:
| Platform | Path |
|---|---|
| macOS, Linux | dist/diskgoup |
| Windows | dist/diskgoup.exe |
The spec bundles the Jinja2 templates and the send2trash data files, and
lists the modules PyInstaller cannot see by itself as hiddenimports:
datas = [ (str(SRC / "diskgoup" / "templates"), "diskgoup/templates"), *collect_data_files("send2trash"),]Excluded, to keep the binary small:
excludes = ["tkinter", "unittest", "pydoc_data", "test", "pytest", "IPython", "notebook"]Building the desktop app
Section titled “Building the desktop app”The Electron app is built separately from the Python one.
# 1. the UInpm --prefix ui installnpm --prefix ui run build
# 2. the Electron shellnpm --prefix electron installnpm --prefix electron run buildelectron/scripts/build-cli.mjs builds the Python binary and puts it in
electron/resources/, where the development resolver looks for it.
Packaging
Section titled “Packaging”npm --prefix electron run dist:mac # arm64 + x64npm --prefix electron run dist:mac:arm64npm --prefix electron run dist:mac:x64npm --prefix electron run dist:win # x64npm --prefix electron run dist:linux # AppImage + tar.gzThe output lands in electron/release/.
| Platform | Format | What it is |
|---|---|---|
| macOS | .dmg |
the installer — drag the app into Applications |
| macOS | .zip |
portable — unzip and run DiskGOup.app from anywhere |
| Windows | NSIS installer .exe |
the installer |
| Windows | portable .exe |
portable — it unpacks itself and runs, no installation |
| Linux | AppImage |
one file, needs the executable bit |
| Linux | tar.gz |
portable — the unpacked folder, works without FUSE |
Both kinds are built by the same electron-builder run: the target list of
each platform in electron/package.json names them, and one dist:* command
produces all of them. The artifact names carry ${ext}, because one name
pattern covers every target of a platform.
The macOS build signs the bundle with an ad-hoc identity (identity: "-")
and the hardened runtime, with the entitlements in electron/build/. That
seals the app — so it is not a damaged file — but it proves nothing about who
built it, so Gatekeeper still asks on another machine. The packaging fails if
the signature does not verify, so a broken seal can never be shipped.
To sign it properly, add these repository secrets; the release workflow picks them up on its own:
| Secret | What it is |
|---|---|
MACOS_CERTIFICATE |
the Developer ID Application certificate (.p12), base64-encoded |
MACOS_CERTIFICATE_PASSWORD |
that certificate’s password |
APPLE_ID |
the Apple ID that owns the team |
APPLE_APP_SPECIFIC_PASSWORD |
an app-specific password for that Apple ID |
APPLE_TEAM_ID |
the 10-character team ID |
With them in place the app is signed with the Developer ID and notarized
before the .dmg is built, and macOS opens it without asking anything.
Releasing
Section titled “Releasing”Merging into main is the release. There is no command to run and no tag
to push — the workflow does it.
| Workflow | When it runs | What it does |
|---|---|---|
ci.yml |
on every pull request and on main |
the CLI’s tests and types, the desktop app’s types, the documentation build |
release.yml |
on every merge into main |
works out the version, writes it into the project, tags it and starts the two builds below |
release-cli.yml |
started by release.yml |
the CLI binary for macOS (arm64 + x64), Linux (x64 + arm64) and Windows (x64) |
release-desktop.yml |
started by release.yml |
the desktop app: a .dmg and a portable .zip for macOS, an installer .exe and a portable .exe for Windows, an .AppImage and a portable .tar.gz for Linux |
The steps, in short:
- The version. The first release publishes the version in
pyproject.toml. After that the bump follows what was merged: afeat:commit means a minor release, a breaking change a major one, anything else a patch.scripts/next-version.pyis where that rule lives. - The tag is the version —
scripts/set-version.pywrites it into the Python package and bothpackage.jsonfiles, so the artifact and the tag always agree. The version is committed tomainand taggedv0.2.0. - The builds. The CLI is built with
uv run pyinstaller diskgoup.specand smoke-tested withdiskgoup --version; the desktop app is packaged withelectron-builder, with the CLI binary bundled into it. - The files are uploaded to the release under fixed names — the
documentation links to them with
releases/latest/download/<name>, so those links never break. - The documentation follows in the same commit:
site/scripts/update-downloads.pyupdates the version on the downloads page, so a release and the documentation never drift apart. Nothing is left to merge by hand.
Two ways to cut a release by hand, if you want one:
# a version of your own choosing — Actions → Cut a release → Run workflowgh workflow run release.yml -f version=0.3.0
# or a tag, which builds exactly the same thinggit tag -a v0.3.0 -m "DiskGOup 0.3.0"git push origin v0.3.0The repository layout
Section titled “The repository layout”diskgoup/├── src/diskgoup/ the Python package│ ├── cli.py the Typer app│ ├── commands/ one module per CLI area│ ├── core/ the engine│ ├── templates/ the Jinja2 report templates│ └── util/ formatting helpers├── electron/ the Electron main process│ ├── src/ main.ts, backend.ts, preload.ts│ └── scripts/ build-cli.mjs, build-assets.mjs, verify-signature.mjs├── ui/ the React renderer│ ├── src/components/ one per tab│ └── src/lib/ the API client and the shared types├── tests/ the Python test suite├── diskgoup.spec the PyInstaller spec├── pyproject.toml the Python project└── docs/ the project's own notesThe Python package lives under src/, so the import path is
diskgoup.* — the same as it is on PyPI.
Before you commit
Section titled “Before you commit”uv run ruff check .uv run mypyuv run pytestnpm --prefix ui run typecheckThe UI has its own type check, separate from the build:
npm --prefix ui run typecheckSee also
Section titled “See also”- Contributing — the workflow.
- The desktop app — how the two halves fit together.