diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ead9f33..e732ea6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,342 +1,453 @@ name: CI on: - push: - branches: [main] - pull_request: - branches: [main] + push: + branches: [main] + pull_request: + branches: [main] concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true + group: ci-${{ github.ref }} + cancel-in-progress: true permissions: - contents: read + contents: read jobs: - markdown: - runs-on: ubuntu-latest - name: Markdown Lint - - steps: - - uses: actions/checkout@v4 - - - name: Lint Markdown files - uses: DavidAnson/markdownlint-cli2-action@v19 - with: - globs: "**/*.md" - config: ".markdownlint.yaml" - - quality: - runs-on: ubuntu-latest - name: Lint, Format & Test - - steps: - - uses: actions/checkout@v4 - - - uses: subosito/flutter-action@v2 - with: - channel: stable - cache: true - - - name: Cache pub dependencies - uses: actions/cache@v4 - with: - path: ~/.pub-cache - key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.yaml') }} - restore-keys: ${{ runner.os }}-pub- - - - name: Get dependencies - run: | - cd core && flutter pub get - cd ../listener && flutter pub get - cd ../app && flutter pub get - - - name: Check formatting - run: dart format --output=none --set-exit-if-changed . - - - name: Analyze - run: | - cd core && flutter analyze - cd ../listener && flutter analyze - cd ../app && flutter analyze - - - name: Check auto-fixable issues - run: | - OUTPUT=$(dart fix --dry-run . 2>&1) - echo "$OUTPUT" - echo "$OUTPUT" | grep -q "Nothing to fix!" || { echo "::error::Auto-fixable issues found. Run 'dart fix --apply' and commit."; exit 1; } - - - name: Verify generated code (Drift) - run: | - cd core && dart run build_runner build --delete-conflicting-outputs - git diff --exit-code . || { echo "::error::Generated code is out of date. Run 'dart run build_runner build --delete-conflicting-outputs' in core/ and commit."; exit 1; } - - - name: Verify generated localizations - run: | - cd app && flutter gen-l10n - git diff --exit-code lib/l10n/ || { echo "::error::Generated l10n files are out of date. Run 'flutter gen-l10n' in app/ and commit."; exit 1; } - - - name: Check outdated dependencies - run: | - cd core && flutter pub outdated --no-dev-dependencies - cd ../listener && flutter pub outdated --no-dev-dependencies - cd ../app && flutter pub outdated --no-dev-dependencies - continue-on-error: true - - - name: Run tests - run: | - cd core && flutter test --coverage - cd ../listener && flutter test --coverage - cd ../app && flutter test --coverage - - - name: Upload coverage artifacts - uses: actions/upload-artifact@v4 - if: always() - with: - name: coverage-reports - path: | - core/coverage/lcov.info - listener/coverage/lcov.info - app/coverage/lcov.info - retention-days: 1 - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 - if: always() - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: core/coverage/lcov.info,listener/coverage/lcov.info,app/coverage/lcov.info - flags: core,listener,app - fail_ci_if_error: false - - release-readiness: - runs-on: ubuntu-latest - name: Release Readiness - - steps: - - uses: actions/checkout@v4 - - - name: Validate pubspec structure - run: | - errors=0 - for pkg in app core listener; do - if [ ! -f "$pkg/pubspec.yaml" ]; then - echo "::error::Missing $pkg/pubspec.yaml" - errors=$((errors + 1)) - continue - fi - version=$(grep '^version:' "$pkg/pubspec.yaml" | head -1 | awk '{print $2}') - name=$(grep '^name:' "$pkg/pubspec.yaml" | head -1 | awk '{print $2}') - if [ -z "$version" ]; then - echo "::error::Missing version field in $pkg/pubspec.yaml" - errors=$((errors + 1)) - fi - if [ -z "$name" ]; then - echo "::error::Missing name field in $pkg/pubspec.yaml" - errors=$((errors + 1)) - fi - echo "✓ $pkg: name=$name version=$version" - done - [ $errors -eq 0 ] || exit 1 - - - name: Validate release workflow references - run: | - errors=0 - for wf in release-win.yml release-mac.yml release-linux.yml; do - if [ ! -f ".github/workflows/$wf" ]; then - echo "::error::Missing .github/workflows/$wf (referenced by release.yml)" - errors=$((errors + 1)) - else - echo "✓ .github/workflows/$wf" - fi - done - [ $errors -eq 0 ] || exit 1 - - - name: Validate project structure - run: | - errors=0 - for dir in app/lib app/windows app/macos app/linux app/assets; do - if [ ! -d "$dir" ]; then - echo "::error::Missing required directory: $dir" - errors=$((errors + 1)) - else - echo "✓ $dir/" - fi - done - for file in app/l10n.yaml app/pubspec.yaml core/pubspec.yaml listener/pubspec.yaml; do - if [ ! -f "$file" ]; then - echo "::error::Missing required file: $file" - errors=$((errors + 1)) - else - echo "✓ $file" - fi - done - [ $errors -eq 0 ] || exit 1 - - - name: Simulate release version extraction - run: | - test_tags=("v2.1.0" "v2.0.0-beta.1" "v3.0.0" "v1.0.0-rc.1") - for tag in "${test_tags[@]}"; do - ref="refs/tags/$tag" - if [[ "$ref" =~ refs/tags/v(.+) ]]; then - VERSION="${BASH_REMATCH[1]}" - # Validate version format (semver) - if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then - echo "::error::Tag $tag produces invalid version: $VERSION" - exit 1 - fi - echo "✓ $tag → $VERSION" - else - echo "::error::Tag $tag would fail version extraction in release.yml" - exit 1 - fi - done - - build: - needs: quality - runs-on: windows-latest - name: Build Verification (Windows) - - steps: - - uses: actions/checkout@v4 - - - uses: subosito/flutter-action@v2 - with: - channel: stable - cache: true - - - name: Cache pub dependencies - uses: actions/cache@v4 - with: - path: ~/.pub-cache - key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.yaml') }} - restore-keys: ${{ runner.os }}-pub- - - - name: Get dependencies - run: | - cd core; flutter pub get - cd ../listener; flutter pub get - cd ../app; flutter pub get - - - name: Build Windows release - run: cd app; flutter build windows --release - - build-linux: - needs: quality - runs-on: ubuntu-22.04 - name: Build Verification (Linux) - - steps: - - uses: actions/checkout@v4 - - - uses: subosito/flutter-action@v2 - with: - channel: stable - cache: true - - - name: Cache pub dependencies - uses: actions/cache@v4 - with: - path: ~/.pub-cache - key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.yaml') }} - restore-keys: ${{ runner.os }}-pub- - - - name: Install Linux build dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - clang \ - cmake \ - desktop-file-utils \ - libayatana-appindicator3-dev \ - libfuse2 \ - libgtk-3-dev \ - libkeybinder-3.0-dev \ - liblzma-dev \ - libx11-dev \ - libxtst-dev \ - lld-14 \ - ninja-build \ - patchelf \ - pkg-config - - - name: Verify Linux toolchain preflight - run: | - set -euo pipefail - test -x /usr/lib/llvm-14/bin/ld.lld || test -x /usr/bin/ld.lld-14 || test -x /usr/bin/ld.lld - pkg-config --modversion gtk+-3.0 - pkg-config --modversion keybinder-3.0 - pkg-config --modversion ayatana-appindicator3-0.1 - pkg-config --modversion x11 - pkg-config --modversion xtst - - - name: Get dependencies - run: | - cd core && flutter pub get - cd ../listener && flutter pub get - cd ../app && flutter pub get - - - name: Build Linux release - run: cd app && flutter build linux --release - - build-macos: - needs: quality - runs-on: macos-latest - name: Build Verification (macOS) - - steps: - - uses: actions/checkout@v4 - - - uses: subosito/flutter-action@v2 - with: - channel: stable - cache: true - - - name: Cache pub dependencies - uses: actions/cache@v4 - with: - path: ~/.pub-cache - key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.yaml') }} - restore-keys: ${{ runner.os }}-pub- - - - name: Get dependencies - run: | - cd core && flutter pub get - cd ../listener && flutter pub get - cd ../app && flutter pub get - - - name: Build macOS release - run: cd app && flutter build macos --release - - sonarcloud: - name: SonarCloud - needs: quality - runs-on: ubuntu-latest - if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository - - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Download coverage reports - uses: actions/download-artifact@v4 - with: - name: coverage-reports - path: . - - - name: SonarCloud Scan - uses: SonarSource/sonarcloud-github-action@v5 - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - with: - args: > - -Dsonar.projectKey=${{ vars.SONAR_PROJECT_KEY }} - -Dsonar.organization=${{ vars.SONAR_ORGANIZATION }} - -Dsonar.sources=core/lib,listener/lib,app/lib - -Dsonar.tests=core/test,listener/test,app/test - -Dsonar.dart.lcov.reportPaths=core/coverage/lcov.info,listener/coverage/lcov.info,app/coverage/lcov.info - -Dsonar.exclusions=**/generated/**,**/*.g.dart,**/*.freezed.dart,**/l10n/**,**/core.dart - -Dsonar.coverage.exclusions=**/main.dart,**/shell/**,**/services/auto_update_service.dart,**/windows_clipboard_listener.dart,**/l10n/**,**/*.g.dart,**/*.freezed.dart,**/core.dart,**/screens/settings_screen.dart + markdown: + runs-on: ubuntu-latest + name: Markdown Lint + + steps: + - uses: actions/checkout@v4 + + - name: Lint Markdown files + uses: DavidAnson/markdownlint-cli2-action@v19 + with: + globs: "**/*.md" + config: ".markdownlint.yaml" + + quality: + runs-on: ubuntu-latest + name: Lint, Format & Test + + steps: + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Cache pub dependencies + uses: actions/cache@v4 + with: + path: ~/.pub-cache + key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.yaml') }} + restore-keys: ${{ runner.os }}-pub- + + - name: Get dependencies + run: | + cd core && flutter pub get + cd ../listener && flutter pub get + cd ../app && flutter pub get + + - name: Check formatting + run: dart format --output=none --set-exit-if-changed . + + - name: Analyze + run: | + cd core && flutter analyze + cd ../listener && flutter analyze + cd ../app && flutter analyze + + - name: Check auto-fixable issues + run: | + OUTPUT=$(dart fix --dry-run . 2>&1) + echo "$OUTPUT" + echo "$OUTPUT" | grep -q "Nothing to fix!" || { echo "::error::Auto-fixable issues found. Run 'dart fix --apply' and commit."; exit 1; } + + - name: Verify generated code (Drift) + run: | + cd core && dart run build_runner build --delete-conflicting-outputs + git diff --exit-code . || { echo "::error::Generated code is out of date. Run 'dart run build_runner build --delete-conflicting-outputs' in core/ and commit."; exit 1; } + + - name: Verify generated localizations + run: | + cd app && flutter gen-l10n + git diff --exit-code lib/l10n/ || { echo "::error::Generated l10n files are out of date. Run 'flutter gen-l10n' in app/ and commit."; exit 1; } + + - name: Check outdated dependencies + run: | + cd core && flutter pub outdated --no-dev-dependencies + cd ../listener && flutter pub outdated --no-dev-dependencies + cd ../app && flutter pub outdated --no-dev-dependencies + continue-on-error: true + + - name: Run tests + run: | + cd core && flutter test --coverage + cd ../listener && flutter test --coverage + cd ../app && flutter test --coverage + + - name: Upload coverage artifacts + uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-reports + path: | + core/coverage/lcov.info + listener/coverage/lcov.info + app/coverage/lcov.info + retention-days: 1 + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + if: always() + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: core/coverage/lcov.info,listener/coverage/lcov.info,app/coverage/lcov.info + flags: core,listener,app + fail_ci_if_error: false + + release-readiness: + runs-on: ubuntu-latest + name: Release Readiness + + steps: + - uses: actions/checkout@v4 + + - name: Validate pubspec structure + run: | + errors=0 + for pkg in app core listener; do + if [ ! -f "$pkg/pubspec.yaml" ]; then + echo "::error::Missing $pkg/pubspec.yaml" + errors=$((errors + 1)) + continue + fi + version=$(grep '^version:' "$pkg/pubspec.yaml" | head -1 | awk '{print $2}') + name=$(grep '^name:' "$pkg/pubspec.yaml" | head -1 | awk '{print $2}') + if [ -z "$version" ]; then + echo "::error::Missing version field in $pkg/pubspec.yaml" + errors=$((errors + 1)) + fi + if [ -z "$name" ]; then + echo "::error::Missing name field in $pkg/pubspec.yaml" + errors=$((errors + 1)) + fi + echo "✓ $pkg: name=$name version=$version" + done + [ $errors -eq 0 ] || exit 1 + + - name: Validate release workflow references + run: | + errors=0 + for wf in release-win.yml release-mac.yml release-linux.yml; do + if [ ! -f ".github/workflows/$wf" ]; then + echo "::error::Missing .github/workflows/$wf (referenced by release.yml)" + errors=$((errors + 1)) + else + echo "✓ .github/workflows/$wf" + fi + done + [ $errors -eq 0 ] || exit 1 + + - name: Validate project structure + run: | + errors=0 + for dir in app/lib app/windows app/macos app/linux app/assets; do + if [ ! -d "$dir" ]; then + echo "::error::Missing required directory: $dir" + errors=$((errors + 1)) + else + echo "✓ $dir/" + fi + done + for file in app/l10n.yaml app/pubspec.yaml core/pubspec.yaml listener/pubspec.yaml; do + if [ ! -f "$file" ]; then + echo "::error::Missing required file: $file" + errors=$((errors + 1)) + else + echo "✓ $file" + fi + done + [ $errors -eq 0 ] || exit 1 + + - name: Simulate release version extraction + run: | + test_tags=("v2.1.0" "v2.0.0-beta.1" "v3.0.0" "v1.0.0-rc.1") + for tag in "${test_tags[@]}"; do + ref="refs/tags/$tag" + if [[ "$ref" =~ refs/tags/v(.+) ]]; then + VERSION="${BASH_REMATCH[1]}" + # Validate version format (semver) + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then + echo "::error::Tag $tag produces invalid version: $VERSION" + exit 1 + fi + echo "✓ $tag → $VERSION" + else + echo "::error::Tag $tag would fail version extraction in release.yml" + exit 1 + fi + done + + build: + needs: quality + runs-on: windows-latest + name: Build Verification (Windows) + + steps: + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Cache pub dependencies + uses: actions/cache@v4 + with: + path: ~/.pub-cache + key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.yaml') }} + restore-keys: ${{ runner.os }}-pub- + + - name: Get dependencies + run: | + cd core; flutter pub get + cd ../listener; flutter pub get + cd ../app; flutter pub get + + - name: Build Windows release + run: cd app; flutter build windows --release + + build-linux: + needs: quality + runs-on: ubuntu-22.04 + name: Build Verification (Linux) + + steps: + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Cache pub dependencies + uses: actions/cache@v4 + with: + path: ~/.pub-cache + key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.yaml') }} + restore-keys: ${{ runner.os }}-pub- + + - name: Install Linux build dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + clang \ + cmake \ + desktop-file-utils \ + libayatana-appindicator3-dev \ + libfuse2 \ + libgtk-3-dev \ + libkeybinder-3.0-dev \ + liblzma-dev \ + libx11-dev \ + libxtst-dev \ + lld-14 \ + ninja-build \ + patchelf \ + pkg-config + + - name: Verify Linux toolchain preflight + run: | + set -euo pipefail + test -x /usr/lib/llvm-14/bin/ld.lld || test -x /usr/bin/ld.lld-14 || test -x /usr/bin/ld.lld + pkg-config --modversion gtk+-3.0 + pkg-config --modversion keybinder-3.0 + pkg-config --modversion ayatana-appindicator3-0.1 + pkg-config --modversion x11 + pkg-config --modversion xtst + + - name: Get dependencies + run: | + cd core && flutter pub get + cd ../listener && flutter pub get + cd ../app && flutter pub get + + - name: Build Linux release + run: cd app && flutter build linux --release + + package-linux-smoke: + needs: quality + runs-on: ubuntu-22.04 + timeout-minutes: 30 + name: Linux Package Smoke (deb/rpm) + + steps: + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Cache pub dependencies + uses: actions/cache@v4 + with: + path: ~/.pub-cache + key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.yaml') }} + restore-keys: ${{ runner.os }}-pub- + + - name: Install Linux packaging dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + clang \ + cmake \ + desktop-file-utils \ + libayatana-appindicator3-dev \ + libfuse2 \ + libgtk-3-dev \ + libkeybinder-3.0-dev \ + liblzma-dev \ + libx11-dev \ + libxtst-dev \ + lld-14 \ + ninja-build \ + patchelf \ + pkg-config \ + rpm + + - name: Get dependencies + run: | + cd core && flutter pub get + cd ../listener && flutter pub get + cd ../app && flutter pub get + + - name: Install Fastforge + run: dart pub global activate fastforge + + - name: Normalize app version for Linux package smoke + run: | + set -euo pipefail + RAW_VERSION=$(grep '^version:' app/pubspec.yaml | awk '{print $2}') + BASE_VERSION="${RAW_VERSION%%-*}" + + if [[ "$RAW_VERSION" != "$BASE_VERSION" ]]; then + echo "Using RPM-compatible version for smoke packaging: $RAW_VERSION -> $BASE_VERSION" + sed -i "s/^version:.*/version: $BASE_VERSION/" app/pubspec.yaml + fi + + echo "Effective app version for package smoke:" + grep '^version:' app/pubspec.yaml + + - name: Build deb package + run: | + cd app + fastforge package --platform linux --targets deb + + - name: Build rpm package + run: | + cd app + fastforge package --platform linux --targets rpm --skip-clean + + - name: Resolve package paths + run: | + set -euo pipefail + DEB_PATH=$(find app/dist app/build -type f -name "*.deb" 2>/dev/null | head -n 1) + RPM_PATH=$(find app/dist app/build -type f -name "*.rpm" 2>/dev/null | head -n 1) + test -n "$DEB_PATH" + test -n "$RPM_PATH" + echo "DEB_PATH=$DEB_PATH" >> "$GITHUB_ENV" + echo "RPM_PATH=$RPM_PATH" >> "$GITHUB_ENV" + echo "Using deb: $DEB_PATH" + echo "Using rpm: $RPM_PATH" + + - name: Validate package dependency metadata + run: | + set -euo pipefail + + echo "Checking deb Depends metadata" + DEB_INFO=$(dpkg-deb -I "$DEB_PATH") + echo "$DEB_INFO" | grep -Eq "Depends:.*libayatana-appindicator3-1" + echo "$DEB_INFO" | grep -Eq "Depends:.*libkeybinder-3.0-0" + echo "$DEB_INFO" | grep -Eq "Depends:.*libx11-6" + echo "$DEB_INFO" | grep -Eq "Depends:.*libxtst6" + + echo "Checking rpm Requires metadata" + RPM_REQUIRES=$(rpm -qpR "$RPM_PATH") + echo "$RPM_REQUIRES" | grep -Eq "^libayatana-appindicator-gtk3$" + echo "$RPM_REQUIRES" | grep -Eq "^keybinder3$" + echo "$RPM_REQUIRES" | grep -Eq "^gtk3$" + echo "$RPM_REQUIRES" | grep -Eq "^libX11$" + echo "$RPM_REQUIRES" | grep -Eq "^libXtst$" + + - name: Run package smoke tests + run: | + set -euo pipefail + bash scripts/ci/linux-package-smoke.sh deb "$DEB_PATH" + bash scripts/ci/linux-package-smoke.sh rpm "$RPM_PATH" + + build-macos: + needs: quality + runs-on: macos-latest + name: Build Verification (macOS) + + steps: + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Cache pub dependencies + uses: actions/cache@v4 + with: + path: ~/.pub-cache + key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.yaml') }} + restore-keys: ${{ runner.os }}-pub- + + - name: Get dependencies + run: | + cd core && flutter pub get + cd ../listener && flutter pub get + cd ../app && flutter pub get + + - name: Build macOS release + run: cd app && flutter build macos --release + + sonarcloud: + name: SonarCloud + needs: quality + runs-on: ubuntu-latest + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Download coverage reports + uses: actions/download-artifact@v4 + with: + name: coverage-reports + path: . + + - name: SonarCloud Scan + uses: SonarSource/sonarcloud-github-action@v5 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + with: + args: > + -Dsonar.projectKey=${{ vars.SONAR_PROJECT_KEY }} + -Dsonar.organization=${{ vars.SONAR_ORGANIZATION }} + -Dsonar.sources=core/lib,listener/lib,app/lib + -Dsonar.tests=core/test,listener/test,app/test + -Dsonar.dart.lcov.reportPaths=core/coverage/lcov.info,listener/coverage/lcov.info,app/coverage/lcov.info + -Dsonar.exclusions=**/generated/**,**/*.g.dart,**/*.freezed.dart,**/l10n/**,**/core.dart + -Dsonar.coverage.exclusions=**/main.dart,**/shell/**,**/services/auto_update_service.dart,**/windows_clipboard_listener.dart,**/l10n/**,**/*.g.dart,**/*.freezed.dart,**/core.dart,**/screens/settings_screen.dart diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml index ab2a717..a2ad060 100644 --- a/.github/workflows/release-linux.yml +++ b/.github/workflows/release-linux.yml @@ -1,200 +1,200 @@ name: Release (Linux) on: - workflow_call: - inputs: - version: - required: true - type: string - workflow_dispatch: - inputs: - version: - description: "Version to use (e.g. 2.1.0). Defaults to 2.0.0-dev" - required: false - default: "2.0.0-dev" + workflow_call: + inputs: + version: + required: true + type: string + workflow_dispatch: + inputs: + version: + description: "Version to use (e.g. 2.1.0). Defaults to 2.0.0-dev" + required: false + default: "2.0.0-dev" permissions: - contents: read + contents: read jobs: - build-linux: - runs-on: ubuntu-22.04 - timeout-minutes: 45 - name: Build Linux (AppImage, deb, rpm) - - steps: - - uses: actions/checkout@v6 - with: - ref: ${{ github.ref_name }} - - - name: Resolve version - id: get_version - run: | - VERSION="${{ inputs.version }}" - - IS_PRERELEASE="false" - if [[ "$VERSION" == *-* ]]; then - IS_PRERELEASE="true" - fi - - echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" - echo "IS_PRERELEASE=$IS_PRERELEASE" >> "$GITHUB_OUTPUT" - echo "Version: $VERSION PreRelease: $IS_PRERELEASE" - - - uses: subosito/flutter-action@v2 - with: - channel: stable - cache: true - - - name: Cache pub dependencies - uses: actions/cache@v5 - with: - path: ~/.pub-cache - key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.yaml') }} - restore-keys: ${{ runner.os }}-pub- - - - name: Install Linux build dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - clang \ - cmake \ - desktop-file-utils \ - libayatana-appindicator3-dev \ - libfuse2 \ - libgtk-3-dev \ - libkeybinder-3.0-dev \ - liblzma-dev \ - libx11-dev \ - libxtst-dev \ - lld-14 \ - ninja-build \ - patchelf \ - pkg-config \ - rpm - - - name: Verify Linux toolchain preflight - run: | - set -euo pipefail - test -x /usr/lib/llvm-14/bin/ld.lld || test -x /usr/bin/ld.lld-14 || test -x /usr/bin/ld.lld - pkg-config --modversion gtk+-3.0 - pkg-config --modversion keybinder-3.0 - pkg-config --modversion ayatana-appindicator3-0.1 - pkg-config --modversion x11 - pkg-config --modversion xtst - - - name: Install Fastforge - run: dart pub global activate fastforge - - - name: Install appimagetool - run: | - wget -qO /usr/local/bin/appimagetool \ - "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" - chmod +x /usr/local/bin/appimagetool - - - name: Get dependencies - run: | - cd core && flutter pub get - cd ../listener && flutter pub get - cd ../app && flutter pub get - - - name: Update pubspec version from tag - run: | - VERSION="${{ steps.get_version.outputs.VERSION }}" - sed -i "s/^version:.*/version: $VERSION/" app/pubspec.yaml - echo "Updated pubspec.yaml version to: $VERSION" - - - name: Package AppImage - env: - APPIMAGE_EXTRACT_AND_RUN: "1" - run: | - cd app - fastforge package \ - --platform linux \ - --targets appimage \ - --build-dart-define "APP_VERSION=${{ steps.get_version.outputs.VERSION }}" - - - name: Rename AppImage - run: | - VERSION="${{ steps.get_version.outputs.VERSION }}" - APPIMAGE=$(find app/dist app/build -type f -name "*.AppImage" 2>/dev/null | head -n 1) - if [[ -z "$APPIMAGE" ]]; then - echo "::error::No AppImage generated" - exit 1 - fi - mkdir -p app/dist - DEST="app/dist/CopyPaste_${VERSION}_x86_64.AppImage" - mv "$APPIMAGE" "$DEST" - chmod +x "$DEST" - echo "Renamed to: $(basename "$DEST")" - - - name: Package deb - run: | - cd app - fastforge package \ - --platform linux \ - --targets deb \ - --build-dart-define "APP_VERSION=${{ steps.get_version.outputs.VERSION }}" \ - --skip-clean - - - name: Rename deb - run: | - VERSION="${{ steps.get_version.outputs.VERSION }}" - DEB=$(find app/dist app/build -type f -name "*.deb" 2>/dev/null | head -n 1) - if [[ -z "$DEB" ]]; then - echo "::error::No deb package generated" - exit 1 - fi - mkdir -p app/dist - DEST="app/dist/CopyPaste_${VERSION}_amd64.deb" - mv "$DEB" "$DEST" - echo "Renamed to: $(basename "$DEST")" - - - name: Package rpm - run: | - cd app - fastforge package \ - --platform linux \ - --targets rpm \ - --build-dart-define "APP_VERSION=${{ steps.get_version.outputs.VERSION }}" \ - --skip-clean - - - name: Rename rpm - run: | - VERSION="${{ steps.get_version.outputs.VERSION }}" - RPM=$(find app/dist app/build -type f -name "*.rpm" 2>/dev/null | head -n 1) - if [[ -z "$RPM" ]]; then - echo "::error::No rpm package generated" - exit 1 - fi - mkdir -p app/dist - DEST="app/dist/CopyPaste_${VERSION}_x86_64.rpm" - mv "$RPM" "$DEST" - echo "Renamed to: $(basename "$DEST")" - - - name: Publish deb to Cloudsmith - if: github.event_name == 'push' - env: - CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }} - run: | - pip install cloudsmith-cli - cloudsmith push deb rgdevment/copypaste/any-distro/any-version \ - app/dist/CopyPaste_${{ steps.get_version.outputs.VERSION }}_amd64.deb --republish - - - name: Publish rpm to Cloudsmith - if: github.event_name == 'push' - env: - CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }} - run: | - cloudsmith push rpm rgdevment/copypaste/any-distro/any-version \ - app/dist/CopyPaste_${{ steps.get_version.outputs.VERSION }}_x86_64.rpm --republish - - - name: Upload artifact - uses: actions/upload-artifact@v6 - with: - name: release-linux - path: | - app/dist/*.AppImage - app/dist/*.deb - app/dist/*.rpm - retention-days: 5 + build-linux: + runs-on: ubuntu-22.04 + timeout-minutes: 45 + name: Build Linux (AppImage, deb, rpm) + + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.ref_name }} + + - name: Resolve version + id: get_version + run: | + VERSION="${{ inputs.version }}" + + IS_PRERELEASE="false" + if [[ "$VERSION" == *-* ]]; then + IS_PRERELEASE="true" + fi + + echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" + echo "IS_PRERELEASE=$IS_PRERELEASE" >> "$GITHUB_OUTPUT" + echo "Version: $VERSION PreRelease: $IS_PRERELEASE" + + - uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Cache pub dependencies + uses: actions/cache@v5 + with: + path: ~/.pub-cache + key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.yaml') }} + restore-keys: ${{ runner.os }}-pub- + + - name: Install Linux build dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + clang \ + cmake \ + desktop-file-utils \ + libayatana-appindicator3-dev \ + libfuse2 \ + libgtk-3-dev \ + libkeybinder-3.0-dev \ + liblzma-dev \ + libx11-dev \ + libxtst-dev \ + lld-14 \ + ninja-build \ + patchelf \ + pkg-config \ + rpm + + - name: Verify Linux toolchain preflight + run: | + set -euo pipefail + test -x /usr/lib/llvm-14/bin/ld.lld || test -x /usr/bin/ld.lld-14 || test -x /usr/bin/ld.lld + pkg-config --modversion gtk+-3.0 + pkg-config --modversion keybinder-3.0 + pkg-config --modversion ayatana-appindicator3-0.1 + pkg-config --modversion x11 + pkg-config --modversion xtst + + - name: Install Fastforge + run: dart pub global activate fastforge + + - name: Install appimagetool + run: | + wget -qO /usr/local/bin/appimagetool \ + "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" + chmod +x /usr/local/bin/appimagetool + + - name: Get dependencies + run: | + cd core && flutter pub get + cd ../listener && flutter pub get + cd ../app && flutter pub get + + - name: Update pubspec version from tag + run: | + VERSION="${{ steps.get_version.outputs.VERSION }}" + sed -i "s/^version:.*/version: $VERSION/" app/pubspec.yaml + echo "Updated pubspec.yaml version to: $VERSION" + + - name: Package AppImage + env: + APPIMAGE_EXTRACT_AND_RUN: "1" + run: | + cd app + fastforge package \ + --platform linux \ + --targets appimage \ + --build-dart-define "APP_VERSION=${{ steps.get_version.outputs.VERSION }}" + + - name: Rename AppImage + run: | + VERSION="${{ steps.get_version.outputs.VERSION }}" + APPIMAGE=$(find app/dist app/build -type f -name "*.AppImage" 2>/dev/null | head -n 1) + if [[ -z "$APPIMAGE" ]]; then + echo "::error::No AppImage generated" + exit 1 + fi + mkdir -p app/dist + DEST="app/dist/CopyPaste_${VERSION}_x86_64.AppImage" + mv "$APPIMAGE" "$DEST" + chmod +x "$DEST" + echo "Renamed to: $(basename "$DEST")" + + - name: Package deb + run: | + cd app + fastforge package \ + --platform linux \ + --targets deb \ + --build-dart-define "APP_VERSION=${{ steps.get_version.outputs.VERSION }}" \ + --skip-clean + + - name: Rename deb + run: | + VERSION="${{ steps.get_version.outputs.VERSION }}" + DEB=$(find app/dist app/build -type f -name "*.deb" 2>/dev/null | head -n 1) + if [[ -z "$DEB" ]]; then + echo "::error::No deb package generated" + exit 1 + fi + mkdir -p app/dist + DEST="app/dist/CopyPaste_${VERSION}_amd64.deb" + mv "$DEB" "$DEST" + echo "Renamed to: $(basename "$DEST")" + + - name: Package rpm + run: | + cd app + fastforge package \ + --platform linux \ + --targets rpm \ + --build-dart-define "APP_VERSION=${{ steps.get_version.outputs.VERSION }}" \ + --skip-clean + + - name: Rename rpm + run: | + VERSION="${{ steps.get_version.outputs.VERSION }}" + RPM=$(find app/dist app/build -type f -name "*.rpm" 2>/dev/null | head -n 1) + if [[ -z "$RPM" ]]; then + echo "::error::No rpm package generated" + exit 1 + fi + mkdir -p app/dist + DEST="app/dist/CopyPaste_${VERSION}_x86_64.rpm" + mv "$RPM" "$DEST" + echo "Renamed to: $(basename "$DEST")" + + - name: Publish deb to Cloudsmith + if: github.event_name == 'push' + env: + CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }} + run: | + pip install cloudsmith-cli + cloudsmith push deb rgdevment/copypaste/any-distro/any-version \ + app/dist/CopyPaste_${{ steps.get_version.outputs.VERSION }}_amd64.deb --republish + + - name: Publish rpm to Cloudsmith + if: github.event_name == 'push' + env: + CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }} + run: | + cloudsmith push rpm rgdevment/copypaste/any-distro/any-version \ + app/dist/CopyPaste_${{ steps.get_version.outputs.VERSION }}_x86_64.rpm --republish + + - name: Upload artifact + uses: actions/upload-artifact@v6 + with: + name: release-linux + path: | + app/dist/*.AppImage + app/dist/*.deb + app/dist/*.rpm + retention-days: 5 diff --git a/README.md b/README.md index d3be352..bca2f20 100644 --- a/README.md +++ b/README.md @@ -413,6 +413,7 @@ sudo dnf install copypaste > **Note:** Requires an **X11 session**. On Wayland, global hotkey and auto-paste are unavailable — a warning is shown at startup. > **Permissions note:** `apt`/`dnf` installation writes to system locations, so `sudo` is required. If your user cannot use `sudo`, those commands will fail with permission errors. > **No-sudo alternatives:** Use **Homebrew (Linux)** if available for your user, or run the `.AppImage` from your home directory (`chmod +x CopyPaste-*.AppImage && ./CopyPaste-*.AppImage`). +> **Runtime note:** On standard desktop installs, `apt`/`dnf` resolve required libraries automatically. Very minimal VMs/containers may need additional desktop runtime libraries. **Alternative Linux (requires Homebrew installed):** diff --git a/app/linux/packaging/deb/make_config.yaml b/app/linux/packaging/deb/make_config.yaml index 4d88265..03a1d85 100644 --- a/app/linux/packaging/deb/make_config.yaml +++ b/app/linux/packaging/deb/make_config.yaml @@ -7,6 +7,12 @@ priority: optional section: x11 installed_size: 51200 essential: false +dependencies: + - libayatana-appindicator3-1 + - libkeybinder-3.0-0 + - libgtk-3-0 | libgtk-3-0t64 + - libx11-6 + - libxtst6 icon: assets/icons/icon_app_256.png generic_name: Clipboard Manager startup_notify: true diff --git a/app/linux/packaging/rpm/make_config.yaml b/app/linux/packaging/rpm/make_config.yaml index ee03f18..30e7403 100644 --- a/app/linux/packaging/rpm/make_config.yaml +++ b/app/linux/packaging/rpm/make_config.yaml @@ -8,6 +8,12 @@ packager: rgdevment packagerEmail: rgdevment@apirest.cl license: GPLv3 url: https://github.com/rgdevment/CopyPaste +requires: + - libayatana-appindicator-gtk3 + - keybinder3 + - gtk3 + - libX11 + - libXtst generic_name: Clipboard Manager startup_notify: true categories: diff --git a/scripts/ci/linux-package-smoke.sh b/scripts/ci/linux-package-smoke.sh new file mode 100644 index 0000000..9783e06 --- /dev/null +++ b/scripts/ci/linux-package-smoke.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + echo "Usage: $0 " >&2 +} + +if [[ $# -ne 2 ]]; then + usage + exit 1 +fi + +package_type="$1" +package_path="$2" + +if [[ "$package_path" = /* ]]; then + host_package="$package_path" +else + host_package="$PWD/$package_path" +fi + +if [[ ! -f "$host_package" ]]; then + echo "Package file not found: $host_package" >&2 + exit 1 +fi + +if [[ "$package_type" == "deb" ]]; then + echo "[smoke] Running deb smoke in ubuntu:22.04" + docker run --rm -v "$host_package:/tmp/copypaste.deb:ro" ubuntu:22.04 bash -lc ' + set -euo pipefail + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y file /tmp/copypaste.deb + + BIN=$(command -v copypaste || true) + if [[ -z "$BIN" ]]; then + BIN=$(find /usr -type f -name copypaste 2>/dev/null | head -n 1) + fi + test -n "$BIN" + + BIN_REAL=$(readlink -f "$BIN" || echo "$BIN") + APP_DIR=$(dirname "$BIN_REAL") + LIB_PATHS=() + for candidate in "$APP_DIR/lib" "/usr/share/copypaste/lib"; do + if [[ -d "$candidate" ]]; then + LIB_PATHS+=("$candidate") + fi + done + if [[ "${#LIB_PATHS[@]}" -gt 0 ]]; then + export LD_LIBRARY_PATH="$(IFS=:; echo "${LIB_PATHS[*]}"):${LD_LIBRARY_PATH:-}" + echo "Using LD_LIBRARY_PATH=$LD_LIBRARY_PATH" + fi + + FLUTTER_GTK_LIB="" + for candidate in "${LIB_PATHS[@]}"; do + if [[ -f "$candidate/libflutter_linux_gtk.so" ]]; then + FLUTTER_GTK_LIB="$candidate/libflutter_linux_gtk.so" + break + fi + done + + mapfile -t ELF_FILES < <( + dpkg -L copypaste | while read -r path; do + [[ -f "$path" ]] || continue + if file -b "$path" | grep -Eq "ELF .* (executable|shared object)"; then + echo "$path" + fi + done + ) + + test "${#ELF_FILES[@]}" -gt 0 + MAX_IGNORED_FLUTTER_GTK=30 + ignored_flutter_gtk=0 + missing=0 + for elf in "${ELF_FILES[@]}"; do + echo "Checking ELF deps: $elf" + ldd_output=$(ldd "$elf" 2>&1 || true) + echo "$ldd_output" >> /tmp/ldd.out + + missing_lines=$(echo "$ldd_output" | grep "not found" || true) + if [[ -n "$missing_lines" ]]; then + filtered_missing="$missing_lines" + if [[ -n "$FLUTTER_GTK_LIB" ]]; then + ignored_in_block=$(echo "$filtered_missing" | grep -Ec "libflutter_linux_gtk\.so[[:space:]]*=>[[:space:]]*not found" || true) + ignored_flutter_gtk=$((ignored_flutter_gtk + ignored_in_block)) + filtered_missing=$(echo "$filtered_missing" | grep -vE "libflutter_linux_gtk\.so[[:space:]]*=>[[:space:]]*not found" || true) + filtered_missing=$(echo "$filtered_missing" | sed "/^[[:space:]]*$/d" || true) + if [[ -n "$missing_lines" && -z "$filtered_missing" ]]; then + echo "Ignoring plugin-local unresolved libflutter_linux_gtk.so (resolved via bundled runtime at $FLUTTER_GTK_LIB)" + fi + fi + + if [[ -n "$filtered_missing" ]]; then + echo "Missing libraries in: $elf" + echo "$filtered_missing" + missing=1 + fi + fi + done + + echo "[smoke] deb summary: checked=${#ELF_FILES[@]} ignored_flutter_linux_gtk=$ignored_flutter_gtk missing=$missing" + if [[ "$ignored_flutter_gtk" -gt "$MAX_IGNORED_FLUTTER_GTK" ]]; then + echo "[smoke] deb guardrail failed: too many ignored libflutter_linux_gtk.so entries ($ignored_flutter_gtk > $MAX_IGNORED_FLUTTER_GTK)" + exit 1 + fi + + if [[ "$missing" -ne 0 ]]; then + echo "[smoke] deb package has unresolved shared libraries" + exit 1 + fi + + echo "[smoke] deb package passed" + ' +elif [[ "$package_type" == "rpm" ]]; then + echo "[smoke] Running rpm smoke in fedora:40" + docker run --rm -v "$host_package:/tmp/copypaste.rpm:ro" fedora:40 bash -lc ' + set -euo pipefail + dnf -y install file /tmp/copypaste.rpm + + BIN=$(command -v copypaste || true) + if [[ -z "$BIN" ]]; then + BIN=$(find /usr -type f -name copypaste 2>/dev/null | head -n 1) + fi + test -n "$BIN" + + BIN_REAL=$(readlink -f "$BIN" || echo "$BIN") + APP_DIR=$(dirname "$BIN_REAL") + LIB_PATHS=() + for candidate in "$APP_DIR/lib" "/usr/share/copypaste/lib"; do + if [[ -d "$candidate" ]]; then + LIB_PATHS+=("$candidate") + fi + done + if [[ "${#LIB_PATHS[@]}" -gt 0 ]]; then + export LD_LIBRARY_PATH="$(IFS=:; echo "${LIB_PATHS[*]}"):${LD_LIBRARY_PATH:-}" + echo "Using LD_LIBRARY_PATH=$LD_LIBRARY_PATH" + fi + + FLUTTER_GTK_LIB="" + for candidate in "${LIB_PATHS[@]}"; do + if [[ -f "$candidate/libflutter_linux_gtk.so" ]]; then + FLUTTER_GTK_LIB="$candidate/libflutter_linux_gtk.so" + break + fi + done + + mapfile -t ELF_FILES < <( + rpm -ql copypaste | while read -r path; do + [[ -f "$path" ]] || continue + if file -b "$path" | grep -Eq "ELF .* (executable|shared object)"; then + echo "$path" + fi + done + ) + + test "${#ELF_FILES[@]}" -gt 0 + MAX_IGNORED_FLUTTER_GTK=30 + ignored_flutter_gtk=0 + missing=0 + for elf in "${ELF_FILES[@]}"; do + echo "Checking ELF deps: $elf" + ldd_output=$(ldd "$elf" 2>&1 || true) + echo "$ldd_output" >> /tmp/ldd.out + + missing_lines=$(echo "$ldd_output" | grep "not found" || true) + if [[ -n "$missing_lines" ]]; then + filtered_missing="$missing_lines" + if [[ -n "$FLUTTER_GTK_LIB" ]]; then + ignored_in_block=$(echo "$filtered_missing" | grep -Ec "libflutter_linux_gtk\.so[[:space:]]*=>[[:space:]]*not found" || true) + ignored_flutter_gtk=$((ignored_flutter_gtk + ignored_in_block)) + filtered_missing=$(echo "$filtered_missing" | grep -vE "libflutter_linux_gtk\.so[[:space:]]*=>[[:space:]]*not found" || true) + filtered_missing=$(echo "$filtered_missing" | sed "/^[[:space:]]*$/d" || true) + if [[ -n "$missing_lines" && -z "$filtered_missing" ]]; then + echo "Ignoring plugin-local unresolved libflutter_linux_gtk.so (resolved via bundled runtime at $FLUTTER_GTK_LIB)" + fi + fi + + if [[ -n "$filtered_missing" ]]; then + echo "Missing libraries in: $elf" + echo "$filtered_missing" + missing=1 + fi + fi + done + + echo "[smoke] rpm summary: checked=${#ELF_FILES[@]} ignored_flutter_linux_gtk=$ignored_flutter_gtk missing=$missing" + if [[ "$ignored_flutter_gtk" -gt "$MAX_IGNORED_FLUTTER_GTK" ]]; then + echo "[smoke] rpm guardrail failed: too many ignored libflutter_linux_gtk.so entries ($ignored_flutter_gtk > $MAX_IGNORED_FLUTTER_GTK)" + exit 1 + fi + + if [[ "$missing" -ne 0 ]]; then + echo "[smoke] rpm package has unresolved shared libraries" + exit 1 + fi + + echo "[smoke] rpm package passed" + ' +else + usage + exit 1 +fi