diff --git a/.github/workflows/backport.yaml b/.github/workflows/backport.yaml new file mode 100644 index 00000000..67445b77 --- /dev/null +++ b/.github/workflows/backport.yaml @@ -0,0 +1,49 @@ +name: Automatic Backport + +on: + pull_request_target: + types: [closed] # fires when PR is closed (merged) + +permissions: + contents: write + pull-requests: write + +jobs: + backport: + if: | + github.event.pull_request.merged == true && + contains(github.event.pull_request.labels.*.name, 'backport') + runs-on: [self-hosted] + + steps: + # 1. Decide which maintenance branch should receive the back‑port + - name: Determine target maintenance branch + id: target + uses: actions/github-script@v7 + with: + script: | + let rel; + try { + rel = await github.rest.repos.getLatestRelease({ + owner: context.repo.owner, + repo: context.repo.repo + }); + } catch (e) { + core.setFailed('No existing releases found; cannot determine backport target.'); + return; + } + const [maj, min] = rel.data.tag_name.replace(/^v/, '').split('.'); + const branch = `release-${maj}.${min}`; + core.setOutput('branch', branch); + console.log(`Latest release ${rel.data.tag_name}; backporting to ${branch}`); + # 2. Checkout (required by backport‑action) + - name: Checkout repository + uses: actions/checkout@v4 + + # 3. Create the back‑port pull request + - name: Create back‑port PR + uses: korthout/backport-action@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + label_pattern: '' # don't read labels for targets + target_branches: ${{ steps.target.outputs.branch }} diff --git a/.github/workflows/pull-requests-release.yaml b/.github/workflows/pull-requests-release.yaml index 790c9188..815ccb59 100644 --- a/.github/workflows/pull-requests-release.yaml +++ b/.github/workflows/pull-requests-release.yaml @@ -39,38 +39,82 @@ jobs: runs-on: [self-hosted] permissions: contents: write - + if: | github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release') - + steps: + # 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 match = branch.match(/^release-(\d+\.\d+\.\d+(?:[-\w\.]+)?)$/); - - if (!match) { - core.setFailed(`Branch '${branch}' does not match expected format 'release-X.Y.Z[-suffix]'`); - } else { - const tag = `v${match[1]}`; - core.setOutput('tag', tag); - console.log(`✅ Extracted tag: ${tag}`); + 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 merged commit + + - name: Create tag on merge commit run: | - git tag ${{ steps.get_tag.outputs.tag }} ${{ github.sha }} --force - git push origin ${{ steps.get_tag.outputs.tag }} --force - + git tag -f ${{ steps.get_tag.outputs.tag }} ${{ github.sha }} + git push -f origin ${{ steps.get_tag.outputs.tag }} + + # Get the latest published release + - name: Get the latest published release + id: latest_release + uses: actions/github-script@v7 + with: + script: | + try { + const rel = await github.rest.repos.getLatestRelease({ + owner: context.repo.owner, + repo: context.repo.repo + }); + core.setOutput('tag', rel.data.tag_name); + } catch (_) { + core.setOutput('tag', ''); + } + + # Compare current tag vs latest using semver-utils + - name: Semver compare + id: semver + uses: madhead/semver-utils@v4.3.0 + with: + version: ${{ steps.get_tag.outputs.tag }} + compare-to: ${{ steps.latest_release.outputs.tag }} + + # Derive flags: prerelease? make_latest? + - name: Calculate publish flags + id: flags + uses: actions/github-script@v7 + with: + script: | + const tag = '${{ steps.get_tag.outputs.tag }}'; // v0.31.5-rc1 + const m = tag.match(/^v(\d+\.\d+\.\d+)(-rc\d+)?$/); + if (!m) { + core.setFailed(`❌ tag '${tag}' must match 'vX.Y.Z' or 'vX.Y.Z-rcN'`); + return; + } + const version = m[1] + (m[2] ?? ''); // 0.31.5‑rc1 + const isRc = Boolean(m[2]); + core.setOutput('is_rc', isRc); + const outdated = '${{ steps.semver.outputs.comparison-result }}' === '<'; + core.setOutput('make_latest', isRc || outdated ? 'false' : 'legacy'); + + # Publish draft release with correct flags - name: Publish draft release uses: actions/github-script@v7 with: @@ -78,19 +122,17 @@ jobs: const tag = '${{ steps.get_tag.outputs.tag }}'; const releases = await github.rest.repos.listReleases({ owner: context.repo.owner, - repo: context.repo.repo + repo: context.repo.repo }); - - const release = releases.data.find(r => r.tag_name === tag && r.draft); - if (!release) { - throw new Error(`Draft release with tag ${tag} not found`); - } - + const draft = releases.data.find(r => r.tag_name === tag && r.draft); + if (!draft) throw new Error(`Draft release for ${tag} not found`); await github.rest.repos.updateRelease({ - owner: context.repo.owner, - repo: context.repo.repo, - release_id: release.id, - draft: false + owner: context.repo.owner, + repo: context.repo.repo, + release_id: draft.id, + draft: false, + prerelease: ${{ steps.flags.outputs.is_rc }}, + make_latest: '${{ steps.flags.outputs.make_latest }}' }); - - console.log(`✅ Published release for ${tag}`); + + console.log(`🚀 Published release for ${tag}`); diff --git a/.github/workflows/pull-requests.yaml b/.github/workflows/pull-requests.yaml index 47d19f4a..92226c2a 100644 --- a/.github/workflows/pull-requests.yaml +++ b/.github/workflows/pull-requests.yaml @@ -12,9 +12,20 @@ jobs: contents: read packages: write + # ───────────────────────────────────────────────────────────── + # Run automatically for internal PRs (same repo). + # For external PRs (forks) require the "ok‑to‑test" label. + # Never run when the PR carries the "release" label. + # ───────────────────────────────────────────────────────────── if: | - contains(github.event.pull_request.labels.*.name, 'ok-to-test') && - !contains(github.event.pull_request.labels.*.name, 'release') + !contains(github.event.pull_request.labels.*.name, 'release') && + ( + github.event.pull_request.head.repo.full_name == github.repository || + ( + github.event.pull_request.head.repo.full_name != github.repository && + contains(github.event.pull_request.labels.*.name, 'ok-to-test') + ) + ) steps: - name: Checkout code @@ -30,10 +41,8 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} registry: ghcr.io - - name: make build - run: | - make build + - name: Build + run: make build - - name: make test - run: | - make test + - name: Test + run: make test diff --git a/.github/workflows/tags.yaml b/.github/workflows/tags.yaml index 85319b2f..f50c9b0a 100644 --- a/.github/workflows/tags.yaml +++ b/.github/workflows/tags.yaml @@ -1,10 +1,9 @@ name: Versioned Tag on: - # Trigger on push if it includes a tag like vX.Y.Z push: tags: - - 'v*.*.*' + - 'v*.*.*' # vX.Y.Z or vX.Y.Z-rcN jobs: prepare-release: @@ -16,7 +15,7 @@ jobs: pull-requests: write steps: - # 1) Check if a non-draft release with this tag already exists + # Check if a non-draft release with this tag already exists - name: Check if release already exists id: check_release uses: actions/github-script@v7 @@ -25,57 +24,67 @@ jobs: const tag = context.ref.replace('refs/tags/', ''); const releases = await github.rest.repos.listReleases({ owner: context.repo.owner, - repo: context.repo.repo + repo: context.repo.repo }); - const existing = releases.data.find(r => r.tag_name === tag && !r.draft); - if (existing) { - core.setOutput('skip', 'true'); - } else { - core.setOutput('skip', 'false'); - } + const exists = releases.data.some(r => r.tag_name === tag && !r.draft); + core.setOutput('skip', exists); + console.log(exists ? `Release ${tag} already published` : `No published release ${tag}`); # If a published release already exists, skip the rest of the workflow - name: Skip if release already exists if: steps.check_release.outputs.skip == 'true' run: echo "Release already exists, skipping workflow." - # 2) Determine the base branch from which the tag was pushed + # Parse tag meta‑data (rc?, maintenance line, etc.) + - name: Parse tag + if: steps.check_release.outputs.skip == 'false' + id: tag + uses: actions/github-script@v7 + with: + script: | + const ref = context.ref.replace('refs/tags/', ''); // e.g. v0.31.5-rc1 + const m = ref.match(/^v(\d+\.\d+\.\d+)(-rc\d+)?$/); + if (!m) { + core.setFailed(`❌ tag '${ref}' must match 'vX.Y.Z' or 'vX.Y.Z-rcN'`); + return; + } + const version = m[1] + (m[2] ?? ''); // 0.31.5‑rc1 + const isRc = Boolean(m[2]); + const [maj, min] = m[1].split('.'); + core.setOutput('tag', ref); + core.setOutput('version', version); + core.setOutput('is_rc', isRc); + core.setOutput('line', `${maj}.${min}`); // 0.31 + + # Detect base branch (main or release‑X.Y) the tag was pushed from - name: Get base branch if: steps.check_release.outputs.skip == 'false' id: get_base uses: actions/github-script@v7 with: script: | - /* - For a push event with a tag, GitHub sets context.payload.base_ref - if the tag was pushed from a branch. - If it's empty, we can't determine the correct base branch and must fail. - */ const baseRef = context.payload.base_ref; if (!baseRef) { - core.setFailed(`❌ base_ref is empty. Make sure you push the tag from a branch (e.g. 'git push origin HEAD:refs/tags/vX.Y.Z').`); + core.setFailed(`❌ base_ref is empty. Push the tag via 'git push origin HEAD:refs/tags/'.`); return; } - - const shortBranch = baseRef.replace("refs/heads/", ""); - const releasePattern = /^release-\d+\.\d+$/; - if (shortBranch !== "main" && !releasePattern.test(shortBranch)) { - core.setFailed(`❌ Tagged commit must belong to branch 'main' or 'release-X.Y'. Got '${shortBranch}'`); + const branch = baseRef.replace('refs/heads/', ''); + const ok = branch === 'main' || /^release-\d+\.\d+$/.test(branch); + if (!ok) { + core.setFailed(`❌ Tagged commit must belong to 'main' or 'release-X.Y'. Got '${branch}'`); return; } + core.setOutput('branch', branch); - core.setOutput('branch', shortBranch); - - # 3) Checkout full git history and tags + # Checkout & login once - name: Checkout code if: steps.check_release.outputs.skip == 'false' uses: actions/checkout@v4 with: fetch-depth: 0 - fetch-tags: true + fetch-tags: true - # 4) Login to GitHub Container Registry - - name: Login to GitHub Container Registry + - name: Login to GHCR if: steps.check_release.outputs.skip == 'false' uses: docker/login-action@v3 with: @@ -83,113 +92,160 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} registry: ghcr.io - # 5) Build project artifacts + # Build project artifacts - name: Build if: steps.check_release.outputs.skip == 'false' run: make build - # 6) Optionally commit built artifacts to the repository + # Commit built artifacts - name: Commit release artifacts if: steps.check_release.outputs.skip == 'false' - env: - GIT_AUTHOR_NAME: ${{ github.actor }} - GIT_AUTHOR_EMAIL: ${{ github.actor }}@users.noreply.github.com run: | - git config user.name "github-actions" + git config user.name "github-actions" git config user.email "github-actions@github.com" git add . git commit -m "Prepare release ${GITHUB_REF#refs/tags/}" -s || echo "No changes to commit" + git push origin HEAD || true - # 7) Create a release branch like release-X.Y.Z + # Get `latest_version` from latest published release + - name: Get latest published release + if: steps.check_release.outputs.skip == 'false' + id: latest_release + uses: actions/github-script@v7 + with: + script: | + try { + const rel = await github.rest.repos.getLatestRelease({ + owner: context.repo.owner, + repo: context.repo.repo + }); + core.setOutput('tag', rel.data.tag_name); + } catch (_) { + core.setOutput('tag', ''); + } + + # Compare tag (A) with latest (B) + - name: Semver compare + if: steps.check_release.outputs.skip == 'false' + id: semver + uses: madhead/semver-utils@v4.3.0 + with: + version: ${{ steps.tag.outputs.tag }} # A + compare-to: ${{ steps.latest_release.outputs.tag }} # B + + # Create or reuse DRAFT GitHub Release + - name: Create / reuse draft release + if: steps.check_release.outputs.skip == 'false' + id: release + uses: actions/github-script@v7 + with: + script: | + const tag = '${{ steps.tag.outputs.tag }}'; + const isRc = ${{ steps.tag.outputs.is_rc }}; + const outdated = '${{ steps.semver.outputs.comparison-result }}' === '<'; + const makeLatest = outdated ? false : 'legacy'; + const releases = await github.rest.repos.listReleases({ + owner: context.repo.owner, + repo: context.repo.repo + }); + let rel = releases.data.find(r => r.tag_name === tag); + if (!rel) { + rel = await github.rest.repos.createRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + tag_name: tag, + name: tag, + draft: true, + prerelease: isRc, + make_latest: makeLatest + }); + console.log(`Draft release created for ${tag}`); + } else { + console.log(`Re‑using existing release ${tag}`); + } + core.setOutput('upload_url', rel.upload_url); + + # Build + upload assets (optional) + - name: Build & upload assets + if: steps.check_release.outputs.skip == 'false' + run: | + make assets + make upload_assets VERSION=${{ steps.tag.outputs.version }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Ensure long‑lived maintenance branch release‑X.Y + - name: Ensure maintenance branch release‑${{ steps.tag.outputs.line }} + if: | + steps.check_release.outputs.skip == 'false' && + steps.get_base.outputs.branch == 'main' + uses: actions/github-script@v7 + with: + script: | + const branch = `release-${'${{ steps.tag.outputs.line }}'}`; + try { + await github.rest.repos.getBranch({ + owner: context.repo.owner, + repo: context.repo.repo, + branch + }); + console.log(`Branch '${branch}' already exists`); + } catch (_) { + await github.git.createRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `refs/heads/${branch}`, + sha: context.sha + }); + console.log(`Branch '${branch}' created at ${context.sha}`); + } + + # Create release‑X.Y.Z branch and push (force‑update) - name: Create release branch if: steps.check_release.outputs.skip == 'false' run: | - BRANCH_NAME="release-${GITHUB_REF#refs/tags/v}" - git branch -f "$BRANCH_NAME" - git push origin "$BRANCH_NAME" --force + BRANCH="release-${GITHUB_REF#refs/tags/v}" + git branch -f "$BRANCH" + git push -f origin "$BRANCH" - # 8) Create a pull request from release-X.Y.Z to the original base branch + # Create pull request into original base branch (if absent) - name: Create pull request if not exists if: steps.check_release.outputs.skip == 'false' uses: actions/github-script@v7 with: script: | const version = context.ref.replace('refs/tags/v', ''); - const base = '${{ steps.get_base.outputs.branch }}'; - const head = `release-${version}`; + const base = '${{ steps.get_base.outputs.branch }}'; + const head = `release-${version}`; const prs = await github.rest.pulls.list({ owner: context.repo.owner, - repo: context.repo.repo, - head: `${context.repo.owner}:${head}`, + repo: context.repo.repo, + head: `${context.repo.owner}:${head}`, base }); - if (prs.data.length === 0) { - const newPr = await github.rest.pulls.create({ + const pr = await github.rest.pulls.create({ owner: context.repo.owner, - repo: context.repo.repo, + repo: context.repo.repo, head, base, title: `Release v${version}`, - body: - `This PR prepares the release \`v${version}\`.\n` + - `(Please merge it before releasing draft)`, + body: `This PR prepares the release \`v${version}\`.`, draft: false }); - - console.log(`Created pull request #${newPr.data.number} from ${head} to ${base}`); await github.rest.issues.addLabels({ owner: context.repo.owner, - repo: context.repo.repo, - issue_number: newPr.data.number, + repo: context.repo.repo, + issue_number: pr.data.number, labels: ['release'] }); + console.log(`Created PR #${pr.data.number}`); } else { - console.log(`Pull request already exists from ${head} to ${base}`); + console.log(`PR already exists from ${head} to ${base}`); } - # 9) Create or reuse an existing draft GitHub release for this tag - - name: Create or reuse draft release - if: steps.check_release.outputs.skip == 'false' - id: create_release - uses: actions/github-script@v7 - with: - script: | - const tag = context.ref.replace('refs/tags/', ''); - const releases = await github.rest.repos.listReleases({ - owner: context.repo.owner, - repo: context.repo.repo - }); - - let release = releases.data.find(r => r.tag_name === tag); - if (!release) { - release = await github.rest.repos.createRelease({ - owner: context.repo.owner, - repo: context.repo.repo, - tag_name: tag, - name: `${tag}`, - draft: true, - prerelease: false - }); - } - core.setOutput('upload_url', release.upload_url); - - # 10) Build additional assets for the release (if needed) - - name: Build assets - if: steps.check_release.outputs.skip == 'false' - run: make assets - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - # 11) Upload assets to the draft release - - name: Upload assets - if: steps.check_release.outputs.skip == 'false' - run: make upload_assets VERSION=${GITHUB_REF#refs/tags/} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - # 12) Run tests - - name: Run tests + # Run tests + - name: Test if: steps.check_release.outputs.skip == 'false' run: make test