bc42604045
create-release now creates the release as a draft so users never see a release page that's missing artifacts (or, worse, missing the sha256 sidecars that the in-app updater requires). A new publish-release job runs after create-release, build-windows, build-linux, and build-docker all succeed, and PATCHes the release to draft=false in one step. If any build fails, the draft stays hidden and can be deleted manually.
378 lines
15 KiB
YAML
378 lines
15 KiB
YAML
name: Build Release
|
|
|
|
on:
|
|
push:
|
|
tags:
|
|
- 'v*'
|
|
# Manual dispatch builds Windows/Linux/Docker artifacts without creating
|
|
# a Gitea release — for validating build scripts between real releases.
|
|
# Attach/push steps are gated on github.event_name == 'push'.
|
|
workflow_dispatch:
|
|
inputs:
|
|
version:
|
|
description: 'Version label for dispatch builds (artifacts only, no release)'
|
|
required: false
|
|
default: 'dev'
|
|
|
|
jobs:
|
|
# ── Create the release first (shared by all build jobs) ────
|
|
create-release:
|
|
# Skipped on workflow_dispatch — dispatch is for build validation only.
|
|
if: github.event_name == 'push'
|
|
runs-on: ubuntu-latest
|
|
outputs:
|
|
release_id: ${{ steps.create.outputs.release_id }}
|
|
steps:
|
|
- name: Fetch RELEASE_NOTES.md only
|
|
uses: actions/checkout@v4
|
|
with:
|
|
sparse-checkout: RELEASE_NOTES.md
|
|
sparse-checkout-cone-mode: false
|
|
|
|
- name: Create Gitea release
|
|
id: create
|
|
env:
|
|
GITEA_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
|
|
run: |
|
|
TAG="${{ gitea.ref_name }}"
|
|
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
|
|
|
IS_PRE="false"
|
|
if echo "$TAG" | grep -qE '(alpha|beta|rc)'; then
|
|
IS_PRE="true"
|
|
fi
|
|
|
|
# Build registry path for Docker instructions
|
|
SERVER_HOST=$(echo "${{ gitea.server_url }}" | sed -E 's|https?://||')
|
|
REPO=$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')
|
|
DOCKER_IMAGE="${SERVER_HOST}/${REPO}"
|
|
|
|
if [ -f RELEASE_NOTES.md ]; then
|
|
export RELEASE_NOTES=$(cat RELEASE_NOTES.md)
|
|
echo "Found RELEASE_NOTES.md"
|
|
else
|
|
export RELEASE_NOTES=""
|
|
echo "No RELEASE_NOTES.md found"
|
|
fi
|
|
|
|
# Build release body via Python to avoid YAML escaping issues
|
|
BODY_JSON=$(python3 -c "
|
|
import json, sys, os, textwrap
|
|
|
|
tag = '$TAG'
|
|
image = '$DOCKER_IMAGE'
|
|
release_notes = os.environ.get('RELEASE_NOTES', '')
|
|
|
|
sections = []
|
|
|
|
if release_notes.strip():
|
|
sections.append(release_notes.strip())
|
|
|
|
sections.append(textwrap.dedent(f'''
|
|
## Downloads
|
|
|
|
| Platform | File | Description |
|
|
|----------|------|-------------|
|
|
| Windows (installer) | \`LedGrab-{tag}-setup.exe\` | Install with Start Menu shortcut, optional autostart, uninstaller |
|
|
| Windows (portable) | \`LedGrab-{tag}-win-x64.zip\` | Unzip anywhere, run LedGrab.bat |
|
|
| Linux | \`LedGrab-{tag}-linux-x64.tar.gz\` | Extract, run ./run.sh |
|
|
| Android | \`LedGrab-{tag}-android-release.apk\` | Sideload on Android 7.0+ (API 24+) — TV boxes, Fire TV, phones, tablets. arm64-v8a / x86_64 / x86 |
|
|
| Docker | See below | docker pull + docker run |
|
|
|
|
After starting, open **http://localhost:8080** in your browser.
|
|
|
|
### Docker
|
|
|
|
\`\`\`bash
|
|
docker pull {image}:{tag}
|
|
docker run -d --name ledgrab -p 8080:8080 -v ledgrab-data:/app/data {image}:{tag}
|
|
\`\`\`
|
|
|
|
### First-time setup
|
|
|
|
1. Change the default API key in `config/default_config.yaml`.
|
|
2. Open http://localhost:8080 and add your LED devices.
|
|
3. See `INSTALLATION.md` for detailed configuration.
|
|
''').strip())
|
|
|
|
print(json.dumps('\n\n'.join(sections)))
|
|
")
|
|
|
|
# Created as draft so the release isn't user-visible until every
|
|
# build job has attached its assets. The publish-release job at
|
|
# the end of the workflow flips draft=false once all builds pass.
|
|
RELEASE=$(curl -s -X POST "$BASE_URL/releases" \
|
|
-H "Authorization: token $GITEA_TOKEN" \
|
|
-H "Content-Type: application/json" \
|
|
-d "{
|
|
\"tag_name\": \"$TAG\",
|
|
\"name\": \"LedGrab $TAG\",
|
|
\"body\": $BODY_JSON,
|
|
\"draft\": true,
|
|
\"prerelease\": $IS_PRE
|
|
}")
|
|
|
|
# Fallback: if release already exists for this tag, fetch it instead
|
|
RELEASE_ID=$(echo "$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" 2>/dev/null)
|
|
if [ -z "$RELEASE_ID" ]; then
|
|
echo "::warning::Release already exists for tag $TAG — reusing existing release"
|
|
RELEASE=$(curl -s "$BASE_URL/releases/tags/$TAG" \
|
|
-H "Authorization: token $GITEA_TOKEN")
|
|
RELEASE_ID=$(echo "$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
|
fi
|
|
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
|
|
echo "Release ID: $RELEASE_ID"
|
|
|
|
# ── Windows portable ZIP (cross-built from Linux) ─────────
|
|
build-windows:
|
|
needs: create-release
|
|
# `!cancelled()` lets this job run even when create-release was skipped
|
|
# (dispatch) or failed. The attach step itself is still push-gated.
|
|
if: ${{ !cancelled() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') }}
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Setup Python
|
|
uses: actions/setup-python@v5
|
|
with:
|
|
python-version: '3.11'
|
|
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@v4
|
|
with:
|
|
node-version: '20'
|
|
|
|
- name: Install system dependencies
|
|
run: |
|
|
sudo apt-get update
|
|
sudo apt-get install -y --no-install-recommends zip libportaudio2 nsis msitools
|
|
|
|
- name: Cross-build Windows distribution
|
|
run: |
|
|
chmod +x build/build-dist-windows.sh
|
|
./build/build-dist-windows.sh "${{ gitea.ref_name }}"
|
|
|
|
- name: Upload build artifacts
|
|
uses: actions/upload-artifact@v3
|
|
with:
|
|
name: LedGrab-${{ gitea.ref_name }}-win-x64
|
|
path: |
|
|
build/LedGrab-*.zip
|
|
build/LedGrab-*-setup.exe
|
|
retention-days: 90
|
|
|
|
- name: Attach assets to release
|
|
# Push (tag) only — dispatch runs produce artifacts but no release.
|
|
if: github.event_name == 'push'
|
|
env:
|
|
GITEA_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
|
|
run: |
|
|
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
|
|
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
|
|
|
# Upload helper — deletes existing asset with same name to prevent duplicates on re-run
|
|
upload_asset() {
|
|
local FILE="$1"
|
|
local NAME=$(basename "$FILE")
|
|
EXISTING_ID=$(curl -s "$BASE_URL/releases/$RELEASE_ID/assets" \
|
|
-H "Authorization: token $GITEA_TOKEN" \
|
|
| python3 -c "import sys,json; assets=json.load(sys.stdin); print(next((str(a['id']) for a in assets if a['name']=='$NAME'),''))" 2>/dev/null)
|
|
if [ -n "$EXISTING_ID" ]; then
|
|
curl -s -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$EXISTING_ID" \
|
|
-H "Authorization: token $GITEA_TOKEN"
|
|
echo "Replaced existing asset: $NAME"
|
|
fi
|
|
curl -s -X POST \
|
|
"$BASE_URL/releases/$RELEASE_ID/assets?name=$NAME" \
|
|
-H "Authorization: token $GITEA_TOKEN" \
|
|
-H "Content-Type: application/octet-stream" \
|
|
--data-binary "@$FILE"
|
|
echo "Uploaded: $NAME"
|
|
}
|
|
|
|
# Publish an asset plus its .sha256 sidecar. The in-app update
|
|
# service refuses to install without a published checksum, so
|
|
# every artifact needs its hash uploaded alongside.
|
|
upload_with_sha256() {
|
|
local FILE="$1"
|
|
upload_asset "$FILE"
|
|
(cd "$(dirname "$FILE")" && sha256sum "$(basename "$FILE")" > "$(basename "$FILE").sha256")
|
|
upload_asset "$FILE.sha256"
|
|
}
|
|
|
|
ZIP_FILE=$(ls build/LedGrab-*.zip | head -1)
|
|
[ -f "$ZIP_FILE" ] && upload_with_sha256 "$ZIP_FILE"
|
|
|
|
SETUP_FILE=$(ls build/LedGrab-*-setup.exe 2>/dev/null | head -1)
|
|
[ -f "$SETUP_FILE" ] && upload_with_sha256 "$SETUP_FILE"
|
|
|
|
# ── Linux tarball ──────────────────────────────────────────
|
|
build-linux:
|
|
needs: create-release
|
|
if: ${{ !cancelled() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') }}
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Setup Python
|
|
uses: actions/setup-python@v5
|
|
with:
|
|
python-version: '3.11'
|
|
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@v4
|
|
with:
|
|
node-version: '20'
|
|
|
|
- name: Install system dependencies
|
|
run: |
|
|
sudo apt-get update
|
|
sudo apt-get install -y --no-install-recommends libportaudio2
|
|
|
|
- name: Build Linux distribution
|
|
run: |
|
|
chmod +x build/build-dist.sh
|
|
./build/build-dist.sh "${{ gitea.ref_name }}"
|
|
|
|
- name: Upload build artifact
|
|
uses: actions/upload-artifact@v3
|
|
with:
|
|
name: LedGrab-${{ gitea.ref_name }}-linux-x64
|
|
path: build/LedGrab-*.tar.gz
|
|
retention-days: 90
|
|
|
|
- name: Attach tarball to release
|
|
if: github.event_name == 'push'
|
|
env:
|
|
GITEA_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
|
|
run: |
|
|
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
|
|
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
|
|
|
upload_asset() {
|
|
local FILE="$1"
|
|
local NAME
|
|
NAME=$(basename "$FILE")
|
|
EXISTING_ID=$(curl -s "$BASE_URL/releases/$RELEASE_ID/assets" \
|
|
-H "Authorization: token $GITEA_TOKEN" \
|
|
| python3 -c "import sys,json; assets=json.load(sys.stdin); print(next((str(a['id']) for a in assets if a['name']=='$NAME'),''))" 2>/dev/null)
|
|
if [ -n "$EXISTING_ID" ]; then
|
|
curl -s -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$EXISTING_ID" \
|
|
-H "Authorization: token $GITEA_TOKEN"
|
|
echo "Replaced existing asset: $NAME"
|
|
fi
|
|
curl -s -X POST \
|
|
"$BASE_URL/releases/$RELEASE_ID/assets?name=$NAME" \
|
|
-H "Authorization: token $GITEA_TOKEN" \
|
|
-H "Content-Type: application/octet-stream" \
|
|
--data-binary "@$FILE"
|
|
echo "Uploaded: $NAME"
|
|
}
|
|
|
|
TAR_FILE=$(ls build/LedGrab-*.tar.gz | head -1)
|
|
if [ -f "$TAR_FILE" ]; then
|
|
upload_asset "$TAR_FILE"
|
|
(cd "$(dirname "$TAR_FILE")" && sha256sum "$(basename "$TAR_FILE")" > "$(basename "$TAR_FILE").sha256")
|
|
upload_asset "$TAR_FILE.sha256"
|
|
fi
|
|
|
|
# ── Docker image ───────────────────────────────────────────
|
|
build-docker:
|
|
needs: create-release
|
|
if: ${{ !cancelled() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') }}
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Extract version metadata
|
|
id: meta
|
|
run: |
|
|
TAG="${{ gitea.ref_name }}"
|
|
VERSION="${TAG#v}"
|
|
# Strip protocol and lowercase for Docker registry path
|
|
SERVER_HOST=$(echo "${{ gitea.server_url }}" | sed -E 's|https?://||')
|
|
REPO=$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')
|
|
REGISTRY="${SERVER_HOST}/${REPO}"
|
|
|
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
|
echo "registry=$REGISTRY" >> "$GITHUB_OUTPUT"
|
|
echo "server_host=$SERVER_HOST" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Login to Gitea Container Registry
|
|
id: docker-login
|
|
# Dispatch runs don't need registry credentials — the build step
|
|
# verifies the Dockerfile locally and push is skipped.
|
|
if: github.event_name == 'push'
|
|
continue-on-error: true
|
|
run: |
|
|
echo "${{ secrets.DEPLOY_TOKEN }}" | docker login \
|
|
"${{ steps.meta.outputs.server_host }}" \
|
|
-u "${{ gitea.actor }}" --password-stdin
|
|
|
|
- name: Build Docker image
|
|
# Always build — dispatch uses this to validate the Dockerfile.
|
|
# On push, still gate on successful login so we don't build a
|
|
# tagged image that can't be pushed.
|
|
if: github.event_name != 'push' || steps.docker-login.outcome == 'success'
|
|
run: |
|
|
TAG="${{ gitea.ref_name }}"
|
|
REGISTRY="${{ steps.meta.outputs.registry }}"
|
|
|
|
docker build \
|
|
--build-arg APP_VERSION="${{ steps.meta.outputs.version }}" \
|
|
--label "org.opencontainers.image.version=${{ steps.meta.outputs.version }}" \
|
|
--label "org.opencontainers.image.revision=${{ gitea.sha }}" \
|
|
-t "$REGISTRY:$TAG" \
|
|
-t "$REGISTRY:${{ steps.meta.outputs.version }}" \
|
|
./server
|
|
|
|
# Tag as latest only for stable releases
|
|
if ! echo "$TAG" | grep -qE '(alpha|beta|rc)'; then
|
|
docker tag "$REGISTRY:$TAG" "$REGISTRY:latest"
|
|
fi
|
|
|
|
- name: Push Docker image
|
|
if: github.event_name == 'push' && steps.docker-login.outcome == 'success'
|
|
run: |
|
|
TAG="${{ gitea.ref_name }}"
|
|
REGISTRY="${{ steps.meta.outputs.registry }}"
|
|
|
|
docker push "$REGISTRY:$TAG"
|
|
docker push "$REGISTRY:${{ steps.meta.outputs.version }}"
|
|
|
|
if ! echo "$TAG" | grep -qE '(alpha|beta|rc)'; then
|
|
docker push "$REGISTRY:latest"
|
|
fi
|
|
|
|
# ── Publish the release (flip draft=false) ─────────────────
|
|
# Runs only after every build job succeeded so users never see a
|
|
# release that's missing artifacts or sha256 sidecars (the in-app
|
|
# updater refuses to install without them).
|
|
publish-release:
|
|
needs: [create-release, build-windows, build-linux, build-docker]
|
|
if: github.event_name == 'push' && success()
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Promote draft release to published
|
|
env:
|
|
GITEA_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
|
|
run: |
|
|
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
|
|
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
|
|
|
curl -s -X PATCH "$BASE_URL/releases/$RELEASE_ID" \
|
|
-H "Authorization: token $GITEA_TOKEN" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"draft": false}'
|
|
echo "Published release $RELEASE_ID"
|