ci: add CCMA release builds

This commit is contained in:
Marcel Peterkau
2026-06-23 20:19:53 +02:00
parent 302170230a
commit 0e3087a780
16 changed files with 842 additions and 24 deletions
+40
View File
@@ -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
+42
View File
@@ -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
+106
View File
@@ -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
+352
View File
@@ -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
+5
View File
@@ -5,6 +5,11 @@ __pycache__/
.ruff_cache/
.venv/
build/
!build/
build/*
!build/ccma.spec
!build/build.ps1
!build/build.bat
dist/
.coverage
htmlcov/
+5
View File
@@ -0,0 +1,5 @@
[submodule "ci-templates"]
path = ci-templates
url = https://git.hiabuto.net/CI-Build/ci-templates.git
branch = main
update = checkout
+24 -3
View File
@@ -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-<version>-linux-<arch>` and `ccma-<version>-windows-<arch>.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.
+16
View File
@@ -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
+98
View File
@@ -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
+100
View File
@@ -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,
)
+43
View File
@@ -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]"]
Submodule
+1
Submodule ci-templates added at 6891ac36d4
+1
View File
@@ -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"]
+2 -12
View File
@@ -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)
+3 -3
View File
@@ -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
+4 -6
View File
@@ -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")