name: Build & Release on: workflow_dispatch: inputs: tag: description: "Tag to create (e.g., v2.0.0)" required: true default: "v2.0.0" type: string force_tag: description: "Recreate tag if it already exists" required: false default: false type: boolean draft: description: "Create release as draft" required: false default: true type: boolean permissions: contents: write jobs: tag: name: Create tag needs: test runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - name: Validate input tag shell: bash run: | set -euo pipefail TAG="${{ inputs.tag }}" if [[ -z "$TAG" ]]; then echo "tag is required" >&2 exit 1 fi if [[ "$TAG" != v* ]]; then echo "tag must start with 'v' (got: $TAG)" >&2 exit 1 fi - name: Create/push tag shell: bash env: TAG: ${{ inputs.tag }} FORCE: ${{ inputs.force_tag }} run: | set -euo pipefail if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then if [[ "${FORCE}" != "true" ]]; then echo "Tag ${TAG} already exists. Re-run with force_tag=true to recreate." >&2 exit 1 fi git tag -d "${TAG}" || true git push origin ":refs/tags/${TAG}" || true fi git tag "${TAG}" "${GITHUB_SHA}" git push origin "${TAG}" test: name: Unit tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/setup-python@v6 with: python-version: "3.12" cache: "pip" - name: Install run: | python -m pip install --upgrade pip pip install -e ".[dev]" || pip install -e . pip install pytest - name: Run tests env: PYTHONPATH: ${{ github.workspace }}/src run: pytest build: name: Build packages needs: tag runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [windows-latest, ubuntu-latest] ffmpeg: [bundled, none] steps: - uses: actions/checkout@v6 - name: Derive version id: version shell: bash run: | TAG="${{ inputs.tag }}" echo "tag=${TAG}" >> "$GITHUB_OUTPUT" echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" - name: Write bundled version file shell: bash run: | set -euo pipefail printf '%s\n' "${{ steps.version.outputs.version }}" > version.txt - uses: actions/setup-python@v6 with: python-version: "3.12" cache: "pip" - name: Install build deps run: | python -m pip install --upgrade pip pip install -e . pip install pyinstaller - name: Build binary (Windows) if: runner.os == 'Windows' shell: pwsh run: | $ErrorActionPreference = "Stop" $ws = "${{ github.workspace }}" pyinstaller --noconfirm --onefile --noconsole --name "ytpl-sync" --icon "$ws/assets/icon.ico" --add-data "$ws/assets/icon.png;assets" --add-data "$ws/version.txt;." --distpath "dist/pyinstaller" --workpath "build/pyinstaller" --specpath "build/pyinstaller" --paths "src" "ytpl-sync-entry.py" - name: Build binary (Linux) if: runner.os == 'Linux' shell: bash run: | set -euo pipefail pyinstaller --noconfirm --onefile --noconsole --name "ytpl-sync" --icon "${GITHUB_WORKSPACE}/assets/icon.png" --add-data "${GITHUB_WORKSPACE}/assets/icon.png:assets" --add-data "${GITHUB_WORKSPACE}/version.txt:." --distpath "dist/pyinstaller" --workpath "build/pyinstaller" --specpath "build/pyinstaller" --paths "src" "ytpl-sync-entry.py" - name: Stage package shell: bash run: | set -euo pipefail OS="${{ runner.os }}" FFMPEG="${{ matrix.ffmpeg }}" VERSION="${{ steps.version.outputs.version }}" PKG_ROOT="$GITHUB_WORKSPACE/package" rm -rf "$PKG_ROOT" mkdir -p "$PKG_ROOT/config" "$PKG_ROOT/bin" # Binary if [[ "$OS" == "Windows" ]]; then cp "$GITHUB_WORKSPACE/dist/pyinstaller/ytpl-sync.exe" "$PKG_ROOT/ytpl-sync.exe" else cp "$GITHUB_WORKSPACE/dist/pyinstaller/ytpl-sync" "$PKG_ROOT/ytpl-sync.exe" chmod +x "$PKG_ROOT/ytpl-sync.exe" fi # Config: ship example as the default config python - <<'PY' import json import os from pathlib import Path workspace = Path(os.environ["GITHUB_WORKSPACE"]) pkg_root = workspace / "package" src = workspace / "config" / "yt-playlist-config.example.json" dst = pkg_root / "config" / "yt-playlist-config.json" data = json.loads(src.read_text(encoding="utf-8")) os_name = os.environ["RUNNER_OS"] ffmpeg_mode = os.environ["FFMPEG_MODE"] if ffmpeg_mode == "bundled": data["ffmpeg_path"] = "./bin/ffmpeg.exe" if os_name == "Windows" else "./bin/ffmpeg" else: data["ffmpeg_path"] = "ffmpeg" dst.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") PY env: FFMPEG_MODE: ${{ matrix.ffmpeg }} - name: Bundle FFmpeg (Windows) if: runner.os == 'Windows' && matrix.ffmpeg == 'bundled' shell: pwsh run: | $ErrorActionPreference = "Stop" New-Item -ItemType Directory -Force -Path "package/bin" | Out-Null Invoke-WebRequest -Uri "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip" -OutFile "ffmpeg.zip" Expand-Archive "ffmpeg.zip" -DestinationPath "ffmpeg_tmp" $ffmpegExe = Get-ChildItem -Path "ffmpeg_tmp" -Filter "ffmpeg.exe" -Recurse | Select-Object -First 1 if (-not $ffmpegExe) { throw "ffmpeg.exe not found in archive" } Copy-Item $ffmpegExe.FullName "package/bin/ffmpeg.exe" Remove-Item -Force "ffmpeg.zip" Remove-Item -Recurse -Force "ffmpeg_tmp" - name: Bundle FFmpeg (Linux) if: runner.os == 'Linux' && matrix.ffmpeg == 'bundled' shell: bash run: | set -euo pipefail mkdir -p package/bin ffmpeg_tmp curl -L "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz" -o ffmpeg.tar.xz tar -xf ffmpeg.tar.xz -C ffmpeg_tmp --strip-components=1 mv ffmpeg_tmp/ffmpeg package/bin/ffmpeg chmod +x package/bin/ffmpeg - name: Archive (Windows) if: runner.os == 'Windows' shell: pwsh run: | $ErrorActionPreference = "Stop" $version = "${{ steps.version.outputs.version }}" $variant = "${{ matrix.ffmpeg }}" if ($variant -eq "bundled") { $name = "ytpl-sync-windows-$version-ffmpeg.zip" } else { $name = "ytpl-sync-windows-$version.zip" } Compress-Archive -Path "package/*" -DestinationPath $name - name: Archive (Linux) if: runner.os == 'Linux' shell: bash run: | set -euo pipefail version="${{ steps.version.outputs.version }}" variant="${{ matrix.ffmpeg }}" if [[ "$variant" == "bundled" ]]; then name="ytpl-sync-linux-${version}-ffmpeg.tar.gz" else name="ytpl-sync-linux-${version}.tar.gz" fi tar -C package -czf "$name" . - name: Upload artifact uses: actions/upload-artifact@v7 with: name: packages-${{ runner.os }}-${{ matrix.ffmpeg }} path: | ytpl-sync-*.zip ytpl-sync-*.tar.gz release: name: Create Release needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - name: Download artifacts uses: actions/download-artifact@v8 with: path: artifacts - name: Generate release notes shell: bash env: TAG: ${{ inputs.tag }} run: | set -euo pipefail git fetch --tags --force VERSION="${TAG#v}" REPO="${GITHUB_REPOSITORY}" prev_tag="$(git tag --sort=-creatordate | grep -Fxv "$TAG" | head -n 1 || true)" { echo "### Changes" echo if [[ -n "$prev_tag" ]]; then echo "Compared to \`$prev_tag\`:" echo git log "${prev_tag}..${TAG}" --no-merges --pretty=format:'- %s (%h)' || true else echo "First tagged release:" echo git log "${TAG}" --no-merges --pretty=format:'- %s (%h)' || true fi echo echo echo "### Reports" echo "![Downloads](https://img.shields.io/github/downloads/${REPO}/${TAG}/total?style=flat-square&logo=github&label=Downloads)" echo "![Linux FFmpeg](https://img.shields.io/github/downloads/${REPO}/${TAG}/ytpl-sync-linux-${VERSION}-ffmpeg.tar.gz?style=flat-square&label=Linux+FFmpeg)" echo "![Linux](https://img.shields.io/github/downloads/${REPO}/${TAG}/ytpl-sync-linux-${VERSION}.tar.gz?style=flat-square&label=Linux)" echo "![Windows FFmpeg](https://img.shields.io/github/downloads/${REPO}/${TAG}/ytpl-sync-windows-${VERSION}-ffmpeg.zip?style=flat-square&label=Windows+FFmpeg)" echo "![Windows](https://img.shields.io/github/downloads/${REPO}/${TAG}/ytpl-sync-windows-${VERSION}.zip?style=flat-square&label=Windows)" echo } > release-notes.md - name: Create release uses: softprops/action-gh-release@v3 with: tag_name: ${{ inputs.tag }} draft: ${{ inputs.draft }} body_path: release-notes.md files: | artifacts/**/*.zip artifacts/**/*.tar.gz