From d4ee9f0663ed6da02032ee27898025836edff48c Mon Sep 17 00:00:00 2001 From: Torsten Ueberschar Date: Fri, 30 Oct 2026 19:02:47 +0100 Subject: [PATCH] fix(bump): Release-Modell nach D8/D9, atomarer Push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - production/*: kein Bump, der Schritt bricht ab (W4, D9) - main hat keine Release-Rolle mehr; stabile Tags nur über settings.release_branch (Patch-Bump, für Repos ohne Majors wie oumta-keyklo), ohne Commit-Message-Magie - Linie für dev-Tags: nächster release/* oder hotfix/*-Branch nach jüngster Merge-Base, bei Gleichstand die niedrigere Linie - existiert das stabile Tag der Linie, zählt der Patch hoch (v2.4.1-beta.0 nach v2.4.0) - hotfix/X.Y ohne Patch wird abgelehnt - Branch und Tag in einem atomaren Push, kein halber Zustand nach Teilerfolg - README: Regeln, Einschränkung des Secrets, Paket-Schreibrecht in actions Co-Authored-By: Claude Fable 5.1 --- README.md | 24 ++-- woodpecker/bump/bump.sh | 235 +++++++++++++++------------------------- 2 files changed, 105 insertions(+), 154 deletions(-) diff --git a/README.md b/README.md index 4a0c13b..58d7d7d 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,9 @@ jobs: ## Woodpecker plugins -Plugins are Docker images built from `woodpecker//` by `.woodpecker/plugins.yaml`. A tag `-vX.Y.Z` publishes `git.uesome.de/actions/ci-cd/:vX.Y.Z`; pipelines pin that version. +Plugins are Docker images built from `woodpecker//` by `.woodpecker/plugins.yaml`. A tag `-vX.Y.Z` publishes `git.uesome.de/actions/ci-cd/:vX.Y.Z`; pipelines pin that version. The push uses `oumta-ci` with the Woodpecker secret `oumta_ci_token`, so `oumta-ci` needs package write access in the org `actions`. + +The secret `oumta_ci_token` holds the one token `oumta-ci` writes with. Limit it in Woodpecker to the images `woodpeckerci/plugin-docker-buildx:6`, `git.uesome.de/actions/ci-cd/bump` and `git.uesome.de/actions/ci-cd/deploy` and to the events `push`, `tag` and `manual` (zugaenge.md in oumta-deploy). ### deploy @@ -169,19 +171,23 @@ steps: ### bump -Sets the version for the pushed branch in `package.json`, commits it as `update version: vX [skip ci]`, tags it and pushes branch and tag. The tag event then starts the build; `[skip ci]` only suppresses push and pull_request events. +Sets the version for the pushed branch in `package.json`, commits it as `update version: vX.Y.Z [skip ci]`, tags it and pushes branch and tag in one atomic push. The tag event then starts the build; `[skip ci]` only suppresses push and pull_request events. -- `main`: release from the commit message (`release/x.y[.z]`, `hotfix/x.y.z`), otherwise a patch bump -- `release/*`, `hotfix/*`: next `vX.Y.Z-beta.N` -- any other branch: `vX.Y.Z-dev.`; the release line comes from `release_line`, the newest ancestor `release/*` branch, or `package.json` +- `release/X.Y[.Z]`, `hotfix/X.Y.Z`: next `vX.Y.Z-beta.N` of that line (D8). A hotfix needs a patch number. +- `production/*`: no bump, the step fails. A release is a retag of a beta image (D9). +- the branch in `release_branch` (repos without majors, e.g. oumta-keyklo `main`): next free `vX.Y.Z`, a patch bump from `package.json` +- any other branch: `vX.Y.Z-dev.`; the line comes from `release_line`, the branch name, the nearest `release/*` or `hotfix/*` branch (newest merge base), or `major.minor` from `package.json` -Only the commit that triggered the pipeline is bumped; if the branch moved on, the step exits without a bump and the newer pipeline takes over. +If the stable tag of a line exists (`v2.4.0` after the release), the next patch of that line is used (`v2.4.1-beta.0`, `v2.4.1-dev.…`). + +Only the commit that triggered the pipeline is bumped; if the branch moved on, the step exits without a bump and the newer pipeline takes over. The same happens when the push is rejected because of a race: branch and tag are pushed atomically, so either both land or neither. Settings: -- `git_token` (required): token with write access to the repo (`oumta_ci_token`) +- `git_token` (required): token of `oumta-ci` (`oumta_ci_token`), write access to the repo - `git_user`: default `oumta-ci` -- `release_line`: optional, `.[.]` +- `release_branch`: optional, branch that gets stable tags instead of dev tags +- `release_line`: optional, `.[.]`, overrides every other rule - `author_name`, `author_email`: default `oumta-ci`, `oumta-ci@noreply.git.uesome.de` - `remote`: default `CI_REPO_CLONE_URL` @@ -190,7 +196,7 @@ Example: ```yaml when: - event: push - branch: [develop, 'feature/**', 'release/**', 'hotfix/**'] + branch: ['feature/**', 'release/**', 'hotfix/**'] - event: manual steps: diff --git a/woodpecker/bump/bump.sh b/woodpecker/bump/bump.sh index 0063d9c..5b48ca3 100755 --- a/woodpecker/bump/bump.sh +++ b/woodpecker/bump/bump.sh @@ -3,10 +3,10 @@ # with [skip ci], tags it and pushes both. The tag event then starts the build; # [skip ci] only suppresses push and pull_request events, not tag events. # -# main release from the commit message (release/x.y[.z], -# hotfix/x.y.z), otherwise a patch bump -# release/*, hotfix/* next vX.Y.Z-beta.N -# any other branch vX.Y.Z-dev. +# release/X.Y[.Z], hotfix/X.Y.Z next vX.Y.Z-beta.N (D8) +# production/* nothing: releases are retags (D9) +# settings.release_branch next free vX.Y.Z (repos without majors) +# any other branch vX.Y.Z-dev. set -euo pipefail die() { echo "bump: $*" >&2; exit 1; } @@ -15,8 +15,13 @@ BRANCH=${CI_COMMIT_BRANCH:?CI_COMMIT_BRANCH is missing (push event only)} SHA=${CI_COMMIT_SHA:?CI_COMMIT_SHA is missing} REMOTE=${PLUGIN_REMOTE:-${CI_REPO_CLONE_URL:?CI_REPO_CLONE_URL is missing}} GIT_USER=${PLUGIN_GIT_USER:-oumta-ci} +RELEASE_LINE=${PLUGIN_RELEASE_LINE:-} +RELEASE_BRANCH=${PLUGIN_RELEASE_BRANCH:-} [[ -n ${PLUGIN_GIT_TOKEN:-} ]] || die "settings.git_token is missing" -INPUT_RELEASE_LINE=${PLUGIN_RELEASE_LINE:-} + +case $BRANCH in + production/*) die "no bump on $BRANCH: a release is a retag of a beta image (D9)" ;; +esac export GIT_AUTHOR_NAME=${PLUGIN_AUTHOR_NAME:-oumta-ci} GIT_AUTHOR_EMAIL=${PLUGIN_AUTHOR_EMAIL:-oumta-ci@noreply.git.uesome.de} export GIT_COMMITTER_NAME=$GIT_AUTHOR_NAME GIT_COMMITTER_EMAIL=$GIT_AUTHOR_EMAIL @@ -35,178 +40,118 @@ git fetch --quiet --tags --force origin "+refs/heads/*:refs/remotes/origin/*" # Bump exactly the commit that triggered the pipeline. A newer push on the # branch starts its own pipeline, so this one steps aside. -if [[ $(git rev-parse "origin/$BRANCH") != "$SHA" ]]; then +head=$(git rev-parse -q --verify "origin/$BRANCH") || die "$BRANCH no longer exists on origin" +if [[ $head != "$SHA" ]]; then echo "bump: $BRANCH moved past ${SHA:0:8}, the newer pipeline bumps" exit 0 fi git checkout --quiet -B "$BRANCH" "$SHA" -set_version() { - npm version "$1" --no-git-tag-version --allow-same-version >/dev/null +tag_exists() { git rev-parse -q --verify "refs/tags/v$1" >/dev/null; } + +# "2.4" or "v2.4.1" -> "2.4.0" / "2.4.1"; fails for anything else +parse_line() { + [[ $1 =~ ^v?([0-9]+)\.([0-9]+)(\.([0-9]+))?$ ]] || return 1 + echo "${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[4]:-0}" } -read_package_version() { - node -p "require('./package.json').version" +# release/2.4 -> 2.4.0, hotfix/2.4.1 -> 2.4.1; a hotfix without a patch fails +line_of_branch() { + local kind name + [[ $1 =~ ^(release|hotfix)/(.+)$ ]] || return 1 + kind=${BASH_REMATCH[1]} name=${BASH_REMATCH[2]} + [[ $kind == hotfix && ! $name =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]] && return 1 + parse_line "$name" } -normalize_release_base() { - local value="$1" - if [[ "${value}" =~ ^v?([0-9]+)\.([0-9]+)$ ]]; then - echo "${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.0" - return 0 - fi - if [[ "${value}" =~ ^v?([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then - echo "${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}" - return 0 - fi - return 1 +# The release or hotfix branch whose merge base with HEAD is the newest, i.e. +# the line this commit was branched from most recently. On a tie (a hotfix +# forked from the same commit as the release) the lower line wins. +line_of_nearest_branch() { + local ref line base + while read -r ref; do + line=$(line_of_branch "${ref#origin/}") || continue + base=$(git merge-base "$ref" HEAD 2>/dev/null) || continue + echo "$(git show -s --format=%ct "$base") ${line//./ } $line" + done < <(git for-each-ref --format='%(refname:short)' refs/remotes/origin/release/ refs/remotes/origin/hotfix/) | + sort -k1,1nr -k2,2n -k3,3n -k4,4n | head -n 1 | { read -r _ _ _ _ line && echo "$line"; } } -extract_release_base() { - local ref_name="$1" - local normalized - if [[ "${ref_name}" =~ ^(release|hotfix)/(.+)$ ]]; then - if normalized="$(normalize_release_base "${BASH_REMATCH[2]}")"; then - echo "${normalized}" - return 0 - fi +package_version() { node -p "require('./package.json').version"; } + +# The line for this branch: setting, branch name, nearest release/hotfix +# branch, else major.minor of package.json. +resolve_line() { + local line + if [[ -n $RELEASE_LINE ]]; then + parse_line "$RELEASE_LINE" || die "invalid settings.release_line '$RELEASE_LINE', expected .[.]" + return fi - return 1 + line_of_branch "$BRANCH" && return + line_of_nearest_branch && return + line=$(package_version) + parse_line "${line%%[-+]*}" 2>/dev/null && return + die "no release line for '$BRANCH': use settings.release_line or a semantic version in package.json" } -# Only release branches: a fresh hotfix branch is an ancestor of everything. -infer_release_base_from_ancestor_release_branch() { - local best_release_base="" - local best_commit_time=0 - local remote_ref - local release_base - local commit_time - - while IFS= read -r remote_ref; do - if ! release_base="$(extract_release_base "${remote_ref#origin/}")"; then - continue - fi - if ! git merge-base --is-ancestor "$(git rev-parse "${remote_ref}")" HEAD; then - continue - fi - commit_time="$(git show -s --format=%ct "${remote_ref}")" - if [[ "${commit_time}" -gt "${best_commit_time}" ]]; then - best_commit_time="${commit_time}" - best_release_base="${release_base}" - fi - done < <(git for-each-ref --format='%(refname:short)' refs/remotes/origin/release/) - - [[ -n "${best_release_base}" ]] && echo "${best_release_base}" +# A line whose stable tag exists was released; the next patch is the open one. +next_free_patch() { + local line=$1 patch + while tag_exists "$line"; do + patch=${line##*.} + line=${line%.*}.$((patch + 1)) + done + echo "$line" } -resolve_release_base() { - local package_version - local normalized - - if [[ -n "${INPUT_RELEASE_LINE}" ]]; then - normalized="$(normalize_release_base "${INPUT_RELEASE_LINE}")" || - die "invalid settings.release_line '${INPUT_RELEASE_LINE}', expected .[.]" - echo "${normalized}" - return 0 - fi - if extract_release_base "${BRANCH}"; then - return 0 - fi - if infer_release_base_from_ancestor_release_branch; then - return 0 - fi - package_version="$(read_package_version)" - if [[ "${package_version}" =~ ^([0-9]+)\.([0-9]+)\.[0-9]+([-.].*)?$ ]]; then - echo "${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.0" - return 0 - fi - die "no release base for '${BRANCH}': use release/.[.], settings.release_line or a semantic version in package.json" +next_beta() { + local line=$1 highest + highest=$(git tag --list "v$line-beta.*" | sed -nE 's/^.*-beta\.([0-9]+)$/\1/p' | sort -n | tail -n 1) + echo "$line-beta.$(( ${highest:--1} + 1 ))" } -next_beta_version() { - local release_base="$1" - local escaped_base - local highest_beta - escaped_base="$(printf '%s' "${release_base}" | sed 's/\./\\./g')" - highest_beta="$( - git tag --list "v${release_base}-beta.*" \ - | sed -nE "s/^v${escaped_base}-beta\.([0-9]+)$/\\1/p" \ - | sort -n \ - | tail -n 1 - )" - if [[ -z "${highest_beta}" ]]; then - echo "${release_base}-beta.0" - else - echo "${release_base}-beta.$((highest_beta + 1))" - fi -} - -next_dev_version() { - local release_base="$1" - local timestamp - while true; do - timestamp="$(date -u +%Y%m%d%H%M%S)" - if ! git rev-parse -q --verify "refs/tags/v${release_base}-dev.${timestamp}" >/dev/null; then - echo "${release_base}-dev.${timestamp}" - return 0 - fi +next_dev() { + local line=$1 stamp + while :; do + stamp=$(date -u +%Y%m%d%H%M%S) + tag_exists "$line-dev.$stamp" || { echo "$line-dev.$stamp"; return; } sleep 1 done } -next_version() { - case "${BRANCH}" in - main) npm version patch --no-git-tag-version >/dev/null ;; - release/* | hotfix/*) set_version "$(next_beta_version "${RELEASE_BASE}")" ;; - *) set_version "$(next_dev_version "${RELEASE_BASE}")" ;; - esac -} - -case "${BRANCH}" in - main) - COMMIT_MSG="$(git log -1 --pretty=%B)" - TARGET_VERSION="" - if [[ "${COMMIT_MSG}" =~ release/v?([0-9]+\.[0-9]+\.[0-9]+) ]]; then - TARGET_VERSION="${BASH_REMATCH[1]}" - elif [[ "${COMMIT_MSG}" =~ release/([0-9]+)\.([0-9]+) ]]; then - TARGET_VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.0" - elif [[ "${COMMIT_MSG}" =~ hotfix/([0-9]+\.[0-9]+\.[0-9]+) ]]; then - TARGET_VERSION="${BASH_REMATCH[1]}" - fi - if [[ -n "${TARGET_VERSION}" ]]; then - set_version "${TARGET_VERSION}" - else - next_version - fi +case $BRANCH in + release/* | hotfix/*) + line=$(line_of_branch "$BRANCH") || die "unusable branch name '$BRANCH', expected release/X.Y[.Z] or hotfix/X.Y.Z" + VERSION=$(next_beta "$(next_free_patch "$line")") ;; *) - RELEASE_BASE="$(resolve_release_base)" - next_version + if [[ -n $RELEASE_BRANCH && $BRANCH == "$RELEASE_BRANCH" ]]; then + line=$(package_version) + line=$(parse_line "${line%%[-+]*}") || die "package.json version '$line' is not semantic" + patch=${line##*.} + VERSION=$(next_free_patch "${line%.*}.$((patch + 1))") + else + VERSION=$(next_dev "$(next_free_patch "$(resolve_line)")") + fi ;; esac +tag_exists "$VERSION" && die "tag v$VERSION already exists" -VERSION="$(read_package_version)" -while git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; do - echo "bump: tag v${VERSION} already exists, trying the next version" - next_version - VERSION="$(read_package_version)" -done - -TAG="v${VERSION}" +npm version "$VERSION" --no-git-tag-version --allow-same-version >/dev/null +TAG=v$VERSION if git diff --quiet; then - echo "bump: no version file changes, tagging the current commit" + echo "bump: version already $VERSION, tagging the current commit" else git add package.json [[ -f package-lock.json ]] && git add package-lock.json - git commit --quiet -m "update version: ${TAG} [skip ci]" + git commit --quiet -m "update version: $TAG [skip ci]" fi -git tag "${TAG}" +git tag "$TAG" -# Branch first, then the tag, so the tag never points at an unpushed commit. -# If the branch moved meanwhile, the push is rejected and nothing is tagged. -if ! git push --quiet origin "HEAD:refs/heads/${BRANCH}"; then - echo "bump: push to ${BRANCH} rejected, the newer pipeline bumps" +# Branch and tag in one atomic push: either both land or neither. If the +# branch moved meanwhile, the push is rejected and nothing is tagged. +if ! git push --quiet --atomic origin "HEAD:refs/heads/$BRANCH" "refs/tags/$TAG"; then + echo "bump: push to $BRANCH rejected, the newer pipeline bumps" exit 0 fi -git push --quiet origin "refs/tags/${TAG}" -echo "bump: ${TAG} on ${BRANCH}" +echo "bump: $TAG on $BRANCH" -- 2.54.0