Add force_update flag to product-updates workflow #6
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: Product Updates | |
| on: | |
| push: | |
| branches: [main] | |
| workflow_dispatch: | |
| inputs: | |
| backfill_count: | |
| description: 'Number of historical commits to backfill (0 = disabled)' | |
| required: false | |
| default: '0' | |
| force_update: | |
| description: 'Update existing entries instead of skipping duplicates (true/false)' | |
| required: false | |
| default: 'false' | |
| env: | |
| API_URL: https://privstack.io | |
| jobs: | |
| process-commits: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Process commits | |
| env: | |
| PRIVSTACK_ADMIN_TOKEN: ${{ secrets.PRIVSTACK_ADMIN_TOKEN }} | |
| run: | | |
| set -eu | |
| GREEN='\033[0;32m' | |
| YELLOW='\033[1;33m' | |
| RED='\033[0;31m' | |
| NC='\033[0m' | |
| BACKFILL_COUNT="${{ github.event.inputs.backfill_count || '0' }}" | |
| FORCE_UPDATE="${{ github.event.inputs.force_update || 'false' }}" | |
| # Determine which commits to process | |
| if [ "$BACKFILL_COUNT" -gt 0 ] 2>/dev/null; then | |
| echo -e "${YELLOW}Backfill mode: processing last $BACKFILL_COUNT commits${NC}" | |
| COMMITS=$(git log --reverse --format='%H' -n "$BACKFILL_COUNT") | |
| elif [ "${{ github.event_name }}" = "push" ]; then | |
| COMMITS=$(git log --reverse --format='%H' ${{ github.event.before }}..${{ github.sha }} 2>/dev/null || echo "${{ github.sha }}") | |
| else | |
| echo "No commits to process" | |
| exit 0 | |
| fi | |
| PROCESSED=0 | |
| SKIPPED=0 | |
| ERRORS=0 | |
| for SHA in $COMMITS; do | |
| # Skip merge commits (more than 1 parent) | |
| PARENT_COUNT=$(git cat-file -p "$SHA" | grep -c '^parent ' || true) | |
| if [ "$PARENT_COUNT" -gt 1 ]; then | |
| echo -e "${YELLOW}Skip merge commit: $SHA${NC}" | |
| SKIPPED=$((SKIPPED + 1)) | |
| continue | |
| fi | |
| # Get commit metadata | |
| SUBJECT=$(git log -1 --format='%s' "$SHA") | |
| BODY=$(git log -1 --format='%b' "$SHA") | |
| AUTHOR_DATE=$(git log -1 --format='%aI' "$SHA") | |
| SHORT_SHA=$(echo "$SHA" | cut -c1-8) | |
| # Get changed files | |
| CHANGED_FILES=$(git diff-tree --no-commit-id --name-only -r "$SHA" 2>/dev/null || echo "") | |
| if [ -z "$CHANGED_FILES" ]; then | |
| echo -e "${YELLOW}Skip (no changed files): $SHORT_SHA $SUBJECT${NC}" | |
| SKIPPED=$((SKIPPED + 1)) | |
| continue | |
| fi | |
| # Noise filter — skip if ALL changed files are tests, docs, or CI | |
| NOISE_ONLY=true | |
| while IFS= read -r file; do | |
| case "$file" in | |
| tests/*|*.Tests/*) continue ;; | |
| README.md|CLAUDE.md|patterns.md) continue ;; | |
| .github/*) continue ;; | |
| *) NOISE_ONLY=false; break ;; | |
| esac | |
| done <<< "$CHANGED_FILES" | |
| if [ "$NOISE_ONLY" = true ]; then | |
| echo -e "${YELLOW}Skip (noise only): $SHORT_SHA $SUBJECT${NC}" | |
| SKIPPED=$((SKIPPED + 1)) | |
| continue | |
| fi | |
| # Extract IO-specific tags from changed paths | |
| TAGS="" | |
| while IFS= read -r file; do | |
| case "$file" in | |
| core/*) TAGS="$TAGS,Core" ;; | |
| desktop/PrivStack.Desktop/*) TAGS="$TAGS,Desktop Shell" ;; | |
| desktop/PrivStack.Sdk/*) TAGS="$TAGS,SDK" ;; | |
| desktop/PrivStack.UI.Adaptive/*) TAGS="$TAGS,UI Components" ;; | |
| desktop/PrivStack.Services/*) TAGS="$TAGS,Services" ;; | |
| desktop/PrivStack.Server/*) TAGS="$TAGS,Server" ;; | |
| relay/*) TAGS="$TAGS,Relay" ;; | |
| esac | |
| done <<< "$CHANGED_FILES" | |
| # Dedupe tags | |
| if [ -n "$TAGS" ]; then | |
| TAGS=$(echo "$TAGS" | tr ',' '\n' | sort -u | grep -v '^$' | tr '\n' ',' | sed 's/,$//') | |
| fi | |
| # Fallback | |
| if [ -z "$TAGS" ]; then | |
| TAGS="IO" | |
| fi | |
| # Build JSON tags array | |
| TAGS_JSON=$(echo "$TAGS" | tr ',' '\n' | grep -v '^$' | sed 's/.*/"&"/' | paste -sd ',' | sed 's/^/[/;s/$/]/') | |
| # Version extraction | |
| VERSIONS_JSON="null" | |
| VERSION_ENTRIES="" | |
| # Helper: extract <Version> from a .csproj via git show | |
| extract_csproj_version() { | |
| git show "$1:$2" 2>/dev/null | sed -n 's/.*<Version>\([^<]*\)<\/Version>.*/\1/p' | head -1 | |
| } | |
| # Helper: extract <PrivStackSdkVersion> from Directory.Build.props | |
| extract_sdk_prop_version() { | |
| git show "$1:$2" 2>/dev/null | sed -n 's/.*<PrivStackSdkVersion>\([^<]*\)<\/PrivStackSdkVersion>.*/\1/p' | head -1 | |
| } | |
| # Helper: extract version from Cargo.toml [workspace.package] or [package] section | |
| extract_cargo_version() { | |
| git show "$1:$2" 2>/dev/null | sed -n '/^\[workspace\.package\]/,/^\[/s/^version = "\([^"]*\)".*/\1/p; /^\[package\]/,/^\[/s/^version = "\([^"]*\)".*/\1/p' | head -1 | |
| } | |
| # Helper: compare versions and append to VERSION_ENTRIES | |
| check_version() { | |
| local label="$1" new_ver="$2" old_ver="$3" | |
| if [ -n "$new_ver" ] && [ -n "$old_ver" ] && [ "$new_ver" != "$old_ver" ]; then | |
| VERSION_ENTRIES="$VERSION_ENTRIES,\"$label\":{\"from\":\"$old_ver\",\"to\":\"$new_ver\"}" | |
| fi | |
| } | |
| # SDK version property in Directory.Build.props | |
| PROPS_FILE="desktop/Directory.Build.props" | |
| if echo "$CHANGED_FILES" | grep -q "$PROPS_FILE"; then | |
| check_version "SDK" \ | |
| "$(extract_sdk_prop_version "$SHA" "$PROPS_FILE")" \ | |
| "$(extract_sdk_prop_version "$SHA^" "$PROPS_FILE")" | |
| fi | |
| # All desktop .csproj files (Desktop, Services, UI.Adaptive, Server) | |
| CSPROJ_MAP="desktop/PrivStack.Desktop/PrivStack.Desktop.csproj:Desktop | |
| desktop/PrivStack.Sdk/PrivStack.Sdk.csproj:SDK Package | |
| desktop/PrivStack.Services/PrivStack.Services.csproj:Services | |
| desktop/PrivStack.UI.Adaptive/PrivStack.UI.Adaptive.csproj:UI Components | |
| desktop/PrivStack.Server/PrivStack.Server.csproj:Server" | |
| while IFS=: read -r csproj_path csproj_label; do | |
| csproj_path=$(echo "$csproj_path" | xargs) | |
| csproj_label=$(echo "$csproj_label" | xargs) | |
| if echo "$CHANGED_FILES" | grep -q "$csproj_path"; then | |
| check_version "$csproj_label" \ | |
| "$(extract_csproj_version "$SHA" "$csproj_path")" \ | |
| "$(extract_csproj_version "$SHA^" "$csproj_path")" | |
| fi | |
| done <<< "$CSPROJ_MAP" | |
| # Rust core (workspace Cargo.toml) | |
| CORE_CARGO="core/Cargo.toml" | |
| if echo "$CHANGED_FILES" | grep -q "$CORE_CARGO"; then | |
| check_version "Core" \ | |
| "$(extract_cargo_version "$SHA" "$CORE_CARGO")" \ | |
| "$(extract_cargo_version "$SHA^" "$CORE_CARGO")" | |
| fi | |
| # Relay | |
| RELAY_CARGO="relay/Cargo.toml" | |
| if echo "$CHANGED_FILES" | grep -q "$RELAY_CARGO"; then | |
| check_version "Relay" \ | |
| "$(extract_cargo_version "$SHA" "$RELAY_CARGO")" \ | |
| "$(extract_cargo_version "$SHA^" "$RELAY_CARGO")" | |
| fi | |
| if [ -n "$VERSION_ENTRIES" ]; then | |
| VERSIONS_JSON="{${VERSION_ENTRIES#,}}" | |
| fi | |
| # Determine update type from the highest version bump detected | |
| UPDATE_TYPE="patch" | |
| if [ "$VERSIONS_JSON" != "null" ]; then | |
| # Collect all version pairs and find the highest bump level | |
| for entry in $(echo "$VERSIONS_JSON" | jq -r 'to_entries[] | "\(.value.from)|\(.value.to)"'); do | |
| OLD_VER=$(echo "$entry" | cut -d'|' -f1) | |
| NEW_VER=$(echo "$entry" | cut -d'|' -f2) | |
| OLD_MAJOR=$(echo "$OLD_VER" | cut -d. -f1) | |
| NEW_MAJOR=$(echo "$NEW_VER" | cut -d. -f1) | |
| OLD_MINOR=$(echo "$OLD_VER" | cut -d. -f2) | |
| NEW_MINOR=$(echo "$NEW_VER" | cut -d. -f2) | |
| if [ "$NEW_MAJOR" != "$OLD_MAJOR" ]; then | |
| UPDATE_TYPE="major" | |
| break | |
| elif [ "$NEW_MINOR" != "$OLD_MINOR" ] && [ "$UPDATE_TYPE" != "major" ]; then | |
| UPDATE_TYPE="minor" | |
| fi | |
| done | |
| fi | |
| # Build slug | |
| SLUG_TITLE=$(echo "$SUBJECT" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//') | |
| SLUG="${SHORT_SHA}-${SLUG_TITLE}" | |
| # Build description from commit body | |
| DESCRIPTION="" | |
| if [ -n "$BODY" ]; then | |
| DESCRIPTION="$BODY" | |
| fi | |
| # Build upsert flag | |
| UPSERT_VAL="false" | |
| if [ "$FORCE_UPDATE" = "true" ]; then | |
| UPSERT_VAL="true" | |
| fi | |
| # POST to API | |
| HTTP_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "${API_URL}/api/admin/product-updates" \ | |
| -H "Authorization: Bearer ${PRIVSTACK_ADMIN_TOKEN}" \ | |
| -H "Content-Type: application/json" \ | |
| -d "$(jq -n \ | |
| --arg title "$SUBJECT" \ | |
| --arg slug "$SLUG" \ | |
| --arg description "$DESCRIPTION" \ | |
| --argjson tags "$TAGS_JSON" \ | |
| --argjson versions "$VERSIONS_JSON" \ | |
| --arg update_type "$UPDATE_TYPE" \ | |
| --arg repo "IO" \ | |
| --arg commit_sha "$SHA" \ | |
| --arg commit_date "$AUTHOR_DATE" \ | |
| --argjson upsert "$UPSERT_VAL" \ | |
| '{title: $title, slug: $slug, description: $description, tags: $tags, versions: $versions, update_type: $update_type, repo: $repo, commit_sha: $commit_sha, commit_date: $commit_date, upsert: $upsert}')") | |
| HTTP_CODE=$(echo "$HTTP_RESPONSE" | tail -1) | |
| HTTP_BODY=$(echo "$HTTP_RESPONSE" | sed '$d') | |
| if [ "$HTTP_CODE" = "201" ]; then | |
| echo -e "${GREEN}Created: $SHORT_SHA $SUBJECT${NC}" | |
| PROCESSED=$((PROCESSED + 1)) | |
| elif [ "$HTTP_CODE" = "200" ]; then | |
| echo -e "${GREEN}Updated: $SHORT_SHA $SUBJECT${NC}" | |
| PROCESSED=$((PROCESSED + 1)) | |
| elif [ "$HTTP_CODE" = "409" ]; then | |
| echo -e "${YELLOW}Duplicate (skipped): $SHORT_SHA $SUBJECT${NC}" | |
| SKIPPED=$((SKIPPED + 1)) | |
| else | |
| echo -e "${RED}Error ($HTTP_CODE): $SHORT_SHA $SUBJECT — $HTTP_BODY${NC}" | |
| ERRORS=$((ERRORS + 1)) | |
| fi | |
| # Rate limit for backfill mode | |
| if [ "$BACKFILL_COUNT" -gt 0 ] 2>/dev/null; then | |
| sleep 0.5 | |
| fi | |
| done | |
| echo "" | |
| echo "=========================================" | |
| echo -e "Processed: ${GREEN}$PROCESSED${NC} | Skipped: ${YELLOW}$SKIPPED${NC} | Errors: ${RED}$ERRORS${NC}" | |
| echo "=========================================" | |
| if [ "$ERRORS" -gt 0 ]; then | |
| exit 1 | |
| fi |