From 0e3087a78082de79bb927ff7cc1a8fe00655bcd1 Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Tue, 23 Jun 2026 20:19:53 +0200 Subject: [PATCH] ci: add CCMA release builds --- .gitea/workflows/01-pr-check.yml | 40 +++ .gitea/workflows/02-push-smoke.yml | 42 ++++ .gitea/workflows/03-dev-bump.yml | 106 ++++++++ .gitea/workflows/04-main-release.yml | 352 +++++++++++++++++++++++++++ .gitignore | 5 + .gitmodules | 5 + README.md | 27 +- build/build.bat | 16 ++ build/build.ps1 | 98 ++++++++ build/ccma.spec | 100 ++++++++ ci-config.yaml | 43 ++++ ci-templates | 1 + pyproject.toml | 1 + src/ccma/config.py | 14 +- src/ccma/services/documents.py | 6 +- tests/test_config.py | 10 +- 16 files changed, 842 insertions(+), 24 deletions(-) create mode 100644 .gitea/workflows/01-pr-check.yml create mode 100644 .gitea/workflows/02-push-smoke.yml create mode 100644 .gitea/workflows/03-dev-bump.yml create mode 100644 .gitea/workflows/04-main-release.yml create mode 100644 .gitmodules create mode 100644 build/build.bat create mode 100644 build/build.ps1 create mode 100644 build/ccma.spec create mode 100644 ci-config.yaml create mode 160000 ci-templates diff --git a/.gitea/workflows/01-pr-check.yml b/.gitea/workflows/01-pr-check.yml new file mode 100644 index 0000000..5688f86 --- /dev/null +++ b/.gitea/workflows/01-pr-check.yml @@ -0,0 +1,40 @@ +name: PR Check + +on: + pull_request: + +jobs: + pr_check: + name: Project PR check (linux-amd64) + runs-on: linux-amd64 + steps: + - name: Checkout repository + shell: bash + env: + CI_MATCHA_GITEA_TOKEN: ${{ secrets.CI_MATCHA_GITEA_TOKEN }} + run: | + set -euo pipefail + if [ -z "${CI_MATCHA_GITEA_TOKEN:-}" ]; then + echo "Missing secrets.CI_MATCHA_GITEA_TOKEN" >&2 + exit 1 + fi + basic=$(python3 -c 'import base64, os; print(base64.b64encode(("matcha:" + os.environ["CI_MATCHA_GITEA_TOKEN"]).encode()).decode())') + git init . + git remote add origin "https://git.hiabuto.net/${{ github.repository }}.git" + git config --global http.https://git.hiabuto.net/.extraheader "AUTHORIZATION: basic ${basic}" + git fetch --depth=1 origin "${{ github.event.pull_request.head.ref }}" + git checkout --force FETCH_HEAD + + - name: Load central CI templates + shell: bash + run: | + set -euo pipefail + git submodule sync --recursive + git submodule update --init --remote ci-templates + git -C ci-templates rev-parse --short HEAD + + - name: Run config-driven PR check + uses: ./ci-templates/actions/ci-runner + with: + command: pr-check + config: ci-config.yaml diff --git a/.gitea/workflows/02-push-smoke.yml b/.gitea/workflows/02-push-smoke.yml new file mode 100644 index 0000000..361dad4 --- /dev/null +++ b/.gitea/workflows/02-push-smoke.yml @@ -0,0 +1,42 @@ +name: Push Smoke Build + +on: + push: + branches: [dev, main] + +jobs: + smoke_build: + name: Project smoke build (linux-amd64) + runs-on: linux-amd64 + if: ${{ !contains(github.event.head_commit.message, '[nobuild]') }} + steps: + - name: Checkout repository + shell: bash + env: + CI_MATCHA_GITEA_TOKEN: ${{ secrets.CI_MATCHA_GITEA_TOKEN }} + run: | + set -euo pipefail + if [ -z "${CI_MATCHA_GITEA_TOKEN:-}" ]; then + echo "Missing secrets.CI_MATCHA_GITEA_TOKEN" >&2 + exit 1 + fi + basic=$(python3 -c 'import base64, os; print(base64.b64encode(("matcha:" + os.environ["CI_MATCHA_GITEA_TOKEN"]).encode()).decode())') + git init . + git remote add origin "https://git.hiabuto.net/${{ github.repository }}.git" + git config --global http.https://git.hiabuto.net/.extraheader "AUTHORIZATION: basic ${basic}" + git fetch --depth=1 origin "${{ github.ref_name }}" + git checkout --force FETCH_HEAD + + - name: Load central CI templates + shell: bash + run: | + set -euo pipefail + git submodule sync --recursive + git submodule update --init --remote ci-templates + git -C ci-templates rev-parse --short HEAD + + - name: Run config-driven smoke build + uses: ./ci-templates/actions/ci-runner + with: + command: smoke-build + config: ci-config.yaml diff --git a/.gitea/workflows/03-dev-bump.yml b/.gitea/workflows/03-dev-bump.yml new file mode 100644 index 0000000..e069fbd --- /dev/null +++ b/.gitea/workflows/03-dev-bump.yml @@ -0,0 +1,106 @@ +name: Dev Version Bump + +on: + workflow_run: + workflows: ["Push Smoke Build"] + types: [completed] + branches: [dev] + +env: + CI_GIT_EMAIL: 'git-ci@hiabuto.net' + CI_GIT_NAME: 'Git-CI' + GITEA_REPO: ${{ github.repository }} + +jobs: + bump: + runs-on: linux-amd64 + if: "${{ gitea.event.workflow_run.conclusion == 'success' && gitea.event.workflow_run.head_branch == 'dev' }}" + steps: + - name: Checkout repository + shell: bash + env: + CI_MATCHA_GITEA_TOKEN: ${{ secrets.CI_MATCHA_GITEA_TOKEN }} + run: | + set -euo pipefail + git init . + git remote add origin "https://git.hiabuto.net/${{ github.repository }}.git" + if [ -n "${CI_MATCHA_GITEA_TOKEN:-}" ]; then + basic=$(python3 -c 'import base64, os; print(base64.b64encode(("matcha:" + os.environ["CI_MATCHA_GITEA_TOKEN"]).encode()).decode())') + git config --global http.https://git.hiabuto.net/.extraheader "AUTHORIZATION: basic ${basic}" + else + echo "CI_MATCHA_GITEA_TOKEN not set; trying unauthenticated checkout." + fi + git fetch --depth=50 origin "${{ gitea.event.workflow_run.head_branch }}" + git checkout --force "${{ gitea.event.workflow_run.head_sha }}" + + - name: Load central CI templates + shell: bash + env: + CI_MATCHA_GITEA_TOKEN: ${{ secrets.CI_MATCHA_GITEA_TOKEN }} + run: | + set -euo pipefail + if [ -z "${CI_MATCHA_GITEA_TOKEN:-}" ]; then + echo "Missing secrets.CI_MATCHA_GITEA_TOKEN" >&2 + exit 1 + fi + basic=$(python3 -c 'import base64, os; print(base64.b64encode(("matcha:" + os.environ["CI_MATCHA_GITEA_TOKEN"]).encode()).decode())') + git config --global http.https://git.hiabuto.net/.extraheader "AUTHORIZATION: basic ${basic}" + git submodule sync --recursive + git submodule update --init --remote ci-templates + git -C ci-templates rev-parse --short HEAD + + - id: ver + name: Compute version + uses: ./ci-templates/.gitea/actions/actions-versioning + with: + config_file: ci-config.yaml + ref_name: ${{ gitea.event.workflow_run.head_branch }} + + - name: Commit + push VERSION (dev) + shell: bash + env: + CI_MATCHA_GITEA_TOKEN: ${{ secrets.CI_MATCHA_GITEA_TOKEN }} + run: | + set -euo pipefail + if [ -z "${CI_MATCHA_GITEA_TOKEN:-}" ]; then + echo "Missing secrets.CI_MATCHA_GITEA_TOKEN" >&2 + exit 1 + fi + + subj=$(git log -1 --pretty=format:%s) + if [[ "$subj" == *"[nobuild]"* ]]; then + echo "[nobuild] present; skipping dev bump." + exit 0 + fi + + if [[ "$subj" == ci:\ bump\ dev\ version* ]]; then + echo "Already a dev bump commit; nothing to do." + exit 0 + fi + + version="${{ steps.ver.outputs.program_version }}" + version_file=$(python3 - <<'PY' + import re + p='VERSION' + for line in open('ci-config.yaml', encoding='utf-8'): + if re.match(r'^\s*file\s*:', line): + p=line.split(':',1)[1].strip().strip('"\'') + break + print(p) + PY + ) + + git config user.email "${CI_GIT_EMAIL}" + git config user.name "${CI_GIT_NAME}" + + echo "$version" > "$version_file" + git add "$version_file" + if git diff --cached --quiet; then + echo "VERSION already up-to-date; nothing to commit."; exit 0 + fi + + git commit -m "ci: bump dev version to ${version} [skip ci]" + git config --local --unset-all http.https://git.hiabuto.net/.extraheader || true + git config --global --unset-all http.https://git.hiabuto.net/.extraheader || true + git remote set-url origin "https://matcha:${CI_MATCHA_GITEA_TOKEN}@git.hiabuto.net/${GITEA_REPO}.git" + git push origin HEAD:dev diff --git a/.gitea/workflows/04-main-release.yml b/.gitea/workflows/04-main-release.yml new file mode 100644 index 0000000..9bb9aec --- /dev/null +++ b/.gitea/workflows/04-main-release.yml @@ -0,0 +1,352 @@ +name: Main Release + +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: release-main + cancel-in-progress: false + +env: + CI_GIT_EMAIL: 'git-ci@hiabuto.net' + CI_GIT_NAME: 'Git-CI' + GITEA_API_BASE: 'https://git.hiabuto.net/api/v1' + GITEA_REPO: ${{ github.repository }} + +jobs: + compute: + runs-on: linux-amd64 + if: ${{ github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' }} + outputs: + program_version: ${{ steps.ver.outputs.program_version }} + did_release: ${{ steps.ver.outputs.did_release }} + release_tag: ${{ steps.ver.outputs.release_tag }} + release_kind: ${{ steps.ver.outputs.release_kind }} + head_sha: ${{ steps.meta.outputs.head_sha }} + head_msg: ${{ steps.meta.outputs.head_msg }} + steps: + - name: Checkout repository + shell: bash + env: + CI_MATCHA_GITEA_TOKEN: ${{ secrets.CI_MATCHA_GITEA_TOKEN }} + run: | + set -euo pipefail + git init . + git remote add origin "https://git.hiabuto.net/${{ github.repository }}.git" + if [ -n "${CI_MATCHA_GITEA_TOKEN:-}" ]; then + basic=$(python3 -c 'import base64, os; print(base64.b64encode(("matcha:" + os.environ["CI_MATCHA_GITEA_TOKEN"]).encode()).decode())') + git config --global http.https://git.hiabuto.net/.extraheader "AUTHORIZATION: basic ${basic}" + else + echo "CI_MATCHA_GITEA_TOKEN not set; trying unauthenticated checkout." + fi + git fetch --depth=50 origin "${{ github.ref_name }}" + git checkout --force "${{ github.sha }}" + + - name: Load central CI templates + shell: bash + env: + CI_MATCHA_GITEA_TOKEN: ${{ secrets.CI_MATCHA_GITEA_TOKEN }} + run: | + set -euo pipefail + if [ -z "${CI_MATCHA_GITEA_TOKEN:-}" ]; then + echo "Missing secrets.CI_MATCHA_GITEA_TOKEN" >&2 + exit 1 + fi + basic=$(python3 -c 'import base64, os; print(base64.b64encode(("matcha:" + os.environ["CI_MATCHA_GITEA_TOKEN"]).encode()).decode())') + git config --global http.https://git.hiabuto.net/.extraheader "AUTHORIZATION: basic ${basic}" + git submodule sync --recursive + git submodule update --init --remote ci-templates + git -C ci-templates rev-parse --short HEAD + + - id: meta + shell: bash + run: | + set -euo pipefail + echo "head_sha=${{ github.sha }}" >> "$GITHUB_OUTPUT" + echo "head_msg=$(git log -1 --pretty=format:%s)" >> "$GITHUB_OUTPUT" + + - id: ver + name: Compute version + uses: ./ci-templates/.gitea/actions/actions-versioning + with: + config_file: ci-config.yaml + ref_name: ${{ github.ref_name }} + + package: + needs: [compute] + if: ${{ needs.compute.outputs.did_release == 'true' && !contains(needs.compute.outputs.head_msg, '[nobuild]') }} + strategy: + fail-fast: false + matrix: + include: + - os_label: linux-amd64 + os: linux + arch: amd64 + - os_label: linux-arm64 + os: linux + arch: arm64 + - os_label: windows-amd64 + os: windows + arch: amd64 + runs-on: ${{ matrix.os_label }} + steps: + - name: Checkout repository + shell: bash + env: + CI_MATCHA_GITEA_TOKEN: ${{ secrets.CI_MATCHA_GITEA_TOKEN }} + run: | + set -euo pipefail + git init . + git remote add origin "https://git.hiabuto.net/${{ github.repository }}.git" + if [ -n "${CI_MATCHA_GITEA_TOKEN:-}" ]; then + basic=$(python3 -c 'import base64, os; print(base64.b64encode(("matcha:" + os.environ["CI_MATCHA_GITEA_TOKEN"]).encode()).decode())') + git config --global http.https://git.hiabuto.net/.extraheader "AUTHORIZATION: basic ${basic}" + else + echo "CI_MATCHA_GITEA_TOKEN not set; trying unauthenticated checkout." + fi + git fetch --depth=50 origin "${{ github.ref_name }}" + git checkout --force "${{ needs.compute.outputs.head_sha }}" + + - name: Load central CI templates + shell: bash + env: + CI_MATCHA_GITEA_TOKEN: ${{ secrets.CI_MATCHA_GITEA_TOKEN }} + run: | + set -euo pipefail + if [ -z "${CI_MATCHA_GITEA_TOKEN:-}" ]; then + echo "Missing secrets.CI_MATCHA_GITEA_TOKEN" >&2 + exit 1 + fi + basic=$(python3 -c 'import base64, os; print(base64.b64encode(("matcha:" + os.environ["CI_MATCHA_GITEA_TOKEN"]).encode()).decode())') + git config --global http.https://git.hiabuto.net/.extraheader "AUTHORIZATION: basic ${basic}" + git submodule sync --recursive + git submodule update --init --remote ci-templates + git -C ci-templates rev-parse --short HEAD + + - name: Package release via central CI templates + uses: ./ci-templates/actions/ci-runner + env: + CI_ARCH: ${{ matrix.arch }} + with: + command: package-release + config: ci-config.yaml + version: ${{ needs.compute.outputs.program_version }} + + - name: Upload dist + uses: actions/upload-artifact@v3 + with: + name: dist-${{ matrix.os }}-${{ matrix.arch }} + path: dist/ + + finalize_and_publish: + needs: [compute, package] + if: ${{ needs.compute.outputs.did_release == 'true' && needs.package.result == 'success' }} + runs-on: linux-amd64 + steps: + - name: Checkout repository + shell: bash + env: + CI_MATCHA_GITEA_TOKEN: ${{ secrets.CI_MATCHA_GITEA_TOKEN }} + run: | + set -euo pipefail + git init . + git remote add origin "https://git.hiabuto.net/${{ github.repository }}.git" + if [ -n "${CI_MATCHA_GITEA_TOKEN:-}" ]; then + basic=$(python3 -c 'import base64, os; print(base64.b64encode(("matcha:" + os.environ["CI_MATCHA_GITEA_TOKEN"]).encode()).decode())') + git config --global http.https://git.hiabuto.net/.extraheader "AUTHORIZATION: basic ${basic}" + else + echo "CI_MATCHA_GITEA_TOKEN not set; trying unauthenticated checkout." + fi + git fetch --depth=50 origin "${{ github.ref_name }}" + git checkout --force "${{ needs.compute.outputs.head_sha }}" + + - name: Download artifacts + uses: actions/download-artifact@v3 + with: + path: release-artifacts + + - name: Prepare upload dir + shell: bash + run: | + set -euo pipefail + mkdir -p upload + find release-artifacts -maxdepth 4 -type f -print -exec cp -f {} upload/ \; + ls -la upload + + - name: Commit VERSION + tag + push (only now) + shell: bash + env: + CI_MATCHA_GITEA_TOKEN: ${{ secrets.CI_MATCHA_GITEA_TOKEN }} + run: | + set -euo pipefail + if [ -z "${CI_MATCHA_GITEA_TOKEN:-}" ]; then + echo "Missing secrets.CI_MATCHA_GITEA_TOKEN" >&2 + exit 1 + fi + + version="${{ needs.compute.outputs.program_version }}" + tag="${{ needs.compute.outputs.release_tag }}" + version_file=$(python3 - <<'PY' + import re + p='VERSION' + for line in open('ci-config.yaml', encoding='utf-8'): + if re.match(r'^\s*file\s*:', line): + p=line.split(':',1)[1].strip().strip('"\'') + break + print(p) + PY + ) + + git config user.email "${CI_GIT_EMAIL}" + git config user.name "${CI_GIT_NAME}" + + echo "$version" > "$version_file" + git add "$version_file" + if git diff --cached --quiet; then + echo "VERSION already set in repo; continuing" + else + git commit -m "ci: release version ${version} [skip ci]" + fi + + git config --local --unset-all http.https://git.hiabuto.net/.extraheader || true + git config --global --unset-all http.https://git.hiabuto.net/.extraheader || true + git remote set-url origin "https://matcha:${CI_MATCHA_GITEA_TOKEN}@git.hiabuto.net/${GITEA_REPO}.git" + git fetch --tags origin + git push origin "HEAD:main" + + if git ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then + echo "Tag already exists on origin: ${tag} (will reuse)" + else + git tag "${tag}" + git push origin "${tag}" + fi + + - name: Publish release + upload assets + shell: bash + env: + GITEA_TOKEN: ${{ secrets.CI_MATCHA_GITEA_TOKEN }} + TAG: ${{ needs.compute.outputs.release_tag }} + run: | + set -euo pipefail + if [ -z "${GITEA_TOKEN:-}" ]; then + echo "Missing secrets.CI_MATCHA_GITEA_TOKEN" >&2 + exit 1 + fi + + python3 - <<'PY' + import json, os, pathlib, urllib.parse, urllib.request, urllib.error + api_base = os.environ['GITEA_API_BASE'].rstrip('/') + repo = os.environ['GITEA_REPO'] + token = os.environ['GITEA_TOKEN'].strip() + tag = os.environ['TAG'].strip() + artifacts = sorted(p for p in pathlib.Path('upload').glob('*') if p.is_file()) + headers = {'Authorization': f'token {token}', 'Accept': 'application/json'} + def request_json(method, url, data=None): + payload=None + h=dict(headers) + if data is not None: + payload=json.dumps(data).encode('utf-8'); h['Content-Type']='application/json' + req=urllib.request.Request(url, data=payload, method=method, headers=h) + try: + with urllib.request.urlopen(req) as resp: + txt=resp.read().decode('utf-8'); return resp.getcode(), json.loads(txt) if txt else None + except urllib.error.HTTPError as exc: + return exc.code, {'error': exc.read().decode('utf-8', errors='replace')} + def request_binary_post(url, payload, content_type): + h=dict(headers); h['Content-Type']=content_type + req=urllib.request.Request(url, data=payload, method='POST', headers=h) + with urllib.request.urlopen(req) as resp: + txt=resp.read().decode('utf-8'); return resp.getcode(), json.loads(txt) if txt else None + tag_url=f"{api_base}/repos/{repo}/releases/tags/{urllib.parse.quote(tag, safe='')}" + status, release = request_json('GET', tag_url) + if status==200 and isinstance(release, dict): + release_id=release.get('id') + else: + status, release = request_json('POST', f"{api_base}/repos/{repo}/releases", { + 'tag_name': tag, 'name': tag, 'body': f'Automated release for {tag}', 'draft': False, 'prerelease': False, + }) + if status not in (200,201): + raise SystemExit(f"create release failed {status} {release}") + release_id=release.get('id') + assets_url=f"{api_base}/repos/{repo}/releases/{release_id}/assets" + status, existing = request_json('GET', assets_url) + existing = existing if isinstance(existing, list) else [] + by_name={e.get('name'):e for e in existing if isinstance(e, dict) and e.get('name')} + for a in artifacts: + name=a.name + prev=by_name.get(name) + if prev and prev.get('id'): + ds,_=request_json('DELETE', f"{assets_url}/{prev['id']}") + if ds!=204: raise SystemExit(f"delete asset failed {name} {ds}") + code,_=request_binary_post(f"{assets_url}?name={urllib.parse.quote(name, safe='')}", a.read_bytes(), 'application/octet-stream') + if code not in (200,201): raise SystemExit(f"upload failed {name} {code}") + print('Uploaded', name) + print('Release done', tag) + PY + + sync_main_to_dev: + needs: [finalize_and_publish] + if: ${{ needs.finalize_and_publish.result == 'success' }} + runs-on: linux-amd64 + steps: + - name: Checkout repository + shell: bash + env: + CI_MATCHA_GITEA_TOKEN: ${{ secrets.CI_MATCHA_GITEA_TOKEN }} + run: | + set -euo pipefail + git init . + git remote add origin "https://git.hiabuto.net/${{ github.repository }}.git" + if [ -n "${CI_MATCHA_GITEA_TOKEN:-}" ]; then + basic=$(python3 -c 'import base64, os; print(base64.b64encode(("matcha:" + os.environ["CI_MATCHA_GITEA_TOKEN"]).encode()).decode())') + git config --global http.https://git.hiabuto.net/.extraheader "AUTHORIZATION: basic ${basic}" + else + echo "CI_MATCHA_GITEA_TOKEN not set; trying unauthenticated checkout." + fi + git fetch --depth=50 origin "${{ github.ref_name }}" + git checkout --force "${{ github.sha }}" + + - name: Merge main into dev + bump dev0 + shell: bash + env: + CI_MATCHA_GITEA_TOKEN: ${{ secrets.CI_MATCHA_GITEA_TOKEN }} + run: | + set -euo pipefail + if [ -z "${CI_MATCHA_GITEA_TOKEN:-}" ]; then + echo "Missing secrets.CI_MATCHA_GITEA_TOKEN" >&2 + exit 1 + fi + + git config user.email "${CI_GIT_EMAIL}" + git config user.name "${CI_GIT_NAME}" + + git config --local --unset-all http.https://git.hiabuto.net/.extraheader || true + git config --global --unset-all http.https://git.hiabuto.net/.extraheader || true + git remote set-url origin "https://matcha:${CI_MATCHA_GITEA_TOKEN}@git.hiabuto.net/${GITEA_REPO}.git" + git fetch origin main dev + git checkout -B dev origin/dev + git merge origin/main -m "ci: merge main into dev [skip ci]" + + version_file=$(python3 - <<'PY' + import re + p='VERSION' + for line in open('ci-config.yaml', encoding='utf-8'): + if re.match(r'^\s*file\s*:', line): + p=line.split(':',1)[1].strip().strip('"\'') + break + print(p) + PY + ) + main_version=$(git show origin/main:"$version_file" | head -n1 | tr -d '\r\n' | xargs) + python3 -c "import re,sys; v=sys.argv[1].strip(); m=re.search(r'(\\d+)\\.(\\d+)\\.(\\d+)', v); (m is not None) or (_ for _ in ()).throw(SystemExit(f'Bad VERSION on main: {v!r}')); print(f'{m.group(1)}.{m.group(2)}.{m.group(3)}-dev0')" "$main_version" > "$version_file" + + git add "$version_file" + if git diff --cached --quiet; then + echo "No VERSION changes after sync."; + else + git commit -m "ci: bump dev version to $(cat "$version_file") [skip ci]" + fi + + git push origin HEAD:dev diff --git a/.gitignore b/.gitignore index 1791b5c..b4b7ba4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,11 @@ __pycache__/ .ruff_cache/ .venv/ build/ +!build/ +build/* +!build/ccma.spec +!build/build.ps1 +!build/build.bat dist/ .coverage htmlcov/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..aba95f4 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,5 @@ +[submodule "ci-templates"] + path = ci-templates + url = https://git.hiabuto.net/CI-Build/ci-templates.git + branch = main + update = checkout diff --git a/README.md b/README.md index 72ffe37..a2a4112 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,8 @@ also checks the default install locations under `%PROGRAMFILES%` and ## Building standalone executables -To bundle CCMA into a single-file Windows executable with PyInstaller, use the -build script in the `build/` directory: +CCMA uses the same PyInstaller-based release flow locally and in CI. The +single-file executables are built from `build/ccma.spec`. ### Windows standalone build @@ -76,9 +76,30 @@ This creates two executables in the `dist/` directory: The build script automatically: - Creates a venv if missing - Installs dependencies and PyInstaller -- Generates a Windows icon from the splash image +- Generates a Windows icon from the CCMA icon - Runs PyInstaller to bundle the application +### Linux standalone build + +```bash +python3 -m venv .venv +. .venv/bin/activate +python -m pip install -U pip +pip install -r requirements.txt +pip install pyinstaller +python -m PyInstaller --noconfirm --clean build/ccma.spec +``` + +This creates `dist/ccma`. In CI, release builds additionally rename artifacts +to `ccma--linux-` and `ccma--windows-.exe`. + +### CI release builds + +The Gitea workflows use the central `ci-templates` submodule and +`ci-config.yaml` to run PR checks, Linux smoke builds, and release packaging for +Linux amd64, Linux arm64, and Windows amd64. A release build is skipped when the +commit message contains `[nobuild]`. + On first start, select or create the central member-store directory. The `VERSION` file is the single source for application and package versions. diff --git a/build/build.bat b/build/build.bat new file mode 100644 index 0000000..a9db086 --- /dev/null +++ b/build/build.bat @@ -0,0 +1,16 @@ +@echo off +setlocal + +set SCRIPT_DIR=%~dp0 +powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%build.ps1" + +if errorlevel 1 ( + echo. + echo Build failed. + pause + exit /b 1 +) + +echo. +echo Build finished. +pause diff --git a/build/build.ps1 b/build/build.ps1 new file mode 100644 index 0000000..0264927 --- /dev/null +++ b/build/build.ps1 @@ -0,0 +1,98 @@ +[CmdletBinding()] +param( + [switch]$NoClean +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = Resolve-Path (Join-Path $scriptDir '..') +Set-Location $repoRoot + +function Get-VersionString { + $versionFile = Join-Path $repoRoot 'VERSION' + if (!(Test-Path $versionFile)) { return 'unknown' } + return (Get-Content $versionFile -Raw).Trim() +} + +function Sanitize-FilePart([string]$text) { + if ([string]::IsNullOrWhiteSpace($text)) { return 'unknown' } + $s = $text.Trim() + foreach ($ch in @('\','/','?',':','*','"','<','>','|')) { + $s = $s.Replace($ch, '_') + } + $s = $s -replace '\s+', '_' + return $s +} + +function Get-PlatformToken { + return "windows" +} + +function Get-ArchToken { + switch ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant()) { + "x64" { return "amd64" } + "arm64" { return "arm64" } + default { return ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant()) } + } +} + +$python = Join-Path $repoRoot '.venv\Scripts\python.exe' +if (!(Test-Path $python)) { + Write-Host "No .venv found. Creating venv..." -ForegroundColor Yellow + python -m venv .venv + if (!(Test-Path $python)) { + throw "Failed to create venv; expected at $python" + } +} + +Write-Host "Using Python: $python" -ForegroundColor Cyan + +& $python -m pip install -r requirements.txt +& $python -m pip install pyinstaller pillow + +$pngIcon = Join-Path $repoRoot 'src\ccma\assets\icons\ccma.png' +$icoOut = Join-Path $repoRoot 'build\app.ico' +if (Test-Path $pngIcon) { + Write-Host "Generating ICO: $icoOut" -ForegroundColor Cyan + & $python -c @" +from pathlib import Path +from PIL import Image +png = Path(r'$pngIcon') +ico = Path(r'$icoOut') +ico.parent.mkdir(parents=True, exist_ok=True) +img = Image.open(png).convert('RGBA') +img.save(ico, format='ICO', sizes=[(16,16),(24,24),(32,32),(48,48),(64,64),(128,128),(256,256)]) +print(str(ico)) +"@ +} else { + Write-Host "PNG icon not found at: $pngIcon (building without icon)" -ForegroundColor Yellow +} + +if (-not $NoClean) { + if (Test-Path (Join-Path $repoRoot 'dist')) { Remove-Item -Recurse -Force (Join-Path $repoRoot 'dist') } + if (Test-Path (Join-Path $repoRoot 'build\pyinstaller')) { Remove-Item -Recurse -Force (Join-Path $repoRoot 'build\pyinstaller') } +} + +Write-Host "Building with PyInstaller spec..." -ForegroundColor Cyan +& $python -m PyInstaller --noconfirm --clean --distpath dist --workpath build\pyinstaller build\ccma.spec + +$exe = Join-Path $repoRoot 'dist\ccma.exe' +if (!(Test-Path $exe)) { + throw "Build finished but exe not found at: $exe" +} + +$version = Get-VersionString +$versionSafe = Sanitize-FilePart $version +$platformToken = Get-PlatformToken +$archToken = Get-ArchToken +$versionedExe = Join-Path $repoRoot ("dist\ccma-$versionSafe-$platformToken-$archToken.exe") + +Copy-Item -Force $exe $versionedExe + +Write-Host "" +Write-Host "Build OK" -ForegroundColor Green +Write-Host "Base EXE: $exe" -ForegroundColor Green +Write-Host "Versioned EXE: $versionedExe" -ForegroundColor Green +Write-Host "Version: $version" -ForegroundColor Green diff --git a/build/ccma.spec b/build/ccma.spec new file mode 100644 index 0000000..6d82c0e --- /dev/null +++ b/build/ccma.spec @@ -0,0 +1,100 @@ +# -*- mode: python ; coding: utf-8 -*- +"""PyInstaller spec for CCMA. + +Packaged output: +- Windows: dist/ccma.exe +- Linux: dist/ccma + +Icon: +- On Windows the spec auto-generates build/app.ico from the PNG icon if it does + not already exist, so direct PyInstaller and scripted builds behave the same. +""" + +import sys +from pathlib import Path + +from PyInstaller.utils.hooks import collect_data_files + +block_cipher = None + +spec_dir = Path(globals().get("SPECPATH", Path.cwd())).resolve() +project_root = spec_dir.parent +entry_script = project_root / "main.py" +package_src = project_root / "src" / "ccma" +assets_src = package_src / "assets" +app_name_slug = "ccma" + +win_icon_path = project_root / "build" / "app.ico" +png_icon_path = assets_src / "icons" / "ccma.png" + +if sys.platform == "win32" and not win_icon_path.exists() and png_icon_path.exists(): + try: + from PIL import Image + + img = Image.open(png_icon_path).convert("RGBA") + win_icon_path.parent.mkdir(parents=True, exist_ok=True) + img.save( + win_icon_path, + format="ICO", + sizes=[(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)], + ) + print(f"[spec] Generated ICO: {win_icon_path}") + except Exception as exc: + print(f"[spec] WARNING: ICO generation failed: {exc}") + +win_icon = str(win_icon_path) if win_icon_path.exists() else None + +datas: list[tuple[str, str]] = [] +if assets_src.exists(): + datas.append((str(assets_src), "ccma/assets")) + +version_file = project_root / "VERSION" +if version_file.exists(): + datas.append((str(version_file), ".")) + datas.append((str(version_file), "ccma")) + +for optional_pkg in ("ttkbootstrap_icons", "ttkbootstrap_icons_mat"): + try: + datas += collect_data_files(optional_pkg) + except Exception: + pass + +hiddenimports = ["ttkbootstrap_icons", "ttkbootstrap_icons_mat"] + +a = Analysis( + [str(entry_script)], + pathex=[str(project_root), str(project_root / "src")], + binaries=[], + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name=app_name_slug, + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=win_icon, +) diff --git a/ci-config.yaml b/ci-config.yaml new file mode 100644 index 0000000..ef68a5d --- /dev/null +++ b/ci-config.yaml @@ -0,0 +1,43 @@ +# Repo-spezifische CI-Konfiguration fuer CI-Build/ci-templates. +branches: + dev: dev + main: main + +project: + type: python + +version: + type: file + file: VERSION + +changelog: + path: src/ccma/assets/CHANGELOG.json + required_for_dev_pr: true + no_changelog_markers: ["[no changelog]", "no changelog"] + +python: + binary: python3 + venv: .venv + requirements: requirements.txt + compile_path: src/ccma + test_paths: ["tests"] + check: + ruff: true + pytest: true + extra_pip: ["ruff", "pytest"] + +package: + type: pyinstaller + pyinstaller: + spec: build/ccma.spec + arch: amd64 + artifacts: + linux: + source: dist/ccma + rename: ccma-{version}-linux-{arch} + windows: + source: dist/ccma.exe + rename: ccma-{version}-windows-{arch}.exe + +release: + patch_markers: ["[patch]", "[hotfix]"] diff --git a/ci-templates b/ci-templates new file mode 160000 index 0000000..6891ac3 --- /dev/null +++ b/ci-templates @@ -0,0 +1 @@ +Subproject commit 6891ac36d4e6c41d98f786d1d491920c89db0a9e diff --git a/pyproject.toml b/pyproject.toml index 8532a45..c0e013d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ pythonpath = ["src"] [tool.ruff] line-length = 110 target-version = "py311" +extend-exclude = ["ci-templates"] [tool.ruff.lint] select = ["E", "F", "I", "UP", "B"] diff --git a/src/ccma/config.py b/src/ccma/config.py index 67f0677..f42c726 100644 --- a/src/ccma/config.py +++ b/src/ccma/config.py @@ -71,7 +71,7 @@ class AppConfig: def config_directory() -> Path: - override = os.environ.get("CCMA_CONFIG_DIR") or os.environ.get("C3MA_CONFIG_DIR") + override = os.environ.get("CCMA_CONFIG_DIR") if override: return Path(override).expanduser() if os.name == "nt": @@ -81,11 +81,7 @@ def config_directory() -> Path: def load_config() -> AppConfig: path = config_directory() / "config.json" - if not path.exists() and not (os.environ.get("CCMA_CONFIG_DIR") or os.environ.get("C3MA_CONFIG_DIR")): - legacy_path = _legacy_config_directory() / "config.json" - if legacy_path.exists(): - path = legacy_path - store_override = os.environ.get("CCMA_STORE") or os.environ.get("C3MA_STORE", "") + store_override = os.environ.get("CCMA_STORE", "") if not path.exists(): return AppConfig(store_path=store_override) try: @@ -113,12 +109,6 @@ def load_config() -> AppConfig: return AppConfig(store_path=store_override) -def _legacy_config_directory() -> Path: - if os.name == "nt": - return Path(os.environ.get("APPDATA", Path.home())) / "C3MA" - return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "c3ma" - - def _non_negative_float(value: object, default: float) -> float: try: parsed = float(value) diff --git a/src/ccma/services/documents.py b/src/ccma/services/documents.py index 5d1ad77..a17bac2 100644 --- a/src/ccma/services/documents.py +++ b/src/ccma/services/documents.py @@ -511,9 +511,9 @@ def _office_executable() -> str | None: if value and value not in windows_roots: windows_roots.append(value) for root in windows_roots: - candidate = Path(root) / "LibreOffice" / "program" / "soffice.exe" - if candidate.is_file(): - return str(candidate) + candidate = os.path.join(root, "LibreOffice", "program", "soffice.exe") + if os.path.isfile(candidate): + return candidate return None diff --git a/tests/test_config.py b/tests/test_config.py index d370931..07df4c0 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -39,10 +39,8 @@ def test_splash_minimum_defaults_to_five_and_is_clamped(tmp_path, monkeypatch) - assert load_config().splash_minimum_seconds == 0.0 -def test_legacy_c3ma_environment_variables_are_still_read(tmp_path, monkeypatch) -> None: - monkeypatch.delenv("CCMA_CONFIG_DIR", raising=False) - monkeypatch.delenv("CCMA_STORE", raising=False) - monkeypatch.setenv("C3MA_CONFIG_DIR", str(tmp_path / "legacy-config")) - monkeypatch.setenv("C3MA_STORE", str(tmp_path / "legacy-store")) +def test_store_path_can_be_overridden_from_ccma_environment(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("CCMA_CONFIG_DIR", str(tmp_path / "config")) + monkeypatch.setenv("CCMA_STORE", str(tmp_path / "store")) - assert load_config().store_path == str(tmp_path / "legacy-store") + assert load_config().store_path == str(tmp_path / "store")