chore: enforce release branch policy and add backport command (#32369)
This commit is contained in:
parent
52ea5e9bb8
commit
042706a713
5 changed files with 348 additions and 3 deletions
195
.github/workflows/command-backport.yml
vendored
Normal file
195
.github/workflows/command-backport.yml
vendored
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
name: Backport Command
|
||||
|
||||
# Triggered by a maintainer commenting `/backport [branch]` on a merged pull
|
||||
# request. Cherry-picks the merged commit onto the target branch and opens a
|
||||
# pull request against it, so a fix can ship as a bugfix release without
|
||||
# pulling in everything that landed on master since.
|
||||
#
|
||||
# Without an argument the branch of the current release line is used, e.g.
|
||||
# `release/0.313`. It is created at the newest tag of that line on first use.
|
||||
#
|
||||
# Pushing and opening the pull request use RELEASE_DEPLOY_TOKEN so its checks
|
||||
# start without approval. GITHUB_TOKEN would create them in a pending state.
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
backport:
|
||||
name: Backport
|
||||
# on any PR comment starting with /backport, only from maintainers
|
||||
if: |
|
||||
github.event.issue.pull_request &&
|
||||
startsWith(github.event.comment.body, '/backport') &&
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)
|
||||
runs-on: depot-ubuntu-24.04-arm
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: React to comment
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
await github.rest.reactions.createForIssueComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: context.payload.comment.id,
|
||||
content: 'eyes',
|
||||
});
|
||||
|
||||
- name: Resolve target branch
|
||||
id: pr
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
// reported back as a comment, so the reason is visible on the pull request
|
||||
const fail = (message) => {
|
||||
core.setOutput('error', message);
|
||||
core.setFailed(message);
|
||||
};
|
||||
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: context.payload.issue.number,
|
||||
});
|
||||
if (!pr.merged) {
|
||||
fail(`pull request #${pr.number} is not merged`);
|
||||
return;
|
||||
}
|
||||
if (!pr.labels.some((l) => l.name === 'bug')) {
|
||||
fail(`pull request #${pr.number} is not labelled \`bug\`, only bugfixes are backported`);
|
||||
return;
|
||||
}
|
||||
// same marker the changelog uses to group breaking changes
|
||||
if (/\(BC\)/i.test(pr.title)) {
|
||||
fail(`pull request #${pr.number} is a breaking change, it must not go into a bugfix release`);
|
||||
return;
|
||||
}
|
||||
|
||||
const compare = (a, b) => {
|
||||
const [x, y] = [a, b].map((v) => v.split('.').map(Number));
|
||||
return x[0] - y[0] || x[1] - y[1] || x[2] - y[2];
|
||||
};
|
||||
const tags = await github.paginate(github.rest.repos.listTags, { owner, repo, per_page: 100 });
|
||||
const releases = tags.map((t) => t.name).filter((n) => /^\d+\.\d+\.\d+$/.test(n)).sort(compare);
|
||||
|
||||
let target = context.payload.comment.body.trim().split(/\s+/)[1];
|
||||
if (target) {
|
||||
// the target ends up in a checkout ref, keep it to plain branch names
|
||||
if (!/^[\w.\-\/]+$/.test(target) || target.includes('..')) {
|
||||
fail('usage: /backport [branch]');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const line = releases[releases.length - 1].split('.').slice(0, 2).join('.');
|
||||
target = `release/${line}`;
|
||||
}
|
||||
|
||||
// branch off the newest tag of the release line on first backport
|
||||
try {
|
||||
await github.rest.repos.getBranch({ owner, repo, branch: target });
|
||||
} catch (err) {
|
||||
if (err.status !== 404) throw err;
|
||||
|
||||
const line = target.replace(/^release\//, '');
|
||||
const base = releases.filter((n) => n.startsWith(`${line}.`)).pop();
|
||||
if (!base) {
|
||||
fail(`branch \`${target}\` does not exist and no release matches it`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: commit } = await github.rest.repos.getCommit({ owner, repo, ref: base });
|
||||
await github.rest.git.createRef({ owner, repo, ref: `refs/heads/${target}`, sha: commit.sha });
|
||||
core.notice(`created ${target} at ${base}`);
|
||||
}
|
||||
|
||||
core.setOutput('target', target);
|
||||
core.setOutput('branch', `backport/${pr.number}-${target}`);
|
||||
core.setOutput('sha', pr.merge_commit_sha);
|
||||
core.setOutput('title', pr.title);
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ steps.pr.outputs.target }}
|
||||
# persisted for the push below
|
||||
token: ${{ secrets.RELEASE_DEPLOY_TOKEN }}
|
||||
|
||||
- name: Cherry-pick
|
||||
env:
|
||||
BRANCH: ${{ steps.pr.outputs.branch }}
|
||||
SHA: ${{ steps.pr.outputs.sha }}
|
||||
run: |
|
||||
git config user.name github-actions
|
||||
git config user.email github-actions@github.com
|
||||
git switch -c "$BRANCH"
|
||||
|
||||
# -m 1 picks the first-parent diff of a merge commit; squashed pull
|
||||
# requests are ordinary commits and must not get the flag
|
||||
mainline=""
|
||||
if git rev-parse --quiet --verify "$SHA^2" >/dev/null; then
|
||||
mainline="-m 1"
|
||||
fi
|
||||
|
||||
git cherry-pick $mainline "$SHA"
|
||||
git push origin "$BRANCH"
|
||||
|
||||
- name: Create pull request
|
||||
id: create
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RELEASE_DEPLOY_TOKEN }}
|
||||
BRANCH: ${{ steps.pr.outputs.branch }}
|
||||
TARGET: ${{ steps.pr.outputs.target }}
|
||||
TITLE: ${{ steps.pr.outputs.title }}
|
||||
NUMBER: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
url=$(gh pr create \
|
||||
--base "$TARGET" \
|
||||
--head "$BRANCH" \
|
||||
--title "$TITLE" \
|
||||
--body "Backport of #$NUMBER to \`$TARGET\`.")
|
||||
echo "url=$url" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Comment result
|
||||
if: always()
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
TARGET: ${{ steps.pr.outputs.target }}
|
||||
URL: ${{ steps.create.outputs.url }}
|
||||
ERROR: ${{ steps.pr.outputs.error }}
|
||||
with:
|
||||
script: |
|
||||
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
|
||||
const { TARGET, URL, ERROR } = process.env;
|
||||
const body = URL
|
||||
? `✅ Backported to \`${TARGET}\`: ${URL}`
|
||||
: ERROR
|
||||
? `❌ ${ERROR}`
|
||||
: `❌ Backport failed, most likely a cherry-pick conflict. See the [run logs](${runUrl}).`;
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
body,
|
||||
});
|
||||
|
||||
- name: Resolve command comment
|
||||
if: steps.create.outputs.url
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
// collapse the /backport comment as resolved now that the pull request exists
|
||||
await github.graphql(
|
||||
`mutation($id:ID!){minimizeComment(input:{subjectId:$id,classifier:RESOLVED}){minimizedComment{isMinimized}}}`,
|
||||
{ id: context.payload.comment.node_id }
|
||||
);
|
||||
38
.github/workflows/release.yml
vendored
38
.github/workflows/release.yml
vendored
|
|
@ -6,11 +6,33 @@ permissions:
|
|||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
- "[0-9]+.[0-9]+.[0-9]+"
|
||||
|
||||
jobs:
|
||||
guard:
|
||||
name: Validate Tag
|
||||
runs-on: depot-ubuntu-24.04-arm
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
latest: ${{ steps.tag.outputs.latest }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
# feature releases must come from master, bugfix releases may come from any
|
||||
# branch. only the newest release moves the latest pointers.
|
||||
- id: tag
|
||||
env:
|
||||
MASTER_REF: origin/${{ github.event.repository.default_branch }}
|
||||
run: .github/scripts/release-tag.sh "${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
call-build-workflow:
|
||||
if: startsWith(github.ref, 'refs/tags')
|
||||
needs:
|
||||
- guard
|
||||
uses: evcc-io/evcc/.github/workflows/default.yml@master
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -19,6 +41,7 @@ jobs:
|
|||
docker:
|
||||
name: Publish Docker :release
|
||||
needs:
|
||||
- guard
|
||||
- call-build-workflow
|
||||
runs-on: depot-ubuntu-24.04-arm
|
||||
permissions:
|
||||
|
|
@ -45,6 +68,9 @@ jobs:
|
|||
with:
|
||||
images: |
|
||||
evcc/evcc
|
||||
# a bugfix release of an older line must not move :latest
|
||||
flavor: |
|
||||
latest=${{ needs.guard.outputs.latest }}
|
||||
|
||||
- name: Publish
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
|
||||
|
|
@ -59,6 +85,7 @@ jobs:
|
|||
apt:
|
||||
name: Github & APT
|
||||
needs:
|
||||
- guard
|
||||
- call-build-workflow
|
||||
runs-on: depot-ubuntu-24.04-arm
|
||||
|
||||
|
|
@ -110,6 +137,9 @@ jobs:
|
|||
env:
|
||||
# use RELEASE_DEPLOY_TOKEN for access to evcc-io/homebrew-tap
|
||||
GITHUB_TOKEN: ${{ secrets.RELEASE_DEPLOY_TOKEN }}
|
||||
# a bugfix release of an older line must not move the homebrew formula
|
||||
# or the Github latest release marker
|
||||
MAKE_LATEST: ${{ needs.guard.outputs.latest }}
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
|
|
@ -126,7 +156,9 @@ jobs:
|
|||
demo:
|
||||
name: Demo
|
||||
needs:
|
||||
- guard
|
||||
- docker
|
||||
if: needs.guard.outputs.latest == 'true'
|
||||
runs-on: depot-ubuntu-24.04-arm
|
||||
|
||||
permissions:
|
||||
|
|
@ -144,7 +176,9 @@ jobs:
|
|||
hassio:
|
||||
name: Hassio Addon
|
||||
needs:
|
||||
- guard
|
||||
- docker
|
||||
if: needs.guard.outputs.latest == 'true'
|
||||
runs-on: depot-ubuntu-24.04-arm
|
||||
|
||||
permissions:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue