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
107 changes: 105 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -130,11 +130,92 @@ jobs:
if-no-files-found: error
retention-days: 7

# The pip/uv install path. The wheel is pure Python — the frontend and the
# backend ride along as package data — so it is built once rather than per
# platform, and it deliberately runs on dry runs too, for the same reason the
# matrix does: a tag should never be the first execution of this path.
wheel:
name: Build the Python distribution
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: '3.11'

- name: Build sdist and wheel
run: |
python -m pip install --upgrade pip build twine
python -m build
shell: bash

# PyPI refuses a version it has already seen, and a filename is never
# reusable even after a delete. A tag that disagrees with VERSION would
# therefore burn the version rather than fail, so it is caught up front.
- name: Check the tag matches VERSION
if: github.event_name == 'push' || inputs.publish
run: |
tag="${{ inputs.tag || github.ref_name }}"
version=$(tr -d '[:space:]' < VERSION)
if [ "$tag" != "v$version" ]; then
echo "Tag '$tag' disagrees with VERSION '$version' (expected 'v$version')." >&2
echo "Run scripts/bump_version.py so the two cannot drift." >&2
exit 1
fi
shell: bash

# PyPI renders the README and rejects markup it cannot parse. Without this
# the first sign of trouble is a failed upload, after the release is cut.
- name: Check the metadata
run: twine check dist/*
shell: bash

# A broken force-include in pyproject.toml yields a wheel that builds,
# installs and runs — serving nothing. See the script's docstring.
- name: Check the wheel carries the app
run: python scripts/check_wheel.py dist/*.whl
shell: bash

# Installed from the built wheel rather than the checkout, so this
# exercises what users actually get: console script, package data, the
# frontend served off the installed tree.
- name: Smoke test the installed wheel
run: |
python -m venv /tmp/wheeltest
/tmp/wheeltest/bin/pip install --quiet dist/*.whl
AMICOSCRIPT_NO_BROWSER=1 /tmp/wheeltest/bin/amicoscript &
pid=$!
for _ in $(seq 1 90); do
if curl -sf -o /dev/null http://127.0.0.1:8002/; then break; fi
sleep 1
done
status=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8002/)
kill $pid 2>/dev/null || true
if [ "$status" != "200" ]; then
echo "Installed wheel did not serve the frontend (HTTP $status)." >&2
exit 1
fi
echo "Installed wheel served the frontend."
shell: bash

- name: Hand the distribution to the publish job
uses: actions/upload-artifact@v4
with:
name: python-dist
path: dist/*
if-no-files-found: error
retention-days: 7

release:
name: Publish release
needs: build
needs: [build, wheel]
runs-on: ubuntu-latest
# Every platform must have built. A release missing an artifact is the
# Every platform must have built, and so must the wheel — otherwise the
# release publishes, `pypi` skips on the failed dependency, and the version
# exists on GitHub but not on PyPI. A release missing an artifact is the
# failure this job exists to prevent, so a partial one is not published.
if: github.event_name == 'push' || inputs.publish
steps:
Expand Down Expand Up @@ -176,3 +257,25 @@ jobs:
artifacts: artifacts/*.zip
allowUpdates: true
skipIfReleaseExists: false

# Last, and needing `release`, on purpose: a PyPI upload cannot be undone —
# the version is spent even if the files are deleted — so nothing goes out
# until the GitHub release it accompanies exists.
pypi:
name: Publish to PyPI
needs: [wheel, release]
runs-on: ubuntu-latest
permissions:
# Trusted publishing: PyPI verifies this workflow's OIDC identity, so
# there is no API token to store or rotate. One-time setup on the PyPI
# side is documented in docs/pypi-release.md.
id-token: write
steps:
- name: Collect the distribution
uses: actions/download-artifact@v4
with:
name: python-dist
path: dist

- name: Publish
uses: pypa/gh-action-pypi-publish@release/v1
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,42 @@ UI's Help modal also has a one-click "Copy command" for this.
pytest -q
```

## 📦 Install

One command, identical on macOS, Windows and Linux:

```bash
uv tool install amicoscript
amicoscript
```

`uv` fetches a suitable Python itself, so nothing needs to be installed first.
([Don't have uv?](https://docs.astral.sh/uv/getting-started/installation/) —
`pipx install amicoscript` works the same way.) To run it once without
installing: `uvx amicoscript`.

This is the recommended route, and not only for convenience: nothing here is a
downloaded application, so neither Gatekeeper nor SmartScreen is involved. The
zips below are unsigned and both will object to them.

Speaker diarization is an optional extra, since transcription never needs
torch:

```bash
uv tool install "amicoscript[diarization]"
```

On **Linux**, that pulls PyPI's default torch, which is the CUDA build and
several GB of `nvidia-*` packages with it. For a CPU-only machine, name the CPU
index — torch comes from there, everything else still comes from PyPI:

```bash
uv tool install "amicoscript[diarization]" \
--index https://download.pytorch.org/whl/cpu
```

Upgrade with `uv tool upgrade amicoscript`.

## 🏃🏼 Running from the installer
In the [releases](https://github.com/sim186/AmicoScript/releases) page you can download the application for Windows or Mac (Linux is coming). Be careful that the .exe (or. the dmg) might be recognized as suspicious by the OS.

Expand All @@ -208,6 +244,10 @@ work offline.

### macOS: Running unsigned apps (Not disabling Gatekeeper)

This applies to the downloaded `.app` only. `uv tool install amicoscript`
(above) skips all of it — Gatekeeper gates downloaded applications, and a wheel
installed into your own environment is not one.

1. Download the latest release from the Releases page.
2. Because the app is not signed by Apple, macOS will initially block it. Open System Settings → Privacy & Security and enable "App Store and identified developers" (allow apps downloaded from App Store and identified developers).
3. Unzip the downloaded file. Double-click the application file (`AmicoScript.app`). macOS will prevent it from opening because it's from an unidentified developer.
Expand Down
38 changes: 38 additions & 0 deletions amicoscript/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Distribution shim for the `amicoscript` wheel.

Only this file and `cli.py` live in the repo. The wheel is assembled by
hatchling (see `pyproject.toml`), which copies `backend/`, `frontend/`,
`scripts/meeting_watcher/` and `run.py` in beside them so the installed package
has the same shape the repo root does. That is the whole trick: `run.py` and
`backend/main.py` already locate their siblings relative to `__file__`, so
neither needs a packaging-specific code path.
"""
from __future__ import annotations

__all__ = ["__version__", "main"]


def _detect_version() -> str:
from importlib.metadata import PackageNotFoundError, version

try:
return version("amicoscript")
except PackageNotFoundError:
# Running from a source checkout that was never installed.
from pathlib import Path

try:
return (Path(__file__).resolve().parents[1] / "VERSION").read_text(
encoding="utf-8"
).strip()
except OSError:
return "0.0.0"


__version__ = _detect_version()


def main() -> int:
from .cli import main as _main

return _main()
54 changes: 54 additions & 0 deletions amicoscript/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""The `amicoscript` console script.

`run.py` is the launcher for every other distribution channel too (`python
run.py`, the PyInstaller bundle), so this module does not reimplement it — it
locates the file and calls its `main()`.
"""
from __future__ import annotations

import importlib.util
import sys
from pathlib import Path

_HERE = Path(__file__).resolve().parent


def _launcher_path() -> Path:
"""Find run.py, whether we are installed or in a checkout."""
# Installed wheel: hatchling put run.py here as _run.py, next to backend/.
installed = _HERE / "_run.py"
if installed.is_file():
return installed
# Source checkout (including `pip install -e .`): the shim package sits at
# <repo>/amicoscript/, so run.py is one level up.
source = _HERE.parent / "run.py"
if source.is_file():
return source
raise ModuleNotFoundError(
"Could not locate the AmicoScript launcher (run.py). The installation "
"looks incomplete — try reinstalling the amicoscript package."
)


def _load_launcher():
path = _launcher_path()
# Loaded by path rather than imported by name so that `__file__` stays the
# real location: run.py derives BASE_DIR from it, and everything the app
# serves — backend modules, the frontend, meeting_watcher — hangs off that.
spec = importlib.util.spec_from_file_location("amicoscript._run", path)
if spec is None or spec.loader is None:
raise ImportError(f"Could not load the AmicoScript launcher from {path}")
module = importlib.util.module_from_spec(spec)
# Registered before exec_module so a re-entrant import gets the same module
# rather than running run.py's import-time side effects a second time.
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module


def main() -> int:
return _load_launcher().main()


if __name__ == "__main__":
sys.exit(main())
12 changes: 10 additions & 2 deletions docs/desktop-shell.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ bundle ships in browser-fallback mode rather than failing.
embedded meeting watcher, which previously survived closing a browser tab.
Users who want the old behaviour can set `AMICOSCRIPT_UI=browser`. Phase 2
should make the window close to the tray instead.
- **No auto-update.** Releases are still hand-downloaded zips.
- **No auto-update for the zips.** They are still hand-downloaded. The wheel
has `uv tool upgrade amicoscript`; a bundled updater is Tauri's job.

### Offline assets

Expand All @@ -98,7 +99,14 @@ and supervises. Both build chains survive; Tauri is added, not swapped in.

What it buys over Phase 1:

- Signed, real installers: `.dmg`, `.msi`/`.nsis`, `.deb`/`.AppImage`
- Real installers: `.dmg`, `.msi`/`.nsis`, `.deb`/`.AppImage`, with hooks that
apply a signature — **not** the certificates themselves. Tauri does not solve
the "unidentified developer" problem: an unsigned `.dmg` is blocked exactly
like the current unsigned `.app`, and under the sidecar design the PyInstaller
binary ends up inside the bundle and needs notarizing either way. Signing is
an independent, paid track (Apple Developer Program; a Windows certificate in
a cloud HSM). The route that avoids it entirely is the wheel —
see [pypi-release.md](pypi-release.md)
- Built-in updater (signed `latest.json`), replacing manual zip downloads
- Native tray that outlives the window, single-instance guard, deep links
- A working Linux window via an apt-declared WebKitGTK dependency
Expand Down
Loading
Loading