diff --git a/.github/scripts/release-tag.sh b/.github/scripts/release-tag.sh new file mode 100755 index 000000000..fd3bf867d --- /dev/null +++ b/.github/scripts/release-tag.sh @@ -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=` 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 + 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 }" +fi diff --git a/.github/workflows/command-backport.yml b/.github/workflows/command-backport.yml new file mode 100644 index 000000000..4fe34ad89 --- /dev/null +++ b/.github/workflows/command-backport.yml @@ -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 } + ); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0796482bf..693144373 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: diff --git a/.goreleaser.yml b/.goreleaser.yml index 1379d72e1..b62ad736c 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -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: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4c37bf915..5a073c0ad 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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