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
91
.github/scripts/release-tag.sh
vendored
Executable file
91
.github/scripts/release-tag.sh
vendored
Executable file
|
|
@ -0,0 +1,91 @@
|
|||
#!/usr/bin/env bash
|
||||
# Validates a release tag and reports whether it is the newest release.
|
||||
#
|
||||
# Feature releases (patch level 0) must be tagged on master. Bugfix releases may
|
||||
# be tagged on any branch so an older release line can be serviced without
|
||||
# shipping everything that landed on master since.
|
||||
#
|
||||
# Prints `latest=<true|false>` for consumption as a GitHub step output. Only the
|
||||
# newest release may move the `latest` pointers (docker tag, homebrew formula,
|
||||
# GitHub latest release, hassio addon, demo instance).
|
||||
#
|
||||
# Expects a checkout with `fetch-depth: 0`, which populates both the remote
|
||||
# tracking branches and all tags.
|
||||
#
|
||||
# Run `release-tag.sh --self-test` to exercise the logic in a scratch repository.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# overridden with the repository default branch by the workflow
|
||||
MASTER_REF="${MASTER_REF:-origin/master}"
|
||||
|
||||
validate() {
|
||||
local tag=$1
|
||||
|
||||
if [[ ! $tag =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
|
||||
echo "::error::invalid release tag '$tag', expected MAJOR.MINOR.PATCH" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ${BASH_REMATCH[3]} == 0 ]] && ! git merge-base --is-ancestor "$tag" "$MASTER_REF"; then
|
||||
echo "::error::feature release '$tag' must be tagged on master" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local newest
|
||||
newest=$(git tag --list | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort --version-sort | tail -1) || true
|
||||
|
||||
if [[ $newest == "$tag" ]]; then
|
||||
echo "latest=true"
|
||||
else
|
||||
echo "latest=false"
|
||||
fi
|
||||
}
|
||||
|
||||
self_test() {
|
||||
dir=$(mktemp -d)
|
||||
trap 'rm -rf "$dir"' EXIT
|
||||
cd "$dir"
|
||||
|
||||
commit() { git -c user.email=t@t -c user.name=t commit --quiet --allow-empty -m "$1"; }
|
||||
|
||||
git init --quiet --initial-branch=master .
|
||||
commit one
|
||||
git tag 0.1.0
|
||||
git checkout --quiet -b fix
|
||||
commit two
|
||||
git tag 0.1.1 # bugfix release off master
|
||||
git tag 0.2.0 # feature release off master
|
||||
git checkout --quiet master
|
||||
commit three
|
||||
git tag 0.3.0
|
||||
git tag 9.9.9-fork # tags from forks must not count as a release
|
||||
|
||||
MASTER_REF=master
|
||||
|
||||
failed=0
|
||||
expect() { # expect <tag> <expected output or FAIL>
|
||||
local got
|
||||
if ! got=$(validate "$1" 2>/dev/null); then got=FAIL; fi
|
||||
if [[ $got != "$2" ]]; then
|
||||
echo "FAIL: $1 -> $got, want $2" >&2
|
||||
failed=1
|
||||
fi
|
||||
}
|
||||
|
||||
expect 0.1.0 latest=false # feature release on master, superseded
|
||||
expect 0.1.1 latest=false # bugfix release off master, older line
|
||||
expect 0.3.0 latest=true # newest release
|
||||
expect 0.2.0 FAIL # feature release not on master
|
||||
expect 0.1 FAIL # not MAJOR.MINOR.PATCH
|
||||
expect v0.1.0 FAIL # no v prefix allowed
|
||||
|
||||
[[ $failed == 0 ]] && echo "self-test ok"
|
||||
return $failed
|
||||
}
|
||||
|
||||
if [[ ${1:-} == --self-test ]]; then
|
||||
self_test
|
||||
else
|
||||
validate "${1:?usage: release-tag.sh <tag>}"
|
||||
fi
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ release:
|
|||
owner: evcc-io
|
||||
name: evcc
|
||||
mode: replace
|
||||
# bugfix releases of an older line are published, but do not become "latest"
|
||||
make_latest: '{{ envOrDefault "MAKE_LATEST" "true" }}'
|
||||
|
||||
builds:
|
||||
- id: evcc
|
||||
|
|
@ -119,7 +121,8 @@ nfpms:
|
|||
postremove: ./packaging/scripts/postremove.sh
|
||||
|
||||
brews:
|
||||
- repository:
|
||||
- skip_upload: '{{ if eq (envOrDefault "MAKE_LATEST" "true") "true" }}false{{ else }}true{{ end }}'
|
||||
repository:
|
||||
owner: evcc-io
|
||||
name: homebrew-tap
|
||||
commit_author:
|
||||
|
|
|
|||
|
|
@ -74,6 +74,28 @@ GOOS=linux GOARCH=arm GOARM=6 make
|
|||
make docker DOCKER_IMAGE=my/docker DOCKER_TAG=0815
|
||||
```
|
||||
|
||||
## Releases
|
||||
|
||||
Releases are cut by pushing a `MAJOR.MINOR.PATCH` tag. Any other tag is ignored.
|
||||
|
||||
Feature releases (`0.313.0`) must be tagged on `master`. The release workflow
|
||||
rejects a feature tag that is not reachable from `master`.
|
||||
|
||||
Bugfix releases (`0.313.1`) may be tagged on any branch. That allows servicing
|
||||
an older release line without shipping everything that has landed on `master`
|
||||
since. Only the newest release moves the `latest` pointers, so a bugfix release
|
||||
of an older line publishes its artifacts, but leaves the `evcc/evcc:latest`
|
||||
docker tag, the homebrew formula, the GitHub latest release, the hassio addon
|
||||
and the demo instance untouched.
|
||||
|
||||
To move a merged pull request onto a release branch, comment `/backport` on it.
|
||||
The pull request has to carry the `bug` label and must not be marked `(BC)`,
|
||||
only non-breaking bugfixes are backported.
|
||||
The commit is cherry-picked onto the branch of the current release line, e.g.
|
||||
`release/0.313`, and a pull request is opened against it. The branch is created
|
||||
at the newest tag of that line if it does not exist yet. Pass a branch name,
|
||||
`/backport release/0.312`, to service an older line.
|
||||
|
||||
## Debugging in VS Code
|
||||
|
||||
### evcc Core
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue