Enhance branding and release workflows

- Updated README.md to include a reference to branding and releases documentation.
- Modified brand_config.py to support multi-brand packaging, including functions for collecting local release data and merging release manifests.
- Adjusted build_macos.sh to set a default brand if none is specified and updated DMG naming conventions.
- Enhanced create_release.ps1 and create_release.sh scripts to support dry-run functionality and improved artifact handling.
- Added a new template for brand configuration in build/brands/template.jsonc.
- Created comprehensive branding and releases documentation in docs/BRANDING_AND_RELEASES.md.
- Added unit tests for new branding functionalities in test_brand_config.py.
This commit is contained in:
claudi 2026-03-12 08:38:40 +01:00
parent fd69996c53
commit 67bfe4a600
8 changed files with 923 additions and 82 deletions

View file

@ -77,6 +77,8 @@ wget https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.0
For more installation options and details, see [QUICKSTART.md](QUICKSTART.md#installing-from-release-wget) and [PACKAGE_MANAGER_SUPPORT.md](docs/PACKAGE_MANAGER_SUPPORT.md)
For multi-brand packaging and release workflows, see [BRANDING_AND_RELEASES.md](docs/BRANDING_AND_RELEASES.md).
### Installation from Source
```bash

View file

@ -0,0 +1,20 @@
{
// Copy this file to build/brands/<your-brand>.json (without comments)
// and replace values.
"brand_id": "your_brand_id",
"display_name": "Your Brand Bridge",
"asset_prefix": "YourBrandBridge",
"exe_name": "YourBrandBridge",
"manufacturer": "Your Company",
"install_dir_name": "Your Brand Bridge",
"shortcut_description": "Your brand drag-and-drop bridge",
"bundle_identifier": "com.yourcompany.bridge",
"config_dir_name": "your_brand_bridge",
"msi_upgrade_code": "00000000-0000-0000-0000-000000000000",
"update_channel": "stable",
"icon_ico": "resources/icons/app.ico",
"icon_icns": "resources/icons/app.icns",
"dialog_bmp": "resources/icons/background.bmp",
"banner_bmp": "resources/icons/banner.bmp",
"license_rtf": "resources/license.rtf"
}

View file

@ -60,6 +60,8 @@ DEFAULT_BRAND_VALUES: dict[str, Any] = {
"license_rtf": "resources/license.rtf",
}
DEFAULT_BRAND_ID = str(DEFAULT_BRAND_VALUES["brand_id"])
def project_root() -> Path:
return Path(__file__).resolve().parents[2]
@ -70,6 +72,18 @@ def brands_dir(root: Path | None = None) -> Path:
return base / "build" / "brands"
def available_brand_names(root: Path | None = None) -> list[str]:
"""Return all supported brand names, including the default build."""
base = root or project_root()
names = [DEFAULT_BRAND_ID]
manifest_dir = brands_dir(base)
if manifest_dir.exists():
for manifest in sorted(manifest_dir.glob("*.json")):
if manifest.stem not in names:
names.append(manifest.stem)
return names
def load_brand_config(
brand: str | None = None,
*,
@ -80,7 +94,7 @@ def load_brand_config(
base = root or project_root()
values = dict(DEFAULT_BRAND_VALUES)
if manifest_path is None and brand:
if manifest_path is None and brand and brand != DEFAULT_BRAND_ID:
manifest_path = brands_dir(base) / f"{brand}.json"
if manifest_path and manifest_path.exists():
@ -160,6 +174,92 @@ def generate_release_manifest(
return output_path
def merge_release_manifests(
base_manifest: dict[str, Any], overlay_manifest: dict[str, Any]
) -> dict[str, Any]:
"""Merge two release manifests, preserving previously uploaded platforms."""
merged: dict[str, Any] = {
"version": overlay_manifest.get("version") or base_manifest.get("version", ""),
"channel": overlay_manifest.get("channel") or base_manifest.get("channel", "stable"),
"brands": dict(base_manifest.get("brands", {})),
}
for brand_id, entries in overlay_manifest.get("brands", {}).items():
brand_entry = dict(merged["brands"].get(brand_id, {}))
for platform_key, platform_value in entries.items():
if platform_value:
brand_entry[platform_key] = platform_value
merged["brands"][brand_id] = brand_entry
return merged
def collect_local_release_data(
version: str,
*,
platform: str,
root: Path | None = None,
brands: list[str] | None = None,
) -> dict[str, Any]:
"""Collect local artifacts and manifest entries for the requested platform."""
base = root or project_root()
selected_brands = brands or available_brand_names(base)
release_manifest: dict[str, Any] = {
"version": version,
"channel": "stable",
"brands": {},
}
artifacts: list[str] = []
found_brands: list[str] = []
for brand_name in selected_brands:
brand = load_brand_config(brand_name, root=base)
release_manifest["channel"] = brand.update_channel
if platform == "windows":
artifact_dir = base / "build" / "dist" / "windows" / brand.brand_id
installer = artifact_dir / brand.windows_installer_name(version)
checksum = artifact_dir / f"{installer.name}.sha256"
platform_key = "windows-x64"
elif platform == "macos":
artifact_dir = base / "build" / "dist" / "macos" / brand.brand_id
installer = artifact_dir / brand.macos_installer_name(version)
checksum = artifact_dir / f"{installer.name}.sha256"
platform_key = "macos-universal"
if not installer.exists() and brand.brand_id == DEFAULT_BRAND_ID:
legacy_installer = (base / "build" / "dist" / "macos") / brand.macos_installer_name(
version
)
legacy_checksum = legacy_installer.parent / f"{legacy_installer.name}.sha256"
if legacy_installer.exists():
installer = legacy_installer
checksum = legacy_checksum
else:
raise ValueError(f"Unsupported platform: {platform}")
if not installer.exists():
continue
found_brands.append(brand.brand_id)
artifacts.append(str(installer))
if checksum.exists():
artifacts.append(str(checksum))
release_manifest["brands"].setdefault(brand.brand_id, {})[platform_key] = {
"installer": installer.name,
"checksum": checksum.name if checksum.exists() else "",
}
return {
"version": version,
"platform": platform,
"brands": found_brands,
"artifacts": artifacts,
"manifest": release_manifest,
}
def cli_env(args: argparse.Namespace) -> int:
brand = load_brand_config(args.brand)
assignments = {
@ -187,6 +287,26 @@ def cli_manifest(args: argparse.Namespace) -> int:
return 0
def cli_local_release_data(args: argparse.Namespace) -> int:
data = collect_local_release_data(
args.version,
platform=args.platform,
brands=args.brands,
)
print(json.dumps(data, indent=2))
return 0
def cli_merge_manifests(args: argparse.Namespace) -> int:
base_manifest = json.loads(Path(args.base).read_text(encoding="utf-8"))
overlay_manifest = json.loads(Path(args.overlay).read_text(encoding="utf-8"))
merged = merge_release_manifests(base_manifest, overlay_manifest)
output_path = Path(args.output)
output_path.write_text(json.dumps(merged, indent=2), encoding="utf-8")
print(output_path)
return 0
def cli_show(args: argparse.Namespace) -> int:
brand = load_brand_config(args.brand)
print(
@ -224,6 +344,18 @@ def main() -> int:
manifest_parser.add_argument("--brands", nargs="+", required=True)
manifest_parser.set_defaults(func=cli_manifest)
local_parser = subparsers.add_parser("local-release-data")
local_parser.add_argument("--version", required=True)
local_parser.add_argument("--platform", choices=["windows", "macos"], required=True)
local_parser.add_argument("--brands", nargs="+")
local_parser.set_defaults(func=cli_local_release_data)
merge_parser = subparsers.add_parser("merge-manifests")
merge_parser.add_argument("--base", required=True)
merge_parser.add_argument("--overlay", required=True)
merge_parser.add_argument("--output", required=True)
merge_parser.set_defaults(func=cli_merge_manifests)
show_parser = subparsers.add_parser("show")
show_parser.add_argument("--brand", required=True)
show_parser.set_defaults(func=cli_show)

View file

@ -77,13 +77,18 @@ fi
echo "📋 Using configuration: $ENV_FILE"
if [ -n "$BRAND" ]; then
eval "$(python3 "$BRAND_HELPER" env --brand "$BRAND")"
APP_NAME="$WEBDROP_ASSET_PREFIX"
DMG_VOLUME_NAME="$WEBDROP_APP_DISPLAY_NAME"
BUNDLE_IDENTIFIER="$WEBDROP_BUNDLE_ID"
DIST_DIR="$BUILD_DIR/dist/macos/$WEBDROP_BRAND_ID"
TEMP_BUILD="$BUILD_DIR/temp/macos/$WEBDROP_BRAND_ID"
if [ -z "$BRAND" ]; then
BRAND="webdrop_bridge"
fi
eval "$(python3 "$BRAND_HELPER" env --brand "$BRAND")"
APP_NAME="$WEBDROP_ASSET_PREFIX"
DMG_VOLUME_NAME="$WEBDROP_APP_DISPLAY_NAME"
BUNDLE_IDENTIFIER="$WEBDROP_BUNDLE_ID"
DIST_DIR="$BUILD_DIR/dist/macos/$WEBDROP_BRAND_ID"
TEMP_BUILD="$BUILD_DIR/temp/macos/$WEBDROP_BRAND_ID"
if [ -n "$WEBDROP_APP_DISPLAY_NAME" ]; then
echo "🏷️ Building brand: $WEBDROP_APP_DISPLAY_NAME ($WEBDROP_BRAND_ID)"
fi
@ -220,7 +225,7 @@ create_dmg() {
log_info "Creating DMG package..."
echo ""
DMG_FILE="$DIST_DIR/${APP_NAME}-${VERSION}.dmg"
DMG_FILE="$DIST_DIR/${APP_NAME}-${VERSION}-macos-universal.dmg"
# Remove existing DMG
if [ -f "$DMG_FILE" ]; then

View file

@ -3,7 +3,7 @@ param(
[string]$Version,
[Parameter(Mandatory = $false)]
[string[]]$Brands = @("agravity"),
[string[]]$Brands,
[Parameter(Mandatory = $false)]
[string]$ForgejoUser,
@ -12,6 +12,7 @@ param(
[string]$ForgejoPW,
[switch]$ClearCredentials,
[switch]$DryRun,
[string]$ForgejoUrl = "https://git.him-tools.de",
[string]$Repo = "HIM-public/webdrop-bridge"
@ -26,11 +27,32 @@ if (-not (Test-Path $pythonExe)) {
$brandHelper = Join-Path $projectRoot "build\scripts\brand_config.py"
$manifestOutput = Join-Path $projectRoot "build\dist\release-manifest.json"
$localManifestPath = Join-Path $projectRoot "build\dist\release-manifest.local.json"
$existingManifestPath = Join-Path $projectRoot "build\dist\release-manifest.existing.json"
function Get-CurrentVersion {
return (& $pythonExe -c "from pathlib import Path; import sys; sys.path.insert(0, str(Path(r'$projectRoot/build/scripts').resolve())); from version_utils import get_current_version; print(get_current_version())").Trim()
}
function Get-LocalReleaseData {
$arguments = @($brandHelper, "local-release-data", "--platform", "windows", "--version", $Version)
if ($Brands) {
$arguments += "--brands"
$arguments += $Brands
}
return (& $pythonExe @arguments | ConvertFrom-Json)
}
function Get-AssetMap {
param([object[]]$Assets)
$map = @{}
foreach ($asset in ($Assets | Where-Object { $_ })) {
$map[$asset.name] = $asset
}
return $map
}
if ($ClearCredentials) {
Remove-Item env:FORGEJO_USER -ErrorAction SilentlyContinue
Remove-Item env:FORGEJO_PASS -ErrorAction SilentlyContinue
@ -38,6 +60,44 @@ if ($ClearCredentials) {
exit 0
}
if (-not $Version) {
$Version = Get-CurrentVersion
}
$localData = Get-LocalReleaseData
$artifactPaths = New-Object System.Collections.Generic.List[string]
foreach ($artifact in $localData.artifacts) {
$artifactPaths.Add([string]$artifact)
if ((Test-Path $artifact) -and ((Get-Item $artifact).Extension -eq ".msi")) {
$msiSize = (Get-Item $artifact).Length / 1MB
Write-Host "Windows artifact: $([System.IO.Path]::GetFileName($artifact)) ($([math]::Round($msiSize, 2)) MB)"
}
}
if ($artifactPaths.Count -eq 0) {
Write-Host "ERROR: No local Windows artifacts found" -ForegroundColor Red
exit 1
}
$localData.manifest | ConvertTo-Json -Depth 10 | Set-Content -Path $localManifestPath -Encoding utf8
if ($DryRun) {
Copy-Item $localManifestPath $manifestOutput -Force
$brandsText = if ($localData.brands.Count -gt 0) { $localData.brands -join ", " } else { "<none>" }
Write-Host "[DRY RUN] No network requests or uploads will be performed." -ForegroundColor Yellow
Write-Host "[DRY RUN] Release tag: v$Version"
Write-Host "[DRY RUN] Release URL: $ForgejoUrl/$Repo/releases/tag/v$Version"
Write-Host "[DRY RUN] Discovered brands: $brandsText"
Write-Host "[DRY RUN] Artifacts that would be uploaded:"
foreach ($artifact in $artifactPaths) {
Write-Host " - $artifact"
}
Write-Host "[DRY RUN] Local manifest preview: $manifestOutput"
exit 0
}
if (-not $ForgejoUser) {
$ForgejoUser = $env:FORGEJO_USER
}
@ -58,36 +118,6 @@ if (-not $ForgejoUser -or -not $ForgejoPW) {
$env:FORGEJO_PASS = $ForgejoPW
}
if (-not $Version) {
$Version = Get-CurrentVersion
}
$artifactPaths = New-Object System.Collections.Generic.List[string]
foreach ($brand in $Brands) {
$brandJson = & $pythonExe $brandHelper show --brand $brand | ConvertFrom-Json
$msiPath = Join-Path $projectRoot "build\dist\windows\$($brandJson.brand_id)\$($brandJson.asset_prefix)-$Version-win-x64.msi"
$checksumPath = "$msiPath.sha256"
if (Test-Path $msiPath) {
$artifactPaths.Add($msiPath)
if (Test-Path $checksumPath) {
$artifactPaths.Add($checksumPath)
}
$msiSize = (Get-Item $msiPath).Length / 1MB
Write-Host "Windows artifact: $([System.IO.Path]::GetFileName($msiPath)) ($([math]::Round($msiSize, 2)) MB)"
}
}
& $pythonExe $brandHelper release-manifest --version $Version --output $manifestOutput --brands $Brands | Out-Null
if (Test-Path $manifestOutput) {
$artifactPaths.Add($manifestOutput)
}
if ($artifactPaths.Count -eq 0) {
Write-Host "ERROR: No Windows artifacts found for the requested brands" -ForegroundColor Red
exit 1
}
$auth = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes("${ForgejoUser}:${ForgejoPW}"))
$headers = @{
"Authorization" = "Basic $auth"
@ -105,28 +135,46 @@ $releaseData = @{
} | ConvertTo-Json
try {
$lookupResponse = Invoke-WebRequest -Uri $releaseLookupUrl -Method GET -Headers $headers -TimeoutSec 30 -UseBasicParsing -ErrorAction Stop
$releaseInfo = $lookupResponse.Content | ConvertFrom-Json
$releaseInfo = Invoke-RestMethod -Uri $releaseLookupUrl -Method GET -Headers $headers -TimeoutSec 30 -ErrorAction Stop
$releaseId = $releaseInfo.id
Write-Host "[OK] Using existing release (ID: $releaseId)" -ForegroundColor Green
}
catch {
$response = Invoke-WebRequest -Uri $releaseUrl -Method POST -Headers $headers -Body $releaseData -TimeoutSec 30 -UseBasicParsing -ErrorAction Stop
$releaseInfo = $response.Content | ConvertFrom-Json
$releaseInfo = Invoke-RestMethod -Uri $releaseUrl -Method POST -Headers $headers -Body $releaseData -TimeoutSec 30 -ErrorAction Stop
$releaseId = $releaseInfo.id
Write-Host "[OK] Release created (ID: $releaseId)" -ForegroundColor Green
}
$assetMap = Get-AssetMap -Assets $releaseInfo.assets
if ($assetMap.ContainsKey("release-manifest.json")) {
Invoke-WebRequest -Uri $assetMap["release-manifest.json"].browser_download_url -Method GET -Headers $headers -TimeoutSec 30 -OutFile $existingManifestPath | Out-Null
& $pythonExe $brandHelper merge-manifests --base $existingManifestPath --overlay $localManifestPath --output $manifestOutput | Out-Null
}
else {
Copy-Item $localManifestPath $manifestOutput -Force
}
$artifactPaths.Add($manifestOutput)
$assetMap = Get-AssetMap -Assets $releaseInfo.assets
$curlAuth = "$ForgejoUser`:$ForgejoPW"
$uploadUrl = "$ForgejoUrl/api/v1/repos/$Repo/releases/$releaseId/assets"
foreach ($artifact in $artifactPaths) {
$assetName = [System.IO.Path]::GetFileName($artifact)
if ($assetMap.ContainsKey($assetName)) {
$existingAsset = $assetMap[$assetName]
Invoke-RestMethod -Uri "$ForgejoUrl/api/v1/repos/$Repo/releases/$releaseId/assets/$($existingAsset.id)" -Method DELETE -Headers $headers -TimeoutSec 30 | Out-Null
Write-Host "[OK] Replaced existing asset $assetName" -ForegroundColor Yellow
}
$response = curl.exe -s -X POST -u $curlAuth -F "attachment=@$artifact" $uploadUrl
if ($response -like "*error*" -or $response -like "*404*") {
Write-Host "WARNING: Could not upload $artifact : $response" -ForegroundColor Yellow
}
else {
Write-Host "[OK] Uploaded $([System.IO.Path]::GetFileName($artifact))" -ForegroundColor Green
Write-Host "[OK] Uploaded $assetName" -ForegroundColor Green
}
}

View file

@ -4,15 +4,19 @@
set -e
VERSION=""
BRANDS=("agravity")
BRANDS=()
FORGEJO_USER="${FORGEJO_USER}"
FORGEJO_PASS="${FORGEJO_PASS}"
FORGEJO_URL="https://git.him-tools.de"
REPO="HIM-public/webdrop-bridge"
CLEAR_CREDS=false
DRY_RUN=false
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
BRAND_HELPER="$PROJECT_ROOT/build/scripts/brand_config.py"
MANIFEST_OUTPUT="$PROJECT_ROOT/build/dist/release-manifest.json"
LOCAL_MANIFEST_OUTPUT="$PROJECT_ROOT/build/dist/release-manifest.local.json"
EXISTING_MANIFEST_OUTPUT="$PROJECT_ROOT/build/dist/release-manifest.existing.json"
LOCAL_DATA_OUTPUT="$PROJECT_ROOT/build/dist/release-data.local.json"
while [[ $# -gt 0 ]]; do
case $1 in
@ -20,14 +24,11 @@ while [[ $# -gt 0 ]]; do
-u|--url) FORGEJO_URL="$2"; shift 2 ;;
--brand) BRANDS+=("$2"); shift 2 ;;
--clear-credentials) CLEAR_CREDS=true; shift ;;
--dry-run) DRY_RUN=true; shift ;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
done
if [ ${#BRANDS[@]} -gt 1 ] && [ "${BRANDS[0]}" = "agravity" ]; then
BRANDS=("${BRANDS[@]:1}")
fi
if [ "$CLEAR_CREDS" = true ]; then
unset FORGEJO_USER
unset FORGEJO_PASS
@ -39,6 +40,61 @@ if [ -z "$VERSION" ]; then
VERSION="$(python3 -c "from pathlib import Path; import sys; sys.path.insert(0, str(Path(r'$PROJECT_ROOT/build/scripts').resolve())); from version_utils import get_current_version; print(get_current_version())")"
fi
LOCAL_ARGS=("$BRAND_HELPER" "local-release-data" "--platform" "macos" "--version" "$VERSION")
if [ ${#BRANDS[@]} -gt 0 ]; then
LOCAL_ARGS+=("--brands" "${BRANDS[@]}")
fi
python3 "${LOCAL_ARGS[@]}" > "$LOCAL_DATA_OUTPUT"
mapfile -t ARTIFACTS < <(python3 - "$LOCAL_DATA_OUTPUT" "$LOCAL_MANIFEST_OUTPUT" <<'PY'
import json
import sys
from pathlib import Path
data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
Path(sys.argv[2]).write_text(json.dumps(data["manifest"], indent=2), encoding="utf-8")
for artifact in data["artifacts"]:
print(artifact)
PY
)
for ARTIFACT in "${ARTIFACTS[@]}"; do
if [ -f "$ARTIFACT" ] && [ "${ARTIFACT##*.}" = "dmg" ]; then
DMG_SIZE=$(du -m "$ARTIFACT" | cut -f1)
echo "macOS artifact: $(basename "$ARTIFACT") ($DMG_SIZE MB)"
fi
done
if [ ${#ARTIFACTS[@]} -eq 0 ]; then
echo "ERROR: No local macOS artifacts found"
exit 1
fi
if [ "$DRY_RUN" = true ]; then
cp "$LOCAL_MANIFEST_OUTPUT" "$MANIFEST_OUTPUT"
DISCOVERED_BRANDS=$(python3 - "$LOCAL_DATA_OUTPUT" <<'PY'
import json
import sys
from pathlib import Path
data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
print(", ".join(data.get("brands", [])) or "<none>")
PY
)
echo "[DRY RUN] No network requests or uploads will be performed."
echo "[DRY RUN] Release tag: v$VERSION"
echo "[DRY RUN] Release URL: $FORGEJO_URL/$REPO/releases/tag/v$VERSION"
echo "[DRY RUN] Discovered brands: $DISCOVERED_BRANDS"
echo "[DRY RUN] Artifacts that would be uploaded:"
for ARTIFACT in "${ARTIFACTS[@]}"; do
echo " - $ARTIFACT"
done
echo "[DRY RUN] Local manifest preview: $MANIFEST_OUTPUT"
exit 0
fi
if [ -z "$FORGEJO_USER" ] || [ -z "$FORGEJO_PASS" ]; then
echo "Forgejo credentials not found. Enter your credentials:"
if [ -z "$FORGEJO_USER" ]; then
@ -52,36 +108,25 @@ if [ -z "$FORGEJO_USER" ] || [ -z "$FORGEJO_PASS" ]; then
export FORGEJO_PASS
fi
ARTIFACTS=()
for BRAND in "${BRANDS[@]}"; do
BRAND_JSON=$(python3 "$BRAND_HELPER" show --brand "$BRAND")
BRAND_ID=$(printf '%s' "$BRAND_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["brand_id"])')
ASSET_PREFIX=$(printf '%s' "$BRAND_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["asset_prefix"])')
DMG_PATH="$PROJECT_ROOT/build/dist/macos/$BRAND_ID/${ASSET_PREFIX}-${VERSION}-macos-universal.dmg"
CHECKSUM_PATH="$DMG_PATH.sha256"
if [ -f "$DMG_PATH" ]; then
ARTIFACTS+=("$DMG_PATH")
[ -f "$CHECKSUM_PATH" ] && ARTIFACTS+=("$CHECKSUM_PATH")
DMG_SIZE=$(du -m "$DMG_PATH" | cut -f1)
echo "macOS artifact: $(basename "$DMG_PATH") ($DMG_SIZE MB)"
fi
done
python3 "$BRAND_HELPER" release-manifest --version "$VERSION" --output "$MANIFEST_OUTPUT" --brands "${BRANDS[@]}" >/dev/null
[ -f "$MANIFEST_OUTPUT" ] && ARTIFACTS+=("$MANIFEST_OUTPUT")
if [ ${#ARTIFACTS[@]} -eq 0 ]; then
echo "ERROR: No macOS artifacts found"
exit 1
fi
BASIC_AUTH=$(echo -n "${FORGEJO_USER}:${FORGEJO_PASS}" | base64)
RELEASE_URL="$FORGEJO_URL/api/v1/repos/$REPO/releases"
RELEASE_LOOKUP_URL="$FORGEJO_URL/api/v1/repos/$REPO/releases/tags/v$VERSION"
RESPONSE=$(curl -s -H "Authorization: Basic $BASIC_AUTH" "$RELEASE_LOOKUP_URL")
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
RELEASE_RESPONSE_FILE=$(mktemp)
HTTP_CODE=$(curl -s -o "$RELEASE_RESPONSE_FILE" -w "%{http_code}" -H "Authorization: Basic $BASIC_AUTH" "$RELEASE_LOOKUP_URL")
if [ "$HTTP_CODE" = "200" ]; then
RELEASE_ID=$(python3 - "$RELEASE_RESPONSE_FILE" <<'PY'
import json
import sys
from pathlib import Path
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
print(payload.get("id", ""))
PY
)
else
RELEASE_ID=""
fi
if [ -z "$RELEASE_ID" ]; then
RELEASE_DATA=$(cat <<EOF
@ -94,22 +139,76 @@ if [ -z "$RELEASE_ID" ]; then
}
EOF
)
RESPONSE=$(curl -s -X POST \
HTTP_CODE=$(curl -s -o "$RELEASE_RESPONSE_FILE" -w "%{http_code}" -X POST \
-H "Authorization: Basic $BASIC_AUTH" \
-H "Content-Type: application/json" \
-d "$RELEASE_DATA" \
"$RELEASE_URL")
RELEASE_ID=$(echo "$RESPONSE" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "201" ]; then
RELEASE_ID=$(python3 - "$RELEASE_RESPONSE_FILE" <<'PY'
import json
import sys
from pathlib import Path
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
print(payload.get("id", ""))
PY
)
fi
fi
if [ -z "$RELEASE_ID" ]; then
echo "ERROR creating or finding release"
echo "$RESPONSE"
cat "$RELEASE_RESPONSE_FILE"
exit 1
fi
MANIFEST_URL=$(python3 - "$RELEASE_RESPONSE_FILE" <<'PY'
import json
import sys
from pathlib import Path
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
for asset in payload.get("assets", []):
if asset.get("name") == "release-manifest.json":
print(asset.get("browser_download_url", ""))
break
PY
)
if [ -n "$MANIFEST_URL" ]; then
curl -s -H "Authorization: Basic $BASIC_AUTH" "$MANIFEST_URL" -o "$EXISTING_MANIFEST_OUTPUT"
python3 "$BRAND_HELPER" merge-manifests --base "$EXISTING_MANIFEST_OUTPUT" --overlay "$LOCAL_MANIFEST_OUTPUT" --output "$MANIFEST_OUTPUT" >/dev/null
else
cp "$LOCAL_MANIFEST_OUTPUT" "$MANIFEST_OUTPUT"
fi
ARTIFACTS+=("$MANIFEST_OUTPUT")
UPLOAD_URL="$FORGEJO_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets"
for ARTIFACT in "${ARTIFACTS[@]}"; do
ASSET_NAME="$(basename "$ARTIFACT")"
EXISTING_ASSET_ID=$(python3 - "$RELEASE_RESPONSE_FILE" "$ASSET_NAME" <<'PY'
import json
import sys
from pathlib import Path
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
asset_name = sys.argv[2]
for asset in payload.get("assets", []):
if asset.get("name") == asset_name:
print(asset.get("id", ""))
break
PY
)
if [ -n "$EXISTING_ASSET_ID" ]; then
curl -s -X DELETE \
-H "Authorization: Basic $BASIC_AUTH" \
"$FORGEJO_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets/$EXISTING_ASSET_ID" >/dev/null
echo "[OK] Replaced existing asset $ASSET_NAME"
fi
HTTP_CODE=$(curl -s -w "%{http_code}" -X POST \
-H "Authorization: Basic $BASIC_AUTH" \
-F "attachment=@$ARTIFACT" \
@ -117,9 +216,9 @@ for ARTIFACT in "${ARTIFACTS[@]}"; do
-o /tmp/curl_response.txt)
if [ "$HTTP_CODE" -eq 201 ] || [ "$HTTP_CODE" -eq 200 ]; then
echo "[OK] Uploaded $(basename "$ARTIFACT")"
echo "[OK] Uploaded $ASSET_NAME"
else
echo "ERROR uploading $(basename "$ARTIFACT") (HTTP $HTTP_CODE)"
echo "ERROR uploading $ASSET_NAME (HTTP $HTTP_CODE)"
cat /tmp/curl_response.txt
exit 1
fi

View file

@ -0,0 +1,469 @@
# Branding, Builds, and Releases
This document describes how branded builds work in this repository, how to add or edit a brand, how to build the default and branded variants, and how to publish releases.
## Overview
The project supports one default product and any number of branded variants from the same codebase.
- The default product is defined by built-in defaults in `build/scripts/brand_config.py`.
- The default product identifier is `webdrop_bridge`.
- Additional brands are defined by JSON manifests in `build/brands/`.
- Runtime behavior can also be branded through application config values such as `brand_id`, `config_dir_name`, `app_name`, and update settings.
- Windows and macOS installers are built as separate artifacts per brand.
- Releases are shared by version. A single Forgejo release can contain installers for the default product and multiple brands.
## Branding Model
There are two layers to branding:
1. Packaging identity
Controls installer name, executable/app bundle name, product display name, bundle identifier, MSI upgrade code, installer artwork, and related metadata.
2. Runtime configuration
Controls app name shown in the UI, config directory name, update feed settings, URL mappings, allowed roots, and similar application behavior.
Packaging identity lives in `build/brands/<brand>.json`.
Runtime configuration lives in app config files loaded by the application. See `config.example.json` for the current branded example.
## Important Files
- `build/scripts/brand_config.py`: central helper for brand metadata, artifact naming, and release manifest generation
- `build/brands/agravity.json`: example branded manifest
- `build/scripts/build_windows.py`: Windows build entrypoint
- `build/scripts/build_macos.sh`: macOS build entrypoint
- `build/scripts/create_release.ps1`: Windows release uploader
- `build/scripts/create_release.sh`: macOS release uploader
- `config.example.json`: example runtime branding config
## Create a New Brand
To create a new brand, add a new manifest file under `build/brands/`.
Example:
1. Copy `build/brands/template.jsonc` to `build/brands/<new-brand>.json`.
2. Update the values for the new brand.
3. Add any brand-specific assets if you do not want to reuse the default icons/license assets.
Minimal example:
```json
{
"brand_id": "customerx",
"display_name": "Customer X Bridge",
"asset_prefix": "CustomerXBridge",
"exe_name": "CustomerXBridge",
"manufacturer": "Customer X",
"install_dir_name": "Customer X Bridge",
"shortcut_description": "Customer X drag-and-drop bridge",
"bundle_identifier": "com.customerx.bridge",
"config_dir_name": "customerx_bridge",
"msi_upgrade_code": "PUT-A-NEW-GUID-HERE",
"update_channel": "stable",
"icon_ico": "resources/icons/app.ico",
"icon_icns": "resources/icons/app.icns",
"dialog_bmp": "resources/icons/background.bmp",
"banner_bmp": "resources/icons/banner.bmp",
"license_rtf": "resources/license.rtf"
}
```
### Required Fields
- `brand_id`: internal identifier used for build output folders and release manifest entries
- `display_name`: user-facing product name
- `asset_prefix`: base name for installer artifacts and app bundle name
- `exe_name`: executable name for Windows and app bundle name base for macOS
- `manufacturer`: MSI manufacturer string
- `install_dir_name`: installation directory name shown to the OS
- `shortcut_description`: Windows shortcut description
- `bundle_identifier`: macOS bundle identifier
- `config_dir_name`: local app config/log/cache directory name
- `msi_upgrade_code`: stable GUID for Windows upgrades
- `update_channel`: currently typically `stable`
Generate a new `msi_upgrade_code` for a new brand once and keep it stable afterwards.
Examples:
```powershell
New-Guid
```
```bash
uuidgen
```
### Asset Fields
These can point at brand-specific files or default shared files:
- `icon_ico`
- `icon_icns`
- `dialog_bmp`
- `banner_bmp`
- `license_rtf`
If a referenced asset path does not exist, the helper falls back to the default asset defined in `build/scripts/brand_config.py`.
### Identity Rules
Treat these values as long-lived product identity once a brand has shipped:
- `brand_id`
- `asset_prefix`
- `exe_name`
- `bundle_identifier`
- `config_dir_name`
- `msi_upgrade_code`
Changing them later can break one or more of the following:
- Windows upgrade behavior
- macOS app identity
- auto-update asset selection
- local config/log/cache continuity
- installer and artifact naming consistency
If the product is already in use, only change these values deliberately and with migration planning.
## Edit an Existing Brand
To edit a shipped or in-progress brand:
1. Update the brand manifest in `build/brands/<brand>.json`.
2. If needed, update brand-specific assets referenced by that manifest.
3. If runtime behavior should also change, update the relevant application config values.
4. Rebuild the affected platform artifacts.
5. Validate the result with a dry-run release before publishing.
Safe edits after release usually include:
- `display_name`
- `shortcut_description`
- artwork paths
- license text
- update channel, if release policy changes
High-risk edits after release are the identity fields listed above.
## Runtime Branding Configuration
Packaging branding alone is not enough if the app should also present a different name, use different local storage, or point to different update settings.
Relevant runtime config keys include:
- `brand_id`
- `config_dir_name`
- `app_name`
- `update_base_url`
- `update_repo`
- `update_channel`
- `update_manifest_name`
The current example in `config.example.json` shows the Agravity runtime setup.
When adding a new brand, make sure the runtime config matches the packaging manifest at least for:
- `brand_id`
- `config_dir_name`
- `app_name`
## Build the Default Product
### Windows
Build the default executable only:
```powershell
python .\build\scripts\build_windows.py
```
Build the default Windows MSI:
```powershell
python .\build\scripts\build_windows.py --msi
```
Build with a specific `.env` file:
```powershell
python .\build\scripts\build_windows.py --msi --env-file .\.env
```
### macOS
Build the default macOS app and DMG:
```bash
bash build/scripts/build_macos.sh
```
Build with a specific `.env` file:
```bash
bash build/scripts/build_macos.sh --env-file .env
```
## Build a Brand
### Windows
Build a branded executable only:
```powershell
python .\build\scripts\build_windows.py --brand agravity
```
Build a branded MSI:
```powershell
python .\build\scripts\build_windows.py --brand agravity --msi
```
### macOS
Build a branded macOS app and DMG:
```bash
bash build/scripts/build_macos.sh --brand agravity
```
## Build Output Locations
Windows artifacts are written to:
- `build/dist/windows/webdrop_bridge/` for the default product
- `build/dist/windows/<brand_id>/` for branded products
macOS artifacts are written to:
- `build/dist/macos/webdrop_bridge/` for the default product
- `build/dist/macos/<brand_id>/` for branded products
Typical artifact names:
- Windows MSI: `<asset_prefix>-<version>-win-x64.msi`
- Windows checksum: `<asset_prefix>-<version>-win-x64.msi.sha256`
- macOS DMG: `<asset_prefix>-<version>-macos-universal.dmg`
- macOS checksum: `<asset_prefix>-<version>-macos-universal.dmg.sha256`
## Create a Release
Releases are shared by version. The release scripts scan local build outputs on the current machine and upload every artifact they find for that platform.
This means:
- a Windows machine can upload all locally built MSIs for the current version
- a macOS machine can later upload all locally built DMGs for the same version
- both runs contribute to the same Forgejo release tag
- `release-manifest.json` is merged so later runs do not wipe earlier platform entries
### Windows Release
Dry run first:
```powershell
.\build\scripts\create_release.ps1 -DryRun
```
Publish all locally built Windows variants for the current version:
```powershell
.\build\scripts\create_release.ps1
```
Publish only selected brands:
```powershell
.\build\scripts\create_release.ps1 -Brands agravity
```
Publish only the default product:
```powershell
.\build\scripts\create_release.ps1 -Brands webdrop_bridge
```
Publish a specific version:
```powershell
.\build\scripts\create_release.ps1 -Version 0.8.4
```
### macOS Release
Dry run first:
```bash
bash build/scripts/create_release.sh --dry-run
```
Publish all locally built macOS variants for the current version:
```bash
bash build/scripts/create_release.sh
```
Publish only selected brands:
```bash
bash build/scripts/create_release.sh --brand agravity
```
Publish only the default product:
```bash
bash build/scripts/create_release.sh --brand webdrop_bridge
```
Publish a specific version:
```bash
bash build/scripts/create_release.sh --version 0.8.4
```
### Credentials
Both release scripts use Forgejo credentials from environment variables when available:
- `FORGEJO_USER`
- `FORGEJO_PASS`
If they are not set and you are not in dry-run mode, the script prompts for them.
Both scripts also support clearing credentials from the current shell session:
- Windows: `-ClearCredentials`
- macOS: `--clear-credentials`
## Dry Run Behavior
Dry-run mode is the preferred validation step before publishing.
Dry-run mode:
- discovers the local artifacts exactly like a real release run
- prints the release tag and target release URL
- prints the brands that were discovered locally
- prints the artifact paths that would be uploaded
- writes a local manifest preview to `build/dist/release-manifest.json`
- does not prompt for credentials
- does not perform network requests
- does not delete or upload assets
## Release Manifest
The release scripts generate and upload `release-manifest.json`.
This file is used by the updater to select the correct installer and checksum for a given brand and platform.
Current platform keys are:
- `windows-x64`
- `macos-universal`
The manifest is built from local artifacts and merged with any existing manifest already attached to the release.
## First Manual Download (Before Auto-Update)
After creating a release, a user can manually download the first installer directly from Forgejo. Once installed, auto-update handles later versions.
Base repository URL:
- `https://git.him-tools.de/HIM-public/webdrop-bridge`
Release page pattern:
- `https://git.him-tools.de/HIM-public/webdrop-bridge/releases/tag/v<version>`
Direct asset download pattern:
- `https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v<version>/<asset-file-name>`
Example asset names:
- `WebDropBridge-0.8.4-win-x64.msi`
- `WebDropBridge-0.8.4-macos-universal.dmg`
- `AgravityBridge-0.8.4-win-x64.msi`
- `AgravityBridge-0.8.4-macos-universal.dmg`
### wget Examples
```bash
# Default Windows installer
wget "https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.4/WebDropBridge-0.8.4-win-x64.msi"
# Agravity macOS installer
wget "https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.4/AgravityBridge-0.8.4-macos-universal.dmg"
```
### curl Examples
```bash
# Default macOS installer
curl -L -o WebDropBridge-0.8.4-macos-universal.dmg \
"https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.4/WebDropBridge-0.8.4-macos-universal.dmg"
# Agravity Windows installer
curl -L -o AgravityBridge-0.8.4-win-x64.msi \
"https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.4/AgravityBridge-0.8.4-win-x64.msi"
```
### PowerShell Example
```powershell
Invoke-WebRequest `
-Uri "https://git.him-tools.de/HIM-public/webdrop-bridge/releases/download/v0.8.4/WebDropBridge-0.8.4-win-x64.msi" `
-OutFile "WebDropBridge-0.8.4-win-x64.msi"
```
You can inspect `release-manifest.json` on the release to see the exact file names for each brand and platform.
## Recommended Workflow for a New Brand
1. Create `build/brands/<brand>.json`.
2. Add or update brand-specific assets if needed.
3. Prepare matching runtime config values.
4. Build the brand on Windows and/or macOS.
5. Run the release script in dry-run mode.
6. Verify artifact names, discovered brands, and manifest contents.
7. Run the actual release script.
8. Validate update behavior against the shared release.
## Troubleshooting Notes
### Brand not discovered by release script
Check that:
- the build completed successfully
- the artifact is under the expected platform folder
- the artifact name matches the `asset_prefix` and current version
- the version used by the release script matches the built artifact version
### Windows upgrade behavior is wrong
Check that the brand has its own stable `msi_upgrade_code`. Reusing or changing it incorrectly will break expected MSI upgrade semantics.
### App uses the wrong local config folder
Check that runtime config uses the intended `config_dir_name`, and that it matches the packaging brand you expect.
### Auto-update downloads the wrong installer
Check that:
- the release contains the correct installer files
- `release-manifest.json` includes the correct brand and platform entry
- runtime update settings point to the expected repo/channel/manifest
## Current Example Brand
The first branded variant currently in the repository is:
- `build/brands/agravity.json`
Use it as the template for future branded variants.

View file

@ -8,7 +8,13 @@ BUILD_SCRIPTS_DIR = Path(__file__).resolve().parents[2] / "build" / "scripts"
if str(BUILD_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(BUILD_SCRIPTS_DIR))
from brand_config import generate_release_manifest, load_brand_config
from brand_config import (
DEFAULT_BRAND_ID,
collect_local_release_data,
generate_release_manifest,
load_brand_config,
merge_release_manifests,
)
def test_load_agravity_brand_config():
@ -75,3 +81,63 @@ def test_generate_release_manifest_for_agravity(tmp_path):
manifest["brands"]["agravity"]["macos-universal"]["installer"]
== "AgravityBridge-0.8.4-macos-universal.dmg"
)
def test_collect_local_release_data_includes_default_brand(tmp_path):
"""Test discovering local artifacts for the default Windows build."""
project_root = tmp_path
installer_dir = project_root / "build" / "dist" / "windows" / DEFAULT_BRAND_ID
installer_dir.mkdir(parents=True)
installer = installer_dir / "WebDropBridge-0.8.4-win-x64.msi"
installer.write_bytes(b"msi")
checksum = installer_dir / f"{installer.name}.sha256"
checksum.write_text("abc", encoding="utf-8")
data = collect_local_release_data("0.8.4", platform="windows", root=project_root)
assert data["brands"] == [DEFAULT_BRAND_ID]
assert str(installer) in data["artifacts"]
assert str(checksum) in data["artifacts"]
assert (
data["manifest"]["brands"][DEFAULT_BRAND_ID]["windows-x64"]["installer"] == installer.name
)
def test_merge_release_manifests_preserves_existing_platforms():
"""Test merging platform-specific manifest entries from separate upload runs."""
base_manifest = {
"version": "0.8.4",
"channel": "stable",
"brands": {
"agravity": {
"windows-x64": {
"installer": "AgravityBridge-0.8.4-win-x64.msi",
"checksum": "AgravityBridge-0.8.4-win-x64.msi.sha256",
}
}
},
}
overlay_manifest = {
"version": "0.8.4",
"channel": "stable",
"brands": {
"agravity": {
"macos-universal": {
"installer": "AgravityBridge-0.8.4-macos-universal.dmg",
"checksum": "AgravityBridge-0.8.4-macos-universal.dmg.sha256",
}
}
},
}
merged = merge_release_manifests(base_manifest, overlay_manifest)
assert (
merged["brands"]["agravity"]["windows-x64"]["installer"]
== "AgravityBridge-0.8.4-win-x64.msi"
)
assert (
merged["brands"]["agravity"]["macos-universal"]["installer"]
== "AgravityBridge-0.8.4-macos-universal.dmg"
)