## Summary - Replace `3297617+cozystack-ci[bot]@users.noreply.github.com` with `cozystack@users.noreply.github.com` across all CI workflows - DCO probot rejects the `[bot]` brackets in email as invalid, causing release PRs to fail DCO checks ## Test plan - [ ] Verify DCO passes on release PRs after backport to release branches Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
212 lines
7.8 KiB
YAML
212 lines
7.8 KiB
YAML
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:
|
||
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`);
|
||
}
|