Binárka ke stažení a release z tagu #3

Merged
david-spacil merged 5 commits from agents/claude/binarka-a-release into main 2026-08-21 17:40:08 +02:00
9 changed files with 364 additions and 7 deletions

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
# Postaví binárku a pověsí ji na release.
#
# Runner běží v host módu, takže tu není kontejner ani hotové akce
# z marketplace — nic se nespoléhá na to, co má hostitel nainstalováno.
# O starou glibc se stará build.sh přes samostatný CPython od uv.
name: binárka
on:
push:
tags: ["v*"]
pull_request:
workflow_dispatch:
jobs:
linux:
runs-on: homelab
steps:
- name: objdump
run: |
# Jediné, co musí být na hostiteli — PyInstaller jím hledá, na
# kterých knihovnách binárka visí. Zbytek si obstará uv.
command -v objdump >/dev/null && exit 0
sudo=""; [ "$(id -u)" -ne 0 ] && sudo=sudo
if command -v apt-get >/dev/null; then
$sudo apt-get update -qq
$sudo apt-get install -y -qq binutils
elif command -v dnf >/dev/null; then
$sudo dnf install -y -q binutils
else
echo "Nainstaluj binutils, PyInstaller bez objdumpu nestaví." >&2
exit 1
fi
- name: Zdrojáky
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git init -q .
git remote remove origin 2>/dev/null || true
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git config http.extraheader "Authorization: token ${TOKEN}"
git fetch -q --depth 1 origin "${GITHUB_REF}"
git config --unset http.extraheader
git checkout -qf FETCH_HEAD
git clean -qfd
- name: uv
run: |
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
export PATH="$HOME/.local/bin:$PATH"
command -v uv >/dev/null || curl -LsSf https://astral.sh/uv/install.sh | sh
uv --version
- name: Testy
run: uv run --with-requirements requirements.txt --no-project pytest -q
- name: Binárka
run: ./build.sh
- name: Zkouška
run: |
XDG_DATA_HOME=/tmp/zkouska DICE_PORT=8321 DICE_HOST=127.0.0.1 \
./dist/kostky > /tmp/server.log 2>&1 &
server=$!
trap 'kill $server 2>/dev/null || true' EXIT
for _ in $(seq 30); do
curl -sf -o /dev/null http://127.0.0.1:8321/ && break
sleep 1
done
curl -sf -o /dev/null http://127.0.0.1:8321/
curl -sf -o /dev/null http://127.0.0.1:8321/static/style.css
curl -sf -o /dev/null http://127.0.0.1:8321/board
test -f /tmp/zkouska/kostky/dice.db
cat /tmp/server.log
- name: Na release
if: startsWith(github.ref, 'refs/tags/')
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
ASSET: kostky-linux-x86_64
run: |
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
auth="Authorization: token ${TOKEN}"
# Release u tagu být může, ale nemusí — dá se založit i ručně.
id=$(curl -s -H "$auth" "$api/releases/tags/${GITHUB_REF_NAME}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin).get("id",""))' || true)
if [ -z "$id" ]; then
id=$(curl -sf -X POST -H "$auth" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"${GITHUB_REF_NAME}\"}" \
"$api/releases" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
fi
# Aby se dal build zopakovat, stará příloha stejného jména jde pryč.
curl -s -H "$auth" "$api/releases/$id/assets" \
| python3 -c 'import json,os,sys; [print(a["id"]) for a in json.load(sys.stdin) if a["name"] == os.environ["ASSET"]]' \
| while read -r old; do
curl -sf -X DELETE -H "$auth" "$api/releases/$id/assets/$old"
done
curl -sf -o /dev/null -X POST -H "$auth" -F "attachment=@dist/kostky" \
"$api/releases/$id/assets?name=${ASSET}"
echo "$ASSET pověšen na ${GITHUB_REF_NAME}"
+69
View File
@@ -0,0 +1,69 @@
# Testy nad každým pull requestem a nad main.
#
# Runner běží v host módu, takže tu není kontejner ani hotové akce
# z marketplace. Pythony obstará uv, na hostiteli nemusí být žádný.
name: testy
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: testy-${{ github.ref }}
cancel-in-progress: true
jobs:
pytest:
runs-on: homelab
strategy:
fail-fast: false
# Runner sdílí pracovní adresář mezi variantami, takže po jedné.
max-parallel: 1
matrix:
python: ["3.11", "3.12", "3.13", "3.14"]
steps:
- name: Zdrojáky
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git init -q .
git remote remove origin 2>/dev/null || true
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git config http.extraheader "Authorization: token ${TOKEN}"
git fetch -q --depth 1 origin "${GITHUB_REF}"
git config --unset http.extraheader
git checkout -qf FETCH_HEAD
git clean -qfd
- name: uv
run: |
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
export PATH="$HOME/.local/bin:$PATH"
command -v uv >/dev/null || curl -LsSf https://astral.sh/uv/install.sh | sh
uv python install "${{ matrix.python }}"
- name: Testy
run: |
uv run --managed-python --python "${{ matrix.python }}" \
--with-requirements requirements.txt --no-project \
pytest -q
- name: Terminálová verze
run: |
# Pytest sahá na core.py, ne na prompty. Tohle projde celou partii
# tak, jak ji odehraje člověk: dvě nuly, třetí vynuluje, výhra.
out=$(printf 'A\nB\n\n1000\n\n200\n10\n0\n10\n0\n10\n0\n\n1000\n' \
| TERM=dumb uv run --managed-python --python "${{ matrix.python }}" dice.py)
for expected in "200xx" "Po třech nulových hodech" "Vítězem se stává B"; do
printf '%s' "$out" | grep -qF "$expected" || {
echo "chybí: $expected"
printf '%s\n' "$out"
exit 1
}
done
echo "terminálová verze v pořádku"
+3
View File
@@ -2,3 +2,6 @@ __pycache__/
.venv/ .venv/
*.db *.db
.pytest_cache/ .pytest_cache/
dist/
build/
.build/
+64 -4
View File
@@ -8,12 +8,24 @@ ten samý Adam jako z minulého měsíce. Žádné účty, žádná hesla.
## Spuštění ## Spuštění
Stáhni binárku z [releases](https://gitea.spacilovi.eu/david-spacil/dice-counter/releases)
a spusť ji:
```bash
chmod +x kostky
./kostky
```
Nic se neinstaluje — Python, Flask i šablony jsou uvnitř. Zatím jen pro Linux
na x86-64; postavené proti glibc 2.17, takže jede prakticky všude.
Ze zdrojáků to je stejně krátké:
```bash ```bash
uv run web.py uv run web.py
``` ```
To je celé. Závislosti si skript nese v hlavičce (PEP 723) a `uv` je obstará Závislosti si skript nese v hlavičce (PEP 723) a `uv` je obstará sám.
sám — není co instalovat ani aktivovat.
Server vypíše všechny adresy, na kterých je dostupný: Server vypíše všechny adresy, na kterých je dostupný:
@@ -25,14 +37,33 @@ Počitadlo je dostupné na:
``` ```
Na notebooku otevři `/board`, naskenuj QR kód telefonem a hraj. Tabule Na notebooku otevři `/board`, naskenuj QR kód telefonem a hraj. Tabule
zobrazuje i zbylé adresy — když jedna nefunguje, zkus další. zobrazuje i zbylé adresy — když jedna nefunguje, zkus další. Zastavuje se
`Ctrl+C`.
| Proměnná | K čemu je | Výchozí | | Proměnná | K čemu je | Výchozí |
|---|---|---| |---|---|---|
| `DICE_DB` | soubor s databází | `dice.db` | | `DICE_DB` | soubor s databází | podle způsobu spuštění, viz níž |
| `DICE_PORT` | port | `8000` | | `DICE_PORT` | port | `8000` |
| `DICE_HOST` | pevná adresa; vypne hledání | hledá se za běhu | | `DICE_HOST` | pevná adresa; vypne hledání | hledá se za běhu |
## Kde jsou data
Jeden soubor SQLite. Založí se sám při prvním načtení stránky, cestu k němu
server vypíše při startu.
| Spuštěno | Databáze |
|---|---|
| binárkou | `~/.local/share/kostky/dice.db` |
| ze zdrojáků | `dice.db` v pracovním adresáři |
Binárka se při každém spuštění rozbaluje do dočasného adresáře a pouští se
odkudkoli, takže relativní cesta by databázi rozsypala po disku — proto
napevno domovský adresář. Ze zdrojáků zůstává relativní, ať se dá mít víc
sad vedle sebe.
Zálohovat i stěhovat jde prostým zkopírováním souboru; `DICE_DB` si ho
najde kdekoli.
Počítá se s domácí sítí — appka nemá přihlašování a kdokoli na stejné WiFi Počítá se s domácí sítí — appka nemá přihlašování a kdokoli na stejné WiFi
může zapisovat. může zapisovat.
@@ -69,6 +100,7 @@ při remíze na prvním místě se hraje dál.
| `stats.py` | síň slávy a kariérní statistiky | | `stats.py` | síň slávy a kariérní statistiky |
| `web.py` | Flask aplikace | | `web.py` | Flask aplikace |
| `dice.py` | totéž v terminálu, bez ukládání | | `dice.py` | totéž v terminálu, bez ukládání |
| `build.sh`, `kostky.spec` | stavba binárky |
Terminálová verze zůstává funkční jako záloha: Terminálová verze zůstává funkční jako záloha:
@@ -93,6 +125,30 @@ sudo dnf install python3-flask python3-qrcode python3-tabulate
python3 web.py python3 web.py
``` ```
## Vlastní binárka
```bash
./build.sh
```
Staví se proti samostatnému CPythonu od `uv`, ne proti systémovému. Ten je
slinkovaný s glibc 2.17; postavené proti Pythonu z Fedory 44 by to chtělo
glibc 2.43 a nešlo by spustit skoro nikde — glibc drží zpětnou kompatibilitu,
ne dopřednou. Nic z hostitele se do binárky nedostane, takže kontejner k tomu
potřeba není. Výsledek je v `dist/kostky`.
Terminálová verze součástí binárky není; ta se pouští ze zdrojáků.
Releasy si tohle dělají samy. Otagovaný commit spustí
`.gitea/workflows/binarka.yml`, ta binárku postaví, projede testy, zkusí
ji nastartovat a pověsí ji na release jako `kostky-linux-x86_64`:
```bash
git tag v1.0.0 && git push origin v1.0.0
```
Stejná stavba běží i nad každým pull requestem, jen se nikam nevěší.
## Testy ## Testy
```bash ```bash
@@ -100,3 +156,7 @@ uv run --with-requirements requirements.txt --no-project pytest
``` ```
Nebo z připraveného prostředí prostě `pytest`. Nebo z připraveného prostředí prostě `pytest`.
Nad každým pull requestem a nad `main` běží `.gitea/workflows/testy.yml` —
84 testů na Pythonu 3.11, 3.12, 3.13 i 3.14, a k tomu skriptovaná partie
v terminálové verzi, na kterou pytest nesahá.
Executable
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Postaví jednosouborovou binárku pro Linux do dist/kostky.
#
# Staví se proti samostatnému CPythonu od uv, ne proti systémovému. Ten je
# slinkovaný s glibc 2.17, takže binárka jede i na letitých systémech.
# Postavená proti Pythonu z Fedory 44 by chtěla glibc 2.43 a nešla by
# spustit skoro nikde — glibc drží zpětnou kompatibilitu, ne dopřednou.
# Nic z hostitele se do ní nedostane, takže kontejner k tomu není potřeba.
set -euo pipefail
VERSION=${VERSION:-3.11}
BUILD=${BUILD:-.build}
uv python install "$VERSION"
uv venv --quiet --managed-python --python "$VERSION" "$BUILD"
uv pip install --quiet --python "$BUILD/bin/python" -r requirements.txt pyinstaller
"$BUILD/bin/pyinstaller" --clean --noconfirm \
--distpath dist --workpath "$BUILD/work" kostky.spec
echo
ls -lh dist/kostky
+26
View File
@@ -0,0 +1,26 @@
# -*- mode: python ; coding: utf-8 -*-
"""Jedna binárka se vším: Python, Flask, šablony i styly.
Staví se přes `./build.sh`, ne ručně — ta se stará o to, aby výsledek jel
i na starších systémech, než je ten tvůj.
"""
analysis = Analysis(
["web.py"],
pathex=["."],
datas=[("templates", "templates"), ("static", "static")],
noarchive=False,
)
pyz = PYZ(analysis.pure)
exe = EXE(
pyz,
analysis.scripts,
analysis.binaries,
analysis.datas,
name="kostky",
console=True,
strip=True,
upx=False,
)
+22 -2
View File
@@ -10,12 +10,30 @@ Součty se nikde neukládají; stav hry se rekonstruuje načtením tahů do
import os import os
import re import re
import sqlite3 import sqlite3
import sys
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from core import FINAL_SCORE, Game, Turn from core import FINAL_SCORE, Game, Turn
DB_PATH = Path(os.environ.get("DICE_DB", "dice.db")) def default_db() -> Path:
"""Kam se ukládá databáze.
Ze zdrojáků do pracovního adresáře — dá se tak mít víc rozehraných
sad vedle sebe a je vidět, s čím se pracuje. Z binárky do datového
adresáře podle XDG: binárka se spouští odkudkoli a rozbaluje se do
dočasného adresáře, takže relativní cesta by databázi rozsypala po disku.
"""
chosen = os.environ.get("DICE_DB")
if chosen:
return Path(chosen)
if getattr(sys, "frozen", False):
share = os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share"
return Path(share) / "kostky" / "dice.db"
return Path("dice.db")
DB_PATH = default_db()
STATUS_RUNNING = "probíhá" STATUS_RUNNING = "probíhá"
STATUS_FINISHED = "dohráno" STATUS_FINISHED = "dohráno"
@@ -60,7 +78,9 @@ CREATE INDEX IF NOT EXISTS turns_game ON turns(game_id, id);
def connect(path: Path | str | None = None) -> sqlite3.Connection: def connect(path: Path | str | None = None) -> sqlite3.Connection:
conn = sqlite3.connect(path or DB_PATH) target = Path(path or DB_PATH)
target.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(target)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON") conn.execute("PRAGMA foreign_keys = ON")
conn.executescript(SCHEMA) conn.executescript(SCHEMA)
+33
View File
@@ -1,3 +1,5 @@
from pathlib import Path
import pytest import pytest
import storage import storage
@@ -132,3 +134,34 @@ def test_hrac_nesmi_sedet_u_stolu_dvakrat(conn):
def test_prazdne_jmeno(conn): def test_prazdne_jmeno(conn):
with pytest.raises(ValueError): with pytest.raises(ValueError):
storage.get_or_create_player(conn, " ") storage.get_or_create_player(conn, " ")
def test_umisteni_databaze_ze_zdrojaku(monkeypatch):
"""Ze zdrojáků se ukládá do pracovního adresáře."""
monkeypatch.delenv("DICE_DB", raising=False)
monkeypatch.delattr(storage.sys, "frozen", raising=False)
assert storage.default_db() == Path("dice.db")
def test_umisteni_databaze_z_binarky(monkeypatch, tmp_path):
"""Binárka se rozbaluje do dočasného adresáře, databáze tam nesmí."""
monkeypatch.delenv("DICE_DB", raising=False)
monkeypatch.setattr(storage.sys, "frozen", True, raising=False)
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
assert storage.default_db() == tmp_path / "kostky" / "dice.db"
def test_dice_db_prebije_oboje(monkeypatch, tmp_path):
monkeypatch.setattr(storage.sys, "frozen", True, raising=False)
monkeypatch.setenv("DICE_DB", str(tmp_path / "jinde.db"))
assert storage.default_db() == tmp_path / "jinde.db"
def test_connect_zalozi_chybejici_adresar(tmp_path):
conn = storage.connect(tmp_path / "novy" / "adresar" / "dice.db")
conn.close()
assert (tmp_path / "novy" / "adresar" / "dice.db").exists()
+17 -1
View File
@@ -10,6 +10,8 @@ Server-rendered HTML, formuláře přes POST/redirect/GET, žádný build step.
import os import os
import socket import socket
import sys
from pathlib import Path
import qrcode import qrcode
from flask import (Flask, abort, g, redirect, render_template, request, from flask import (Flask, abort, g, redirect, render_template, request,
@@ -21,7 +23,19 @@ import stats
import storage import storage
from core import FINAL_SCORE, Game, GameOver from core import FINAL_SCORE, Game, GameOver
app = Flask(__name__) def bundled(folder: str) -> str:
"""Cesta k šablonám a stylům.
Ze zdrojáků leží vedle tohoto souboru. V binárce je PyInstaller rozbalí
do dočasného adresáře, na který ukazuje `sys._MEIPASS`.
"""
root = Path(getattr(sys, "_MEIPASS", Path(__file__).parent))
return str(root / folder)
app = Flask(__name__,
template_folder=bundled("templates"),
static_folder=bundled("static"))
PORT = int(os.environ.get("DICE_PORT", 8000)) PORT = int(os.environ.get("DICE_PORT", 8000))
@@ -298,4 +312,6 @@ if __name__ == "__main__":
else: else:
print("Adresu se nepodařilo zjistit. Spusť server s DICE_HOST=<adresa>.") print("Adresu se nepodařilo zjistit. Spusť server s DICE_HOST=<adresa>.")
print(f"\nDatabáze: {storage.DB_PATH.resolve()}")
app.run(host="0.0.0.0", port=PORT, threaded=True) app.run(host="0.0.0.0", port=PORT, threaded=True)