Modular Refactor, UI Enhancements, and Config Persistence #37
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Build and Release | |
| on: | |
| push: | |
| branches: | |
| - "main" | |
| - "dev" | |
| paths-ignore: | |
| - '*.md' | |
| - '**/*.md' | |
| - 'images/**' | |
| - 'readme.md' | |
| - 'README.md' | |
| - 'ALPACA_README.md' | |
| pull_request: | |
| types: [opened, synchronize, reopened] | |
| branches: [ main, dev ] | |
| paths-ignore: | |
| - '*.md' | |
| - '**/*.md' | |
| - 'images/**' | |
| - 'readme.md' | |
| - 'README.md' | |
| - 'ALPACA_README.md' | |
| jobs: | |
| build: | |
| runs-on: self-hosted | |
| if: github.event_name != 'pull_request' | |
| outputs: | |
| should_release: ${{ steps.check_release.outputs.should_release }} | |
| tag: ${{ steps.version.outputs.tag }} | |
| branch: ${{ steps.version.outputs.branch }} | |
| release_type: ${{ steps.version.outputs.release_type }} | |
| docker_tag: ${{ steps.docker_tags.outputs.tag }} | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Set up QEMU | |
| uses: docker/setup-qemu-action@v3 | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v3 | |
| - name: Login to Docker Hub | |
| uses: docker/login-action@v3 | |
| with: | |
| username: ${{ vars.DOCKERHUB_USERNAME }} | |
| password: ${{ secrets.DOCKERHUB_TOKEN }} | |
| - name: Cache pip packages | |
| # Skip cache for self-hosted runners (files persist locally) | |
| if: ${{ runner.name != 'git01' }} | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| ~/.cache/pip | |
| /root/.cache/pip | |
| key: ${{ runner.os }}-pip-${{ hashFiles('requirements*.txt') }} | |
| restore-keys: | | |
| ${{ runner.os }}-pip- | |
| - name: Determine Docker tags and cache | |
| id: docker_tags | |
| run: | | |
| BRANCH_NAME="${GITHUB_REF#refs/heads/}" | |
| if [ "$BRANCH_NAME" = "main" ]; then | |
| echo "tag=latest" >> $GITHUB_OUTPUT | |
| echo "cache_key=${{ runner.os }}-buildx-main-${{ github.sha }}" >> $GITHUB_OUTPUT | |
| echo "cache_restore=${{ runner.os }}-buildx-main-" >> $GITHUB_OUTPUT | |
| else | |
| echo "tag=dev" >> $GITHUB_OUTPUT | |
| echo "cache_key=${{ runner.os }}-buildx-dev-${{ github.sha }}" >> $GITHUB_OUTPUT | |
| echo "cache_restore=${{ runner.os }}-buildx-dev-" >> $GITHUB_OUTPUT | |
| fi | |
| - name: Cache Docker layers | |
| # Skip GitHub Actions cache for self-hosted runners (files persist locally) | |
| if: ${{ runner.name != 'git01' }} | |
| uses: actions/cache@v4 | |
| with: | |
| path: /tmp/.buildx-cache | |
| key: ${{ steps.docker_tags.outputs.cache_key }} | |
| restore-keys: | | |
| ${{ steps.docker_tags.outputs.cache_restore }} | |
| ${{ runner.os }}-buildx- | |
| - name: Determine if release should be created | |
| id: check_release | |
| run: | | |
| # Create release on push to main or dev branch | |
| if [ "${{ github.event_name }}" = "push" ]; then | |
| echo "should_release=true" >> $GITHUB_OUTPUT | |
| BRANCH_NAME="${GITHUB_REF#refs/heads/}" | |
| if [ "$BRANCH_NAME" = "main" ]; then | |
| echo "📦 Release will be created (push to main)" | |
| else | |
| echo "📦 Test release will be created (push to $BRANCH_NAME)" | |
| fi | |
| else | |
| echo "should_release=false" >> $GITHUB_OUTPUT | |
| echo "🔨 Build only (no release)" | |
| fi | |
| - name: Get version tag for release | |
| id: version | |
| if: steps.check_release.outputs.should_release == 'true' | |
| run: | | |
| # Fetch all tags to ensure we have complete tag history | |
| git fetch --tags --force | |
| # Determine branch-specific tag prefix | |
| BRANCH_NAME="${GITHUB_REF#refs/heads/}" | |
| echo "Branch: $BRANCH_NAME" | |
| if [ "$BRANCH_NAME" = "main" ]; then | |
| TAG_PREFIX="v" | |
| TAG_PATTERN="v[0-9]*.[0-9]*" | |
| RELEASE_TYPE="stable" | |
| else | |
| TAG_PREFIX="v-${BRANCH_NAME}-" | |
| TAG_PATTERN="v-${BRANCH_NAME}-[0-9]*.[0-9]*" | |
| RELEASE_TYPE="test" | |
| fi | |
| # Get all matching tags and find the highest version | |
| echo "Looking for tags matching pattern: ${TAG_PATTERN}" | |
| LATEST_TAG=$(git tag -l "${TAG_PATTERN}" | sort -V | tail -n1) | |
| if [ -z "$LATEST_TAG" ]; then | |
| # No existing tags, start at 0.1 | |
| MAJOR=0 | |
| MINOR=0 | |
| echo "No existing tags found, starting at ${TAG_PREFIX}0.1" | |
| else | |
| echo "Latest tag: $LATEST_TAG" | |
| # Extract version numbers (remove prefix first) | |
| VERSION="${LATEST_TAG#${TAG_PREFIX}}" | |
| IFS='.' read -ra PARTS <<< "$VERSION" | |
| MAJOR=${PARTS[0]:-0} | |
| MINOR=${PARTS[1]:-0} | |
| echo "Current version: $MAJOR.$MINOR" | |
| fi | |
| # Increment minor version by 1 (0.1 increments) | |
| MINOR=$((MINOR + 1)) | |
| NEW_TAG="${TAG_PREFIX}${MAJOR}.${MINOR}" | |
| # Ensure the new tag doesn't already exist (keep incrementing if it does) | |
| ATTEMPTS=0 | |
| while git rev-parse "$NEW_TAG" >/dev/null 2>&1; do | |
| echo "⚠️ Tag $NEW_TAG already exists, incrementing..." | |
| MINOR=$((MINOR + 1)) | |
| NEW_TAG="${TAG_PREFIX}${MAJOR}.${MINOR}" | |
| ATTEMPTS=$((ATTEMPTS + 1)) | |
| if [ $ATTEMPTS -gt 100 ]; then | |
| echo "❌ Error: Too many version increment attempts" | |
| exit 1 | |
| fi | |
| done | |
| echo "✅ New tag: $NEW_TAG" | |
| echo "tag=$NEW_TAG" >> $GITHUB_OUTPUT | |
| echo "release_type=$RELEASE_TYPE" >> $GITHUB_OUTPUT | |
| echo "branch=$BRANCH_NAME" >> $GITHUB_OUTPUT | |
| - name: Build and push | |
| uses: docker/build-push-action@v5 | |
| with: | |
| context: . | |
| push: true | |
| tags: ${{ vars.DOCKERHUB_USERNAME }}/simpleclouddetect:${{ steps.docker_tags.outputs.tag }} | |
| cache-from: type=local,src=/tmp/.buildx-cache | |
| # CHANGED: mode=min speeds up export by only caching final layers, avoiding massive I/O | |
| cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=min | |
| platforms: linux/amd64,linux/arm64 | |
| - name: Move cache | |
| if: always() | |
| run: | | |
| rm -rf /tmp/.buildx-cache | |
| mv /tmp/.buildx-cache-new /tmp/.buildx-cache | |
| - name: Docker Hub Description | |
| if: github.ref == 'refs/heads/main' | |
| uses: peter-evans/dockerhub-description@v4 | |
| with: | |
| username: ${{ vars.DOCKERHUB_USERNAME }} | |
| password: ${{ secrets.DOCKERHUB_TOKEN }} | |
| repository: ${{ vars.DOCKERHUB_USERNAME }}/simpleclouddetect | |
| short-description: "ML-based cloud detection for AllSky cameras with MQTT and ASCOM Alpaca" | |
| readme-filepath: ./readme.md | |
| - name: Get image size | |
| if: github.ref == 'refs/heads/dev' | |
| run: | | |
| docker pull ${{ vars.DOCKERHUB_USERNAME }}/simpleclouddetect:${{ steps.docker_tags.outputs.tag }} | |
| IMAGE_SIZE=$(docker images --format "{{.Size}}" ${{ vars.DOCKERHUB_USERNAME }}/simpleclouddetect:${{ steps.docker_tags.outputs.tag }} | head -n1) | |
| echo "Docker image size: $IMAGE_SIZE" | |
| release: | |
| needs: build | |
| if: needs.build.outputs.should_release == 'true' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Generate commit history | |
| id: changelog | |
| run: | | |
| # Get the previous tag for this branch (excluding the tag we're about to create) | |
| BRANCH_NAME="${{ needs.build.outputs.branch }}" | |
| if [ "$BRANCH_NAME" = "main" ]; then | |
| TAG_PREFIX="v" | |
| TAG_PATTERN="${TAG_PREFIX}*" | |
| else | |
| TAG_PREFIX="v-${BRANCH_NAME}-" | |
| TAG_PATTERN="${TAG_PREFIX}*" | |
| fi | |
| NEW_TAG="${{ needs.build.outputs.tag }}" | |
| # Get all matching tags, exclude the new tag if it exists, and get the latest | |
| # For main branch, also exclude dev tags (v-dev-*) to prevent incorrect comparisons | |
| if [ "$BRANCH_NAME" = "main" ]; then | |
| PREVIOUS_TAG=$(git tag -l "${TAG_PATTERN}" | grep -v "^${NEW_TAG}$" | grep -v "^v-.*-" | sort -V | tail -n1) | |
| else | |
| PREVIOUS_TAG=$(git tag -l "${TAG_PATTERN}" | grep -v "^${NEW_TAG}$" | sort -V | tail -n1) | |
| fi | |
| if [ -z "$PREVIOUS_TAG" ]; then | |
| echo "No previous tag found, showing last 20 commits" | |
| COMMITS=$(git log -20 --pretty=format:"- %s (%h)" --no-merges) | |
| else | |
| echo "Generating changelog from $PREVIOUS_TAG to HEAD" | |
| COMMITS=$(git log ${PREVIOUS_TAG}..HEAD --pretty=format:"- %s (%h)" --no-merges) | |
| # If no commits found, it means we're on the same commit | |
| if [ -z "$COMMITS" ]; then | |
| echo "No new commits since $PREVIOUS_TAG" | |
| COMMITS="- No changes since previous release" | |
| fi | |
| fi | |
| # Save to output using heredoc to handle multiline | |
| echo "changelog<<EOF" >> $GITHUB_OUTPUT | |
| echo "$COMMITS" >> $GITHUB_OUTPUT | |
| echo "EOF" >> $GITHUB_OUTPUT | |
| - name: Create and push tag | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| # Check if tag exists (should not happen due to version increment logic) | |
| if git rev-parse "${{ needs.build.outputs.tag }}" >/dev/null 2>&1; then | |
| echo "❌ Error: Tag ${{ needs.build.outputs.tag }} already exists" | |
| echo "This should not happen - version increment logic failed" | |
| exit 1 | |
| else | |
| git tag ${{ needs.build.outputs.tag }} | |
| git push origin ${{ needs.build.outputs.tag }} | |
| echo "✅ Created and pushed tag ${{ needs.build.outputs.tag }}" | |
| fi | |
| - name: Create Release | |
| uses: softprops/action-gh-release@v2 | |
| with: | |
| tag_name: ${{ needs.build.outputs.tag }} | |
| name: ${{ needs.build.outputs.release_type == 'test' && format('🧪 Test Release {0} ({1} branch)', needs.build.outputs.tag, needs.build.outputs.branch) || format('Release {0}', needs.build.outputs.tag) }} | |
| prerelease: ${{ needs.build.outputs.release_type == 'test' }} | |
| files: | | |
| README.md | |
| ALPACA_README.md | |
| body: | | |
| ${{ needs.build.outputs.release_type == 'test' && '## 🧪 Test Release' || '## 📦 SimpleCloudDetect' }} | |
| ${{ needs.build.outputs.tag }} | |
| ${{ needs.build.outputs.release_type == 'test' && format('⚠️ **This is a pre-release test build from the `{0}` branch.**', needs.build.outputs.branch) || '' }} | |
| ${{ needs.build.outputs.release_type == 'test' && '**Use for testing purposes only. For stable releases, use builds from the main branch.**' || '' }} | |
| ${{ needs.build.outputs.release_type == 'test' && '' || 'ML-based cloud detection for AllSky cameras with MQTT and ASCOM Alpaca SafetyMonitor support.' }} | |
| ### Documentation | |
| - [README.md](README.md) - Complete setup and features | |
| - [ALPACA_README.md](ALPACA_README.md) - ASCOM Alpaca integration guide | |
| ### Changes in This Release | |
| ${{ steps.changelog.outputs.changelog || '_No commits found_' }} | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| pr-commit-summary: | |
| name: Generate PR Summary | |
| runs-on: ubuntu-latest | |
| if: github.event_name == 'pull_request' | |
| permissions: | |
| pull-requests: write | |
| contents: read | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Collect commit information | |
| id: collect | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| echo "Fetching commits for PR #${{ github.event.pull_request.number }}" | |
| # Get detailed commit information | |
| gh pr view ${{ github.event.pull_request.number }} --json commits \ | |
| --jq '.commits[] | "**Commit \(.oid[0:7])** by \(.authors[0].name // "Unknown")\nMessage: \(.messageHeadline)\n\(.messageBody // "")\n---"' \ | |
| > commits.txt | |
| # Get changed files with stats | |
| gh pr view ${{ github.event.pull_request.number }} --json files \ | |
| --jq '.files[] | "- `\(.path)` (+\(.additions)/-\(.deletions))"' \ | |
| > files.txt | |
| # Get changed files excluding GitHub workflows (for AI summary) | |
| gh pr view ${{ github.event.pull_request.number }} --json files \ | |
| --jq '.files[] | select(.path | startswith(".github/workflows/") | not) | "- `\(.path)` (+\(.additions)/-\(.deletions))"' \ | |
| > files_for_ai.txt | |
| # Check if changes are only in GitHub Actions workflows | |
| NON_WORKFLOW_CHANGES=$(gh pr view ${{ github.event.pull_request.number }} --json files \ | |
| --jq '[.files[] | select(.path | startswith(".github/workflows/") | not)] | length') | |
| if [ "$NON_WORKFLOW_CHANGES" -eq 0 ]; then | |
| echo "skip_summary=true" >> $GITHUB_OUTPUT | |
| echo "⏭️ Skipping AI summary - changes are only in GitHub Actions workflows" | |
| else | |
| echo "skip_summary=false" >> $GITHUB_OUTPUT | |
| echo "✅ Will generate AI summary for code changes (excluding workflow files)" | |
| fi | |
| # Get commit count | |
| COMMIT_COUNT=$(gh pr view ${{ github.event.pull_request.number }} --json commits --jq '.commits | length') | |
| echo "commit_count=$COMMIT_COUNT" >> $GITHUB_OUTPUT | |
| echo "Collected $COMMIT_COUNT commits" | |
| - name: Generate summary with Google Gemini | |
| id: summarize | |
| if: steps.collect.outputs.skip_summary == 'false' | |
| env: | |
| GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} | |
| run: | | |
| set +e # Don't exit immediately on error | |
| # Read collected data | |
| COMMITS=$(cat commits.txt) | |
| FILES=$(cat files_for_ai.txt) | |
| # Escape special characters for JSON | |
| COMMITS_ESCAPED=$(echo "$COMMITS" | jq -Rs .) | |
| FILES_ESCAPED=$(echo "$FILES" | jq -Rs .) | |
| PR_TITLE=$(echo "${{ github.event.pull_request.title }}" | jq -Rs .) | |
| PR_BODY=$(echo "${{ github.event.pull_request.body }}" | jq -Rs .) | |
| # Build the prompt | |
| read -r -d '' PROMPT << 'EOF' | |
| You are a code review assistant. Analyze the following pull request and provide a summary. | |
| **IMPORTANT:** Focus ONLY on the actual code changes listed below. Do NOT mention or summarize any GitHub Actions workflow files (.github/workflows/) as those have been filtered out. | |
| ## Pull Request Details | |
| **Title:** %PR_TITLE% | |
| **Description:** %PR_BODY% | |
| ## Commits in this PR | |
| %COMMITS% | |
| ## Files Changed (excluding workflow files) | |
| %FILES% | |
| ## Your Task | |
| Generate a response in the following format: | |
| First, provide a concise PR title (50-70 characters) summarizing the main purpose. Do NOT include emojis. Put this on the very first line of your response with no prefix or label. | |
| Then add a blank line and provide: | |
| ### 📋 Overview | |
| Write 2-3 sentences describing what this PR accomplishes overall. | |
| ### 🔄 Changes by Category | |
| Group the changes into relevant categories such as: | |
| - **Features**: New functionality added | |
| - **Bug Fixes**: Issues resolved | |
| - **Refactoring**: Code improvements without behavior changes | |
| - **Documentation**: README, comments, docs updates | |
| - **Tests**: Test additions or modifications | |
| - **Dependencies**: Package or dependency changes | |
| - **Configuration**: Config file changes | |
| Only include categories that apply. Use bullet points for each change. | |
| EOF | |
| # Replace placeholders (remove jq quotes) | |
| PROMPT="${PROMPT//%PR_TITLE%/$(echo $PR_TITLE | jq -r .)}" | |
| PROMPT="${PROMPT//%PR_BODY%/$(echo $PR_BODY | jq -r .)}" | |
| PROMPT="${PROMPT//%COMMITS%/$(echo $COMMITS_ESCAPED | jq -r .)}" | |
| PROMPT="${PROMPT//%FILES%/$(echo $FILES_ESCAPED | jq -r .)}" | |
| # Make API request to Google Gemini | |
| RESPONSE=$(curl -s -X POST "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent" \ | |
| -H "Content-Type: application/json" \ | |
| -H "X-goog-api-key: $GOOGLE_API_KEY" \ | |
| -d "$(jq -n \ | |
| --arg prompt "$PROMPT" \ | |
| '{ | |
| contents: [ | |
| { | |
| parts: [ | |
| { | |
| text: $prompt | |
| } | |
| ] | |
| } | |
| ], | |
| generationConfig: { | |
| temperature: 0.3, | |
| maxOutputTokens: 8192 | |
| } | |
| }')") | |
| # Check for errors | |
| if [ -z "$RESPONSE" ]; then | |
| echo "❌ Error: Empty response from API" | |
| exit 1 | |
| fi | |
| if echo "$RESPONSE" | jq -e '.error' > /dev/null 2>&1; then | |
| echo "❌ Error from API:" | |
| echo "$RESPONSE" | jq '.' | |
| exit 1 | |
| fi | |
| if ! echo "$RESPONSE" | jq -e '.candidates[0].content.parts[0].text' > /dev/null 2>&1; then | |
| echo "❌ Invalid response structure" | |
| exit 1 | |
| fi | |
| # Extract summary | |
| echo "$RESPONSE" | jq -r '.candidates[0].content.parts[0].text' > summary.md | |
| - name: Update PR title and body with summary | |
| if: steps.collect.outputs.skip_summary == 'false' | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| // Read the generated summary | |
| const summaryContent = fs.readFileSync('summary.md', 'utf8'); | |
| const commitCount = '${{ steps.collect.outputs.commit_count }}'; | |
| const timestamp = new Date().toISOString(); | |
| // Extract title and body from the summary | |
| // Title is on the first line, then blank line, then the rest | |
| const lines = summaryContent.split('\n'); | |
| let generatedTitle = ''; | |
| let summaryBody = []; | |
| // First non-empty line is the title | |
| let foundTitle = false; | |
| for (let i = 0; i < lines.length; i++) { | |
| const line = lines[i]; | |
| if (!foundTitle && line.trim() && !line.startsWith('#')) { | |
| generatedTitle = line.trim(); | |
| foundTitle = true; | |
| continue; | |
| } | |
| // After title, start collecting body from first header | |
| if (foundTitle && line.startsWith('#')) { | |
| summaryBody = lines.slice(i); | |
| break; | |
| } | |
| } | |
| const summary = summaryBody.join('\n').trim(); | |
| // Create enhanced PR body with AI summary | |
| const marker = ''; | |
| const summarySection = `${marker}\n\n${summary}\n\n---\n<sub>📊 Analyzed **${commitCount}** commit(s) | 🕐 Updated: ${timestamp} | Generated by GitHub Actions</sub>\n\n---\n\n`; | |
| // Get current PR details | |
| const { data: pr } = await github.rest.pulls.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: context.issue.number, | |
| }); | |
| // Replace body completely with new summary (remove old summary if exists) | |
| let newBody; | |
| if (pr.body && pr.body.includes(marker)) { | |
| // Find and remove the old summary section completely | |
| const markerIndex = pr.body.indexOf(marker); | |
| const endPattern = /---\n\n/g; | |
| endPattern.lastIndex = markerIndex; | |
| const match1 = endPattern.exec(pr.body); | |
| if (match1) { | |
| const secondMatch = endPattern.exec(pr.body); | |
| if (secondMatch) { | |
| // Remove old summary and add new one | |
| const afterSummary = pr.body.substring(secondMatch.index + 5); | |
| newBody = summarySection + afterSummary; | |
| } else { | |
| newBody = summarySection; | |
| } | |
| } else { | |
| newBody = summarySection; | |
| } | |
| } else { | |
| // First time - just use the summary | |
| newBody = summarySection + (pr.body || ''); | |
| } | |
| // Use generated title if available, otherwise keep original | |
| const finalTitle = generatedTitle || pr.title; | |
| console.log(`Extracted title: "${generatedTitle}"`); | |
| console.log(`Final title: "${finalTitle}"`); | |
| // Update the PR | |
| await github.rest.pulls.update({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: context.issue.number, | |
| title: finalTitle, | |
| body: newBody | |
| }); | |
| console.log(`✅ Updated PR #${context.issue.number}`); |