name: Build on: push: branches: - main - dev tags: - 'v*' # Large AppImage/exe uploads time out on Gitea's actions artifact pipeline. # Build jobs publish assets straight to Gitea/GitHub releases via the REST API. jobs: increment-version: runs-on: ubuntu-latest outputs: version: ${{ steps.finalize.outputs.VERSION }} should_build: ${{ steps.decide.outputs.SHOULD_BUILD }} steps: - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - name: Decide version behavior id: decide run: | CURRENT=$(grep -Po '"version":\s*"\K[^"]+' package.json | head -1) COMMIT_MSG="${{ github.event.head_commit.message || '' }}" SHOULD_BUMP=false SHOULD_BUILD=true if [ "${{ github.ref }}" = "refs/heads/main" ] && ! echo "$COMMIT_MSG" | grep -qi '\[skip ci\]'; then SHOULD_BUMP=true fi if echo "$COMMIT_MSG" | grep -qi '\[skip ci\]'; then SHOULD_BUILD=false fi echo "CURRENT_VERSION=$CURRENT" >> $GITHUB_OUTPUT echo "SHOULD_BUMP=$SHOULD_BUMP" >> $GITHUB_OUTPUT echo "SHOULD_BUILD=$SHOULD_BUILD" >> $GITHUB_OUTPUT echo "Current version: $CURRENT" echo "Should bump: $SHOULD_BUMP" echo "Should build: $SHOULD_BUILD" - name: Bump patch version id: bump if: ${{ steps.decide.outputs.SHOULD_BUMP == 'true' }} run: | # Get current version from package.json CURRENT=$(grep -Po '"version":\s*"\K[^"]+' package.json | head -1) echo "Current version: $CURRENT" # Split and increment patch MAJOR=$(echo $CURRENT | cut -d. -f1) MINOR=$(echo $CURRENT | cut -d. -f2) PATCH=$(echo $CURRENT | cut -d. -f3) NEW_PATCH=$((PATCH + 1)) NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}" echo "New version: $NEW_VERSION" # Update package.json sed -i "s/\"version\": \"${CURRENT}\"/\"version\": \"${NEW_VERSION}\"/" package.json # Verify the change grep '"version"' package.json | head -1 echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT - name: Commit and push version bump if: ${{ steps.decide.outputs.SHOULD_BUMP == 'true' }} run: | git config user.name "GitHub Actions" git config user.email "actions@github.com" git add package.json git commit -m "chore: bump version to ${{ steps.bump.outputs.VERSION }} [skip ci]" for attempt in 1 2 3; do git fetch origin main git rebase origin/main || { git rebase --abort || true echo "Failed to rebase version bump onto latest main" exit 1 } if git push origin HEAD:main; then exit 0 fi echo "Push rejected on attempt ${attempt}, retrying against latest main" done echo "Failed to push version bump after 3 attempts" exit 1 - name: Finalize version output id: finalize run: | if [ -n "${{ steps.bump.outputs.VERSION }}" ]; then echo "VERSION=${{ steps.bump.outputs.VERSION }}" >> $GITHUB_OUTPUT else echo "VERSION=${{ steps.decide.outputs.CURRENT_VERSION }}" >> $GITHUB_OUTPUT fi prepare-release: runs-on: ubuntu-latest needs: increment-version if: always() && needs.increment-version.outputs.should_build == 'true' outputs: version: ${{ steps.version.outputs.VERSION }} tag: ${{ steps.version.outputs.TAG_NAME }} steps: - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 0 ref: ${{ github.ref_name }} - name: Pull latest (after version bump) if: github.ref == 'refs/heads/main' run: git pull origin main - name: Resolve version id: version run: | if [ -n "${{ needs.increment-version.outputs.version }}" ]; then VERSION="${{ needs.increment-version.outputs.version }}" else VERSION=$(grep -Po '"version":\s*"\K[^"]+' package.json | head -1) fi echo "VERSION=$VERSION" >> $GITHUB_OUTPUT echo "TAG_NAME=v$VERSION" >> $GITHUB_OUTPUT echo "Version: $VERSION" - name: Create and push tag run: | VERSION="${{ steps.version.outputs.VERSION }}" TAG_NAME="v${VERSION}" if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then echo "Tag $TAG_NAME already exists" else git config user.name "GitHub Actions" git config user.email "actions@github.com" git tag -a "$TAG_NAME" -m "Release $TAG_NAME" git push origin "$TAG_NAME" fi - name: Ensure Gitea release env: GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail VERSION="${{ steps.version.outputs.VERSION }}" TAG_NAME="v${VERSION}" API_BASE="${{ github.server_url }}/api/v1" REPO="${{ github.repository }}" RELEASE_ID=$(curl -fsS -H "Authorization: token ${GITEA_TOKEN}" \ "${API_BASE}/repos/${REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty' || true) if [ -z "$RELEASE_ID" ]; then echo "Creating Gitea release ${TAG_NAME}..." RELEASE_ID=$(curl -fsS -X POST \ -H "Authorization: token ${GITEA_TOKEN}" \ -H "Content-Type: application/json" \ -d "{\"tag_name\": \"${TAG_NAME}\", \"name\": \"Release ${TAG_NAME}\", \"body\": \"Release ${VERSION}\", \"draft\": false, \"prerelease\": false}" \ "${API_BASE}/repos/${REPO}/releases" | jq -r '.id') fi echo "Gitea release id: ${RELEASE_ID}" - name: Ensure GitHub release env: GH_TOKEN: ${{ secrets.GHTOKEN }} run: | set -euo pipefail VERSION="${{ steps.version.outputs.VERSION }}" TAG_NAME="v${VERSION}" GITHUB_REPO="Paarrot/Paarrot-Desktop" RELEASE_ID=$(curl -fsS -H "Authorization: token ${GH_TOKEN}" \ "https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG_NAME}" | jq -r '.id // empty' || true) if [ -z "$RELEASE_ID" ]; then echo "Creating GitHub release ${TAG_NAME}..." RELEASE_ID=$(curl -fsS -X POST \ -H "Authorization: token ${GH_TOKEN}" \ -H "Content-Type: application/json" \ -d "{\"tag_name\": \"${TAG_NAME}\", \"name\": \"Release ${TAG_NAME}\", \"body\": \"Release ${VERSION}\", \"draft\": false, \"prerelease\": false}" \ "https://api.github.com/repos/${GITHUB_REPO}/releases" | jq -r '.id') fi echo "GitHub release id: ${RELEASE_ID}" build-windows: runs-on: windows needs: [increment-version, prepare-release] if: always() && needs.prepare-release.result == 'success' steps: - name: Checkout repository uses: actions/checkout@v4 with: submodules: false ref: ${{ github.ref_name }} - name: Checkout submodules manually shell: powershell run: | $ErrorActionPreference = "Stop" # Use the same host/scheme the runner already used for origin (not public HTTPS). $origin = (git remote get-url origin).Trim() $origin = $origin.TrimEnd('/') if ($origin.EndsWith('.git')) { $origin = $origin.Substring(0, $origin.Length - 4) } $base = $origin.Substring(0, $origin.LastIndexOf('/')) $cinnyUrl = "$base/cinny.git" Write-Host "Rewriting submodule URL to $cinnyUrl" git config submodule.cinny.url $cinnyUrl git submodule sync --recursive git submodule update --init --recursive if ($LASTEXITCODE -eq 0) { Write-Host "Submodules checked out at pinned commits." exit 0 } Write-Warning "Pinned submodule commit is unavailable on remote. Falling back to remote submodule HEAD." git submodule deinit -f --all git config submodule.cinny.url $cinnyUrl git submodule sync --recursive git submodule update --init --recursive --remote if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } Write-Host "Resolved submodule commit:" git -C cinny rev-parse HEAD - name: Pull latest (after version bump) if: github.ref == 'refs/heads/main' shell: powershell run: git pull origin main - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' - name: Install dependencies (root) run: npm ci --prefer-offline - name: Install dependencies (cinny) working-directory: ./cinny run: npm ci --prefer-offline - name: Clean previous builds shell: powershell run: | if (Test-Path cinny/dist) { Remove-Item -Recurse -Force cinny/dist } if (Test-Path dist-electron) { Remove-Item -Recurse -Force dist-electron } - name: Build Electron app run: npm run build:local -- --win env: GH_TOKEN: ${{ secrets.GHTOKEN }} - name: Upload Windows assets to releases shell: powershell env: GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GHTOKEN }} run: | $ErrorActionPreference = "Stop" $TAG_NAME = "${{ needs.prepare-release.outputs.tag }}" $API_BASE = "${{ github.server_url }}/api/v1" $REPO = "${{ github.repository }}" $GITHUB_REPO = "Paarrot/Paarrot-Desktop" function Invoke-JsonCurl([string[]]$CurlArgs) { $raw = & curl.exe @CurlArgs if ($LASTEXITCODE -ne 0) { throw "curl failed: $($CurlArgs -join ' ')" } return ($raw | ConvertFrom-Json) } $giteaRelease = Invoke-JsonCurl @( "-fsS", "-H", "Authorization: token $env:GITEA_TOKEN", "$API_BASE/repos/$REPO/releases/tags/$TAG_NAME" ) $ghRelease = Invoke-JsonCurl @( "-fsS", "-H", "Authorization: token $env:GH_TOKEN", "https://api.github.com/repos/$GITHUB_REPO/releases/tags/$TAG_NAME" ) # GitHub returns upload_url like .../assets{?name,label} $ghUploadUrl = [string]$ghRelease.upload_url if ([string]::IsNullOrWhiteSpace($ghUploadUrl)) { throw "GitHub release $($ghRelease.id) has no upload_url" } $ghUploadUrl = $ghUploadUrl -replace '\{\?[^}]*\}', '' Write-Host "GitHub upload base: $ghUploadUrl" function Upload-One([string]$File) { $filename = [System.IO.Path]::GetFileName($File) $sizeMb = [math]::Round((Get-Item $File).Length / 1MB, 1) Write-Host "Uploading $filename (${sizeMb} MB)..." $giteaAssets = Invoke-JsonCurl @( "-fsS", "-H", "Authorization: token $env:GITEA_TOKEN", "$API_BASE/repos/$REPO/releases/$($giteaRelease.id)/assets" ) foreach ($asset in @($giteaAssets)) { if ($asset.name -eq $filename) { & curl.exe -fsS -X DELETE -H "Authorization: token $env:GITEA_TOKEN" ` "$API_BASE/repos/$REPO/releases/$($giteaRelease.id)/assets/$($asset.id)" | Out-Null } } & curl.exe -fsS --max-time 0 --retry 5 --retry-delay 10 ` -X POST ` -H "Authorization: token $env:GITEA_TOKEN" ` -H "Content-Type: application/octet-stream" ` --data-binary "@$File" ` "$API_BASE/repos/$REPO/releases/$($giteaRelease.id)/assets?name=$filename" | Out-Null if ($LASTEXITCODE -ne 0) { throw "Gitea upload failed for $filename" } Write-Host " Gitea: ok" $ghAssets = Invoke-JsonCurl @( "-fsS", "-H", "Authorization: token $env:GH_TOKEN", "https://api.github.com/repos/$GITHUB_REPO/releases/$($ghRelease.id)/assets" ) foreach ($asset in @($ghAssets)) { if ($asset.name -eq $filename) { & curl.exe -fsS -X DELETE -H "Authorization: token $env:GH_TOKEN" ` "https://api.github.com/repos/$GITHUB_REPO/releases/assets/$($asset.id)" | Out-Null } } # Brace the variable — `$url?name=` is parsed as PowerShell's null-conditional. $ghAssetUrl = "${ghUploadUrl}?name=$([uri]::EscapeDataString($filename))" Write-Host " GitHub URL: $ghAssetUrl" & curl.exe -fsS --max-time 0 --retry 5 --retry-delay 10 ` -X POST ` -H "Authorization: token $env:GH_TOKEN" ` -H "Content-Type: application/octet-stream" ` --data-binary "@$File" ` $ghAssetUrl | Out-Null if ($LASTEXITCODE -ne 0) { throw "GitHub upload failed for $filename" } Write-Host " GitHub: ok" } Get-ChildItem -Path dist-electron -File | Where-Object { $_.Name -like 'Paarrot-*-win-x64.exe' -or $_.Name -eq 'latest.yml' } | ForEach-Object { Upload-One $_.FullName } - name: List build output shell: powershell run: | Get-ChildItem -Path dist-electron -Recurse | Select-Object FullName build-linux: runs-on: ubuntu-latest needs: [increment-version, prepare-release] if: always() && needs.prepare-release.result == 'success' steps: - name: Checkout repository uses: actions/checkout@v4 with: submodules: false ref: ${{ github.ref_name }} - name: Checkout submodules manually run: | set -euo pipefail # Use the same host/scheme the runner already used for origin (not public HTTPS). ORIGIN_URL=$(git remote get-url origin) ORIGIN_URL=${ORIGIN_URL%/} ORIGIN_URL=${ORIGIN_URL%.git} BASE_URL=${ORIGIN_URL%/*} CINNY_URL="${BASE_URL}/cinny.git" echo "Rewriting submodule URL to ${CINNY_URL}" git config submodule.cinny.url "${CINNY_URL}" git submodule sync --recursive if git submodule update --init --recursive; then echo "Submodules checked out at pinned commits." exit 0 fi echo "Pinned submodule commit is unavailable on remote. Falling back to remote submodule HEAD." git submodule deinit -f --all || true rm -rf cinny .git/modules/cinny git config submodule.cinny.url "${CINNY_URL}" git submodule sync --recursive git submodule update --init --recursive --remote echo "Resolved submodule commit:" git -C cinny rev-parse HEAD - name: Pull latest (after version bump) if: github.ref == 'refs/heads/main' run: git pull origin main - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' - name: Install dependencies (root) run: npm ci --prefer-offline - name: Install dependencies (cinny) working-directory: ./cinny run: npm ci --prefer-offline - name: Clean previous builds run: | rm -rf cinny/dist dist-electron - name: Build Electron app run: npm run build:local -- --linux env: GH_TOKEN: ${{ secrets.GHTOKEN }} - name: Upload Linux assets to releases env: GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GHTOKEN }} run: | set -euo pipefail VERSION="${{ needs.prepare-release.outputs.version }}" TAG_NAME="${{ needs.prepare-release.outputs.tag }}" API_BASE="${{ github.server_url }}/api/v1" REPO="${{ github.repository }}" GITHUB_REPO="Paarrot/Paarrot-Desktop" GITEA_RELEASE_ID=$(curl -fsS -H "Authorization: token ${GITEA_TOKEN}" \ "${API_BASE}/repos/${REPO}/releases/tags/${TAG_NAME}" | jq -r '.id') GH_RELEASE_ID=$(curl -fsS -H "Authorization: token ${GH_TOKEN}" \ "https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${TAG_NAME}" | jq -r '.id') GH_UPLOAD_URL=$(curl -fsS -H "Authorization: token ${GH_TOKEN}" \ "https://api.github.com/repos/${GITHUB_REPO}/releases/${GH_RELEASE_ID}" | jq -r '.upload_url' | sed 's/{?name,label}//') upload_one() { local FILE="$1" local FILENAME FILENAME=$(basename "$FILE") echo "Uploading ${FILENAME} ($(du -h "$FILE" | cut -f1))..." OLD_ID=$(curl -fsS -H "Authorization: token ${GITEA_TOKEN}" \ "${API_BASE}/repos/${REPO}/releases/${GITEA_RELEASE_ID}/assets" \ | jq -r --arg n "$FILENAME" '.[] | select(.name == $n) | .id' || true) if [ -n "${OLD_ID:-}" ]; then curl -fsS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \ "${API_BASE}/repos/${REPO}/releases/${GITEA_RELEASE_ID}/assets/${OLD_ID}" >/dev/null fi curl -fsS --max-time 0 --retry 5 --retry-delay 10 \ -X POST \ -H "Authorization: token ${GITEA_TOKEN}" \ -H "Content-Type: application/octet-stream" \ --data-binary @"${FILE}" \ "${API_BASE}/repos/${REPO}/releases/${GITEA_RELEASE_ID}/assets?name=${FILENAME}" \ >/dev/null echo " Gitea: ok" OLD_GH=$(curl -fsS -H "Authorization: token ${GH_TOKEN}" \ "https://api.github.com/repos/${GITHUB_REPO}/releases/${GH_RELEASE_ID}/assets" \ | jq -r --arg n "$FILENAME" '.[] | select(.name == $n) | .id' || true) if [ -n "${OLD_GH:-}" ]; then curl -fsS -X DELETE -H "Authorization: token ${GH_TOKEN}" \ "https://api.github.com/repos/${GITHUB_REPO}/releases/assets/${OLD_GH}" >/dev/null fi curl -fsS --max-time 0 --retry 5 --retry-delay 10 \ -X POST \ -H "Authorization: token ${GH_TOKEN}" \ -H "Content-Type: application/octet-stream" \ --data-binary @"${FILE}" \ "${GH_UPLOAD_URL}?name=${FILENAME}" \ >/dev/null echo " GitHub: ok" } shopt -s nullglob for FILE in dist-electron/Paarrot-*.AppImage dist-electron/latest-linux.yml; do upload_one "$FILE" done finalize-release: runs-on: ubuntu-latest needs: [increment-version, prepare-release, build-windows, build-linux] if: always() && needs.prepare-release.result == 'success' && (needs.build-windows.result == 'success' || needs.build-linux.result == 'success') steps: - name: Cleanup old Gitea releases env: GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail TAG_NAME="${{ needs.prepare-release.outputs.tag }}" API_BASE="${{ github.server_url }}/api/v1" REPO="${{ github.repository }}" echo "Cleaning up old Gitea releases (keeping ${TAG_NAME})..." OLD_RELEASES=$(curl -fsS -H "Authorization: token ${GITEA_TOKEN}" \ "${API_BASE}/repos/${REPO}/releases" | jq -r --arg t "$TAG_NAME" '.[] | select(.tag_name != $t) | .id') for OLD_ID in $OLD_RELEASES; do [ -n "$OLD_ID" ] || continue OLD_TAG=$(curl -fsS -H "Authorization: token ${GITEA_TOKEN}" \ "${API_BASE}/repos/${REPO}/releases/${OLD_ID}" | jq -r '.tag_name') echo "Deleting Gitea release ${OLD_ID} (${OLD_TAG})..." curl -fsS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \ "${API_BASE}/repos/${REPO}/releases/${OLD_ID}" >/dev/null || true if [ -n "$OLD_TAG" ] && [ "$OLD_TAG" != "null" ]; then curl -fsS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \ "${API_BASE}/repos/${REPO}/tags/${OLD_TAG}" >/dev/null || true fi done - name: Cleanup old GitHub releases env: GH_TOKEN: ${{ secrets.GHTOKEN }} run: | set -euo pipefail TAG_NAME="${{ needs.prepare-release.outputs.tag }}" GITHUB_REPO="Paarrot/Paarrot-Desktop" echo "Cleaning up old GitHub releases (keeping ${TAG_NAME})..." OLD_RELEASES=$(curl -fsS -H "Authorization: token ${GH_TOKEN}" \ "https://api.github.com/repos/${GITHUB_REPO}/releases" | jq -r --arg t "$TAG_NAME" '.[] | select(.tag_name != $t) | .id') for OLD_ID in $OLD_RELEASES; do [ -n "$OLD_ID" ] || continue OLD_TAG=$(curl -fsS -H "Authorization: token ${GH_TOKEN}" \ "https://api.github.com/repos/${GITHUB_REPO}/releases/${OLD_ID}" | jq -r '.tag_name') echo "Deleting GitHub release ${OLD_ID} (${OLD_TAG})..." curl -fsS -X DELETE -H "Authorization: token ${GH_TOKEN}" \ "https://api.github.com/repos/${GITHUB_REPO}/releases/${OLD_ID}" >/dev/null || true if [ -n "$OLD_TAG" ] && [ "$OLD_TAG" != "null" ]; then curl -fsS -X DELETE -H "Authorization: token ${GH_TOKEN}" \ "https://api.github.com/repos/${GITHUB_REPO}/git/refs/tags/${OLD_TAG}" >/dev/null || true fi done echo "Release finalize complete for ${TAG_NAME}"