name: Auto Patch Release on: schedule: # Run daily at 2:00 AM CET (1:00 UTC in winter, 0:00 UTC in summer) # Using 1:00 UTC to approximate 2:00 AM CET - cron: '0 1 * * *' workflow_dispatch: # Allow manual trigger concurrency: group: auto-release-${{ github.workflow }} cancel-in-progress: false jobs: auto-release: name: Auto Patch Release runs-on: [self-hosted] permissions: contents: write pull-requests: read steps: - name: Checkout code uses: actions/checkout@v4 with: fetch-depth: 0 fetch-tags: true - name: Configure git env: GH_PAT: ${{ secrets.GH_PAT }} run: | git config user.name "cozystack-bot" git config user.email "217169706+cozystack-bot@users.noreply.github.com" git remote set-url origin https://cozystack-bot:${GH_PAT}@github.com/${GITHUB_REPOSITORY} git config --unset-all http.https://github.com/.extraheader || true - name: Process release branches uses: actions/github-script@v7 env: GH_PAT: ${{ secrets.GH_PAT }} with: github-token: ${{ secrets.GH_PAT }} script: | const { execSync } = require('child_process'); // Configure git to use PAT for authentication execSync('git config user.name "cozystack-bot"', { encoding: 'utf8' }); execSync('git config user.email "217169706+cozystack-bot@users.noreply.github.com"', { encoding: 'utf8' }); execSync(`git remote set-url origin https://cozystack-bot:${process.env.GH_PAT}@github.com/${process.env.GITHUB_REPOSITORY}`, { encoding: 'utf8' }); // Remove GITHUB_TOKEN extraheader to ensure PAT is used (needed to trigger other workflows) execSync('git config --unset-all http.https://github.com/.extraheader || true', { encoding: 'utf8' }); // Get all release-X.Y branches const branches = execSync('git branch -r | grep -E "origin/release-[0-9]+\\.[0-9]+$" | sed "s|origin/||" | tr -d " "', { encoding: 'utf8' }) .split('\n') .filter(b => b.trim()) .filter(b => /^release-\d+\.\d+$/.test(b)); console.log(`Found ${branches.length} release branches: ${branches.join(', ')}`); // Get all published releases (not draft) const allReleases = await github.rest.repos.listReleases({ owner: context.repo.owner, repo: context.repo.repo, per_page: 100 }); // Filter to only published releases (not draft) with tags matching vX.Y.Z (no suffixes) const publishedReleases = allReleases.data .filter(r => !r.draft) .filter(r => /^v\d+\.\d+\.\d+$/.test(r.tag_name)); console.log(`Found ${publishedReleases.length} published releases without suffixes`); for (const branch of branches) { console.log(`\n=== Processing branch: ${branch} ===`); // Extract X.Y from branch name (release-X.Y) const match = branch.match(/^release-(\d+\.\d+)$/); if (!match) { console.log(` ⚠️ Branch ${branch} doesn't match pattern, skipping`); continue; } const [major, minor] = match[1].split('.'); const versionPrefix = `v${major}.${minor}.`; console.log(` Looking for releases with prefix: ${versionPrefix}`); // Find the latest published release for this branch (vX.Y.Z without suffixes) const branchReleases = publishedReleases .filter(r => r.tag_name.startsWith(versionPrefix)) .filter(r => /^v\d+\.\d+\.\d+$/.test(r.tag_name)); // Ensure no suffixes if (branchReleases.length === 0) { console.log(` ⚠️ No published releases found for ${branch}, skipping`); continue; } // Sort by version (descending) to get the latest branchReleases.sort((a, b) => { const aVersion = a.tag_name.match(/^v(\d+)\.(\d+)\.(\d+)$/); const bVersion = b.tag_name.match(/^v(\d+)\.(\d+)\.(\d+)$/); if (!aVersion || !bVersion) return 0; const aNum = parseInt(aVersion[1]) * 10000 + parseInt(aVersion[2]) * 100 + parseInt(aVersion[3]); const bNum = parseInt(bVersion[1]) * 10000 + parseInt(bVersion[2]) * 100 + parseInt(bVersion[3]); return bNum - aNum; }); const latestRelease = branchReleases[0]; console.log(` ✅ Latest published release: ${latestRelease.tag_name}`); // Get the commit SHA for this release tag let releaseCommitSha; try { releaseCommitSha = execSync(`git rev-list -n 1 ${latestRelease.tag_name}`, { encoding: 'utf8' }).trim(); console.log(` Release commit SHA: ${releaseCommitSha}`); } catch (error) { console.log(` ⚠️ Could not find commit for tag ${latestRelease.tag_name}, skipping`); continue; } // Checkout the branch execSync(`git fetch origin ${branch}:${branch}`, { encoding: 'utf8' }); execSync(`git checkout ${branch}`, { encoding: 'utf8' }); // Get the latest commit on the branch const latestBranchCommit = execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim(); console.log(` Latest branch commit: ${latestBranchCommit}`); // Check if there are new commits after the release const commitsAfterRelease = execSync( `git rev-list ${releaseCommitSha}..HEAD --oneline`, { encoding: 'utf8' } ).trim(); if (!commitsAfterRelease) { console.log(` ℹ️ No new commits after ${latestRelease.tag_name}, skipping`); continue; } console.log(` ✅ Found new commits after release:`); console.log(commitsAfterRelease); // Calculate next version (Z+1) const versionMatch = latestRelease.tag_name.match(/^v(\d+)\.(\d+)\.(\d+)$/); if (!versionMatch) { console.log(` ❌ Could not parse version from ${latestRelease.tag_name}, skipping`); continue; } const nextPatch = parseInt(versionMatch[3]) + 1; const nextTag = `v${versionMatch[1]}.${versionMatch[2]}.${nextPatch}`; console.log(` 🏷️ Creating new tag: ${nextTag} on commit ${latestBranchCommit}`); // Create and push the tag with base_ref for workflow triggering try { // Delete local tag if exists to force update try { execSync(`git tag -d ${nextTag}`, { encoding: 'utf8' }); } catch (e) { // Tag doesn't exist locally, that's fine } // Delete remote tag if exists try { execSync(`git push origin :refs/tags/${nextTag}`, { encoding: 'utf8' }); } catch (e) { // Tag doesn't exist remotely, that's fine } // Create tag locally execSync(`git tag ${nextTag} ${latestBranchCommit}`, { encoding: 'utf8' }); // Push tag with HEAD reference to preserve base_ref execSync(`git push origin HEAD:refs/tags/${nextTag}`, { encoding: 'utf8' }); console.log(` ✅ Successfully created and pushed tag ${nextTag}`); } catch (error) { console.log(` ❌ Error creating/pushing tag ${nextTag}: ${error.message}`); core.setFailed(`Failed to create tag ${nextTag} for branch ${branch}`); } } console.log(`\n✅ Finished processing all release branches`);