Skip to content

Modular Refactor, UI Enhancements, and Config Persistence #37

Modular Refactor, UI Enhancements, and Config Persistence

Modular Refactor, UI Enhancements, and Config Persistence #37

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}`);