diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml new file mode 100644 index 0000000..d4dccef --- /dev/null +++ b/.github/workflows/build-release.yml @@ -0,0 +1,247 @@ +name: Build & Release + +on: + workflow_dispatch: + inputs: + tag: + description: "Tag to create (e.g., v0.1.0)" + required: true + default: "v0.1.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 + 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, test] + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest] + ffmpeg: [bundled, system] + + 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" + + - 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" + pyinstaller --noconfirm --onefile --name "ytpl-sync" --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 --name "ytpl-sync" --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" + + - 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 }}" + $name = "ytpl-sync-windows-$version-$variant.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 }}" + name="ytpl-sync-linux-${version}-${variant}.tar.gz" + tar -C package -czf "$name" . + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: packages-${{ runner.os }}-${{ matrix.ffmpeg }} + path: | + *.zip + *.tar.gz + + release: + name: Create Release + needs: build + runs-on: ubuntu-latest + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ inputs.tag }} + draft: ${{ inputs.draft }} + files: | + artifacts/**/*.zip + artifacts/**/*.tar.gz diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index b7356bb..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,155 +0,0 @@ -name: Build Release V2 - -on: - workflow_dispatch: - inputs: - tag: - description: "Release tag (e.g., v0.1.0)" - required: true - default: "v0.1.0" - type: string - -permissions: - contents: write - packages: write - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: '3.11' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install pytest - - name: Run tests - env: - PYTHONPATH: ${{ github.workspace }} - run: pytest - - build: - needs: test - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - include: - - platform: windows - os: windows-latest - artifact_name: windows-release - - platform: linux - os: ubuntu-latest - artifact_name: linux-release - - steps: - - uses: actions/checkout@v6 - - - name: Get version - id: version - shell: bash - run: | - TAG="${{ github.event.inputs.tag || inputs.tag }}" - VERSION="${TAG#v}" - echo "version=$VERSION" >> $GITHUB_OUTPUT - - # --- WINDOWS BUILD --- - - name: Build Windows Package - if: matrix.platform == 'windows' - shell: pwsh - run: | - $VERSION = "${{ steps.version.outputs.version }}" - $WORKSPACE = "${{ github.workspace }}" - New-Item -ItemType Directory -Force -Path "$WORKSPACE/dist/windows/bin" - - # FFmpeg - Invoke-WebRequest -Uri "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip" -OutFile "$WORKSPACE/dist/windows/ffmpeg.zip" - Expand-Archive "$WORKSPACE/dist/windows/ffmpeg.zip" -DestinationPath "$WORKSPACE/dist/windows/ffmpeg_temp" - $ffmpegExe = Get-ChildItem -Path "$WORKSPACE/dist/windows/ffmpeg_temp" -Filter "ffmpeg.exe" -Recurse | Select-Object -First 1 - Move-Item $ffmpegExe.FullName "$WORKSPACE/dist/windows/bin/ffmpeg.exe" - - # Build .exe using PyInstaller - # Use --add-data to include the src folder so internal imports (like 'import cli') work - pip install pyinstaller - pyinstaller --onefile --name "yt-playlist-downloader" --workpath "$WORKSPACE/build" --specpath "$WORKSPACE" --distpath "$WORKSPACE/dist" --add-data "$WORKSPACE/src;src" "$WORKSPACE/yt-playlist-main.py" - Move-Item "$WORKSPACE/dist/yt-playlist-downloader.exe" "$WORKSPACE/dist/windows/yt-playlist-downloader.exe" - - # Cleanup & Archive - Remove-Item -Recurse -Force "$WORKSPACE/dist/windows/ffmpeg_temp", "$WORKSPACE/dist/windows/aria2_temp", "$WORKSPACE/dist/windows/*.zip" - Compress-Archive -Path "$WORKSPACE/dist/windows/*" -DestinationPath "$WORKSPACE/yt-playlist-windows-$VERSION.zip" - - # --- LINUX BUILD --- - - name: Build Linux Package - if: matrix.platform == 'linux' - run: | - VERSION="${{ steps.version.outputs.version }}" - mkdir -p "$GITHUB_WORKSPACE/dist/linux/bin" - cp "$GITHUB_WORKSPACE/yt-playlist-main.py" "$GITHUB_WORKSPACE/dist/linux/" - cp -r "$GITHUB_WORKSPACE/src" "$GITHUB_WORKSPACE/dist/linux/" - - # FFmpeg (static) - curl -L https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz -o "$GITHUB_WORKSPACE/ffmpeg.tar.xz" - mkdir -p "$GITHUB_WORKSPACE/ffmpeg_temp" - tar -xf "$GITHUB_WORKSPACE/ffmpeg.tar.xz" -C "$GITHUB_WORKSPACE/ffmpeg_temp" --strip-components=1 - mv "$GITHUB_WORKSPACE/ffmpeg_temp/ffmpeg" "$GITHUB_WORKSPACE/dist/linux/bin/" - chmod +x "$GITHUB_WORKSPACE/dist/linux/bin/ffmpeg" - - - name: Archive Linux Package - if: matrix.platform == 'linux' - run: | - VERSION="${{ steps.version.outputs.version }}" - # Archive - cd "$GITHUB_WORKSPACE/dist/linux" && tar -czf "$GITHUB_WORKSPACE/yt-playlist-linux-$VERSION.tar.gz" * - - - name: Upload Artifact - uses: actions/upload-artifact@v7 - with: - name: ${{ matrix.artifact_name }} - path: | - ${{ github.workspace }}/*.zip - ${{ github.workspace }}/*.tar.gz - - - release: - needs: build - runs-on: ubuntu-latest - if: startsWith(github.event.inputs.tag, 'v') - steps: - - name: Download all artifacts - uses: actions/download-artifact@v8 - with: - path: ${{ github.workspace }}/artifacts - - - name: Get version - id: version - shell: bash - run: | - TAG="${{ github.event.inputs.tag || inputs.tag }}" - VERSION="${TAG#v}" - echo "version=$VERSION" >> $GITHUB_OUTPUT - - - name: Login to GitHub Container Registry - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Push Docker images - run: | - docker load -i "${{ github.workspace }}/artifacts/docker-images/docker-image.tar" - docker push ghcr.io/${GITHUB_ACTOR}/ytpl-Sync:${{ steps.version.outputs.version }} - docker load -i "${{ github.workspace }}/artifacts/docker-images/docker-image-latest.tar" - docker push ghcr.io/${GITHUB_ACTOR}/ytpl-Sync:latest - - - name: Create Release - uses: softprops/action-gh-release@v3 - with: - tag_name: ${{ github.event.inputs.tag }} - draft: true - files: | - ${{ github.workspace }}/artifacts/**/*.zip - ${{ github.workspace }}/artifacts/**/*.tar.gz diff --git a/.github/workflows/dependency-updates.yml b/.github/workflows/dependency-updates.yml deleted file mode 100644 index d04af3f..0000000 --- a/.github/workflows/dependency-updates.yml +++ /dev/null @@ -1,64 +0,0 @@ -name: dependency-updates - -on: - workflow_dispatch: - schedule: - - cron: "0 12 * * 1" - -permissions: - contents: write - pull-requests: write - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - update-bundled-binaries: - name: Refresh pinned build dependencies - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Discover latest aria2 release - id: aria2 - shell: bash - run: | - set -euo pipefail - - latest_version="$( - git ls-remote --tags --refs https://github.com/aria2/aria2.git 'refs/tags/release-*' \ - | awk -F/ '{print $NF}' \ - | sed 's/^release-//' \ - | sort -V \ - | tail -n 1 - )" - - if [ -z "$latest_version" ]; then - echo "Unable to determine the latest aria2 release." >&2 - exit 1 - fi - - echo "version=$latest_version" >> "$GITHUB_OUTPUT" - - - name: Update release workflow pins - shell: bash - env: - ARIA2_VERSION: ${{ steps.aria2.outputs.version }} - run: | - set -euo pipefail - python3 -c 'from pathlib import Path; import os; path = Path(".github/workflows/build_v2.yml"); text = path.read_text(encoding="utf-8"); version = os.environ["ARIA2_VERSION"]; updated = text.replace("release-1.37.0", f"release-{version}").replace("aria2-1.37.0", f"aria2-{version}").replace("aria2c-linux-1.37.0", f"aria2c-linux-{version}"); path.write_text(updated, encoding="utf-8") if updated != text else None; print(f"Updated aria2 references to {version}." if updated != text else "No aria2 references changed.")' - - - name: Create Pull Request - uses: peter-evans/create-pull-request@v8 - with: - token: ${{ secrets.GITHUB_TOKEN }} - commit-message: "chore: refresh aria2 build pins" - title: "Refresh aria2 build pins" - body: | - Automated maintenance update for the bundled aria2 release references used by the release workflow. - branch: dependency-updates/aria2 - delete-branch: true - labels: dependencies, maintenance diff --git a/ytpl-sync-entry.py b/ytpl-sync-entry.py new file mode 100644 index 0000000..8d47515 --- /dev/null +++ b/ytpl-sync-entry.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from app.cli import main + + +if __name__ == "__main__": + raise SystemExit(main()) +