name: "Releasing PR" on: pull_request: types: [closed] paths-ignore: - 'docs/**/*' # Cancel in‑flight runs for the same PR when a new push arrives. concurrency: group: pr-${{ github.workflow }}-${{ github.event.pull_request.number }} cancel-in-progress: true jobs: finalize: name: Finalize Release runs-on: [self-hosted] permissions: contents: write if: | github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release') steps: - name: Generate GitHub App token id: app-token uses: actions/create-github-app-token@v1 with: app-id: ${{ secrets.COZYSTACK_CI_APP_ID }} private-key: ${{ secrets.COZYSTACK_CI_PRIVATE_KEY }} owner: cozystack # Extract tag from branch name (branch = release-X.Y.Z*) - name: Extract tag from branch name id: get_tag uses: actions/github-script@v7 with: script: | const branch = context.payload.pull_request.head.ref; const m = branch.match(/^release-(\d+\.\d+\.\d+(?:[-\w\.]+)?)$/); if (!m) { core.setFailed(`Branch '${branch}' does not match 'release-X.Y.Z[-suffix]'`); return; } const tag = `v${m[1]}`; core.setOutput('tag', tag); console.log(`βœ… Tag to publish: ${tag}`); # Checkout repo & create / push annotated tag - name: Checkout repo uses: actions/checkout@v4 with: fetch-depth: 0 - name: Create tag on merge commit env: APP_TOKEN: ${{ steps.app-token.outputs.token }} run: | git config user.name "cozystack-ci[bot]" git config user.email "274107086+cozystack-ci[bot]@users.noreply.github.com" git remote set-url origin https://x-access-token:${APP_TOKEN}@github.com/${GITHUB_REPOSITORY} git tag -f ${{ steps.get_tag.outputs.tag }} ${{ github.sha }} git push -f origin ${{ steps.get_tag.outputs.tag }} # Ensure maintenance branch release-X.Y - name: Ensure maintenance branch release-X.Y uses: actions/github-script@v7 with: github-token: ${{ steps.app-token.outputs.token }} script: | const tag = '${{ steps.get_tag.outputs.tag }}'; // e.g. v0.1.3 or v0.1.3-rc3 const match = tag.match(/^v(\d+)\.(\d+)\.\d+(?:[-\w\.]+)?$/); if (!match) { core.setFailed(`❌ tag '${tag}' must match 'vX.Y.Z' or 'vX.Y.Z-suffix'`); return; } const line = `${match[1]}.${match[2]}`; const branch = `release-${line}`; // Get main branch commit for the tag const ref = await github.rest.git.getRef({ owner: context.repo.owner, repo: context.repo.repo, ref: `tags/${tag}` }); const commitSha = ref.data.object.sha; try { await github.rest.repos.getBranch({ owner: context.repo.owner, repo: context.repo.repo, branch }); await github.rest.git.updateRef({ owner: context.repo.owner, repo: context.repo.repo, ref: `heads/${branch}`, sha: commitSha, force: true }); console.log(`πŸ” Force-updated '${branch}' to ${commitSha}`); } catch (err) { if (err.status === 404) { await github.rest.git.createRef({ owner: context.repo.owner, repo: context.repo.repo, ref: `refs/heads/${branch}`, sha: commitSha }); console.log(`βœ… Created branch '${branch}' at ${commitSha}`); } else { console.error('Unexpected error --', err); core.setFailed(`Unexpected error creating/updating branch: ${err.message}`); throw err; } } # Publish draft release and ensure correct latest flag - name: Publish draft release uses: actions/github-script@v7 with: github-token: ${{ steps.app-token.outputs.token }} script: | const tag = '${{ steps.get_tag.outputs.tag }}'; const m = tag.match(/^v(\d+\.\d+\.\d+)(-(?:alpha|beta|rc)\.\d+)?$/); if (!m) { core.setFailed(`❌ tag '${tag}' must match 'vX.Y.Z' or 'vX.Y.Z-(alpha|beta|rc).N'`); return; } const isRc = Boolean(m[2]); // Parse semver string to comparable numbers function parseSemver(v) { const match = v.replace(/^v/, '').match(/^(\d+)\.(\d+)\.(\d+)/); if (!match) return null; return { major: parseInt(match[1]), minor: parseInt(match[2]), patch: parseInt(match[3]) }; } // Compare two semver objects function compareSemver(a, b) { if (a.major !== b.major) return a.major - b.major; if (a.minor !== b.minor) return a.minor - b.minor; return a.patch - b.patch; } const currentSemver = parseSemver(tag); // Get all releases const releases = await github.rest.repos.listReleases({ owner: context.repo.owner, repo: context.repo.repo, per_page: 100 }); // Find draft release to publish const draft = releases.data.find(r => r.tag_name === tag && r.draft); if (!draft) throw new Error(`Draft release for ${tag} not found`); // Find max semver among published releases (excluding current draft) const publishedReleases = releases.data .filter(r => !r.draft && !r.prerelease) .filter(r => /^v\d+\.\d+\.\d+$/.test(r.tag_name)) .map(r => ({ id: r.id, tag: r.tag_name, semver: parseSemver(r.tag_name) })) .filter(r => r.semver !== null); let maxRelease = null; for (const rel of publishedReleases) { if (!maxRelease || compareSemver(rel.semver, maxRelease.semver) > 0) { maxRelease = rel; } } // Determine if this release should be latest const isOutdated = maxRelease && compareSemver(currentSemver, maxRelease.semver) < 0; const makeLatest = (isRc || isOutdated) ? 'false' : 'true'; if (isRc) { console.log(`🏷️ ${tag} is a prerelease, make_latest: false`); } else if (isOutdated) { console.log(`🏷️ ${tag} < ${maxRelease.tag} (max semver), make_latest: false`); } else { console.log(`🏷️ ${tag} is the highest version, make_latest: true`); } // Publish the release await github.rest.repos.updateRelease({ owner: context.repo.owner, repo: context.repo.repo, release_id: draft.id, draft: false, prerelease: isRc, make_latest: makeLatest }); console.log(`πŸš€ Published release ${tag}`); // If this is a backport/outdated release, ensure the correct release is marked as latest if (isOutdated && maxRelease) { console.log(`πŸ”§ Ensuring ${maxRelease.tag} remains the latest release...`); await github.rest.repos.updateRelease({ owner: context.repo.owner, repo: context.repo.repo, release_id: maxRelease.id, make_latest: 'true' }); console.log(`βœ… Restored ${maxRelease.tag} as latest release`); }