#!/usr/bin/env bash # Woodpecker plugin "bump": sets the version for the pushed branch, commits it # 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. # # 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; } 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" 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 # Token only via credential helper, never in a URL or config, so no log shows it export GIT_USER PLUGIN_GIT_TOKEN git config --global --add safe.directory "$PWD" git config credential.helper '!f() { echo "username=$GIT_USER"; echo "password=$PLUGIN_GIT_TOKEN"; }; f' git remote set-url origin "$REMOTE" # The Woodpecker clone is shallow and has no tags; the version rules need both. if [[ $(git rev-parse --is-shallow-repository) == true ]]; then git fetch --quiet --unshallow origin fi 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. 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" 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}" } # 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" } # 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"; } } 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 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" } # 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" } 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_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 } 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")") ;; *) 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" npm version "$VERSION" --no-git-tag-version --allow-same-version >/dev/null TAG=v$VERSION if git diff --quiet; then 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]" fi git tag "$TAG" # 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 echo "bump: $TAG on $BRANCH"