From 06f817948cc9f0ee7424444955855130add13cb2 Mon Sep 17 00:00:00 2001 From: Dmytro Sydorov Date: Wed, 1 Apr 2026 10:46:30 +0200 Subject: [PATCH] feat(release-notification): extract branch detection into testable script Move inline shell logic from notify-release workflow into detect-branch.sh so it can be shellchecked and unit-tested. The composite action now auto-detects the source branch when base_branch is not provided. Add bats test suite and CI workflow. --- .../actions/release-notification/action.yml | 14 +- .../release-notification/detect-branch.sh | 61 +++++ .../test/detect-branch.bats | 238 ++++++++++++++++++ .github/workflows/notify-release.yaml | 55 ++++ .../workflows/test-release-notification.yaml | 27 ++ .gitignore | 2 + Makefile | 7 +- docs/CONVENTIONS.md | 15 +- 8 files changed, 414 insertions(+), 5 deletions(-) create mode 100755 .github/actions/release-notification/detect-branch.sh create mode 100644 .github/actions/release-notification/test/detect-branch.bats create mode 100644 .github/workflows/notify-release.yaml create mode 100644 .github/workflows/test-release-notification.yaml diff --git a/.github/actions/release-notification/action.yml b/.github/actions/release-notification/action.yml index 798147c..bd82dbf 100644 --- a/.github/actions/release-notification/action.yml +++ b/.github/actions/release-notification/action.yml @@ -30,8 +30,9 @@ inputs: description: 'Product name (vCluster or vCluster Platform)' required: true base_branch: - description: 'Source branch from which the release was cut' + description: 'Source branch from which the release was cut (auto-detected from git history when omitted)' required: false + default: '' webhook_url: description: 'Slack Webhook URL' required: true @@ -39,6 +40,15 @@ inputs: runs: using: "composite" steps: + - name: Detect base branch + id: detect_branch + if: inputs.base_branch == '' + shell: bash + env: + RELEASE_VERSION: ${{ inputs.version }} + run: | + BRANCH=$("${{ github.action_path }}/detect-branch.sh") + echo "base_branch=$BRANCH" >> "$GITHUB_OUTPUT" - name: Post release notification uses: slackapi/slack-github-action@af78098f536edbc4de71162a307590698245be95 # v3.0.1 with: @@ -66,7 +76,7 @@ runs: - type: section fields: - type: mrkdwn - text: "*Source Branch:*\n${{ inputs.base_branch || 'main' }}" + text: "*Source Branch:*\n${{ inputs.base_branch || steps.detect_branch.outputs.base_branch || 'main' }}" - type: section text: type: mrkdwn diff --git a/.github/actions/release-notification/detect-branch.sh b/.github/actions/release-notification/detect-branch.sh new file mode 100755 index 0000000..bf67326 --- /dev/null +++ b/.github/actions/release-notification/detect-branch.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# detect-branch.sh — Detect which branch a release tag was cut from. +# +# Finds all remote branches containing the tag commit, then picks the one +# whose tip is closest (fewest commits ahead). +# +# Required environment variables: +# RELEASE_VERSION — the release tag (e.g. v1.2.3) +# +# Optional environment variables: +# DEFAULT_BRANCH — fallback branch name (default: main) +# +# Output (stdout): the detected branch name + +set -euo pipefail + +: "${RELEASE_VERSION:?RELEASE_VERSION must be set}" +DEFAULT_BRANCH="${DEFAULT_BRANCH:-main}" + +TAG_COMMIT=$(git rev-list -n 1 "$RELEASE_VERSION") + +BEST_BRANCH="$DEFAULT_BRANCH" +MAX_DISTANCE=999999 +BEST_DISTANCE=$MAX_DISTANCE + +if ! git rev-parse --verify "origin/$DEFAULT_BRANCH" >/dev/null 2>&1; then + echo "WARNING: default branch 'origin/$DEFAULT_BRANCH' not found on remote" >&2 +fi + +REMOTE_BRANCHES=$(git for-each-ref --format='%(refname:short)' \ + --contains="$TAG_COMMIT" refs/remotes/origin/ | sed 's|^origin/||' | { grep -v '^HEAD$' || true; }) + +if [ -z "$REMOTE_BRANCHES" ]; then + echo "No remote branches contain this commit, falling back to '$DEFAULT_BRANCH'" >&2 +else + echo "Remote branches containing this commit: $REMOTE_BRANCHES" >&2 + + for REMOTE_BRANCH in $REMOTE_BRANCHES; do + BRANCH_BASE=$(git merge-base "origin/$DEFAULT_BRANCH" "origin/$REMOTE_BRANCH" 2>/dev/null || echo "") + + if [ -n "$BRANCH_BASE" ]; then + if git merge-base --is-ancestor "$BRANCH_BASE" "$TAG_COMMIT" 2>/dev/null; then + DISTANCE=$(git rev-list --count "$TAG_COMMIT..origin/$REMOTE_BRANCH") + echo "Branch $REMOTE_BRANCH — distance from tip: $DISTANCE" >&2 + + if [ "$DISTANCE" -lt "$BEST_DISTANCE" ]; then + BEST_BRANCH=$REMOTE_BRANCH + BEST_DISTANCE=$DISTANCE + fi + fi + fi + done + + if [ "$BEST_DISTANCE" -eq $MAX_DISTANCE ]; then + echo "Distance algorithm found no match, falling back to first branch" >&2 + BEST_BRANCH=$(echo "$REMOTE_BRANCHES" | head -n 1) + fi +fi + +echo "Detected source branch: $BEST_BRANCH (distance: $BEST_DISTANCE)" >&2 +echo "$BEST_BRANCH" diff --git a/.github/actions/release-notification/test/detect-branch.bats b/.github/actions/release-notification/test/detect-branch.bats new file mode 100644 index 0000000..160f070 --- /dev/null +++ b/.github/actions/release-notification/test/detect-branch.bats @@ -0,0 +1,238 @@ +#!/usr/bin/env bats +# Tests for detect-branch.sh +# +# Each test creates an isolated git repo with a controlled branch/tag topology, +# then runs detect-branch.sh and asserts the output. + +SCRIPT="$BATS_TEST_DIRNAME/../detect-branch.sh" + +setup() { + TEST_REPO=$(mktemp -d) + git -C "$TEST_REPO" init --bare -b main remote.git >/dev/null 2>&1 + git clone "$TEST_REPO/remote.git" "$TEST_REPO/local" >/dev/null 2>&1 + cd "$TEST_REPO/local" + git config user.email "test@test.com" + git config user.name "Test" +} + +teardown() { + rm -rf "$TEST_REPO" +} + +make_commit() { + local msg="${1:-commit}" + echo "$msg" >> file.txt + git add file.txt + git commit -m "$msg" >/dev/null 2>&1 + git rev-parse HEAD +} + +push_all() { + git push origin --all >/dev/null 2>&1 + git push origin --tags >/dev/null 2>&1 +} + +# Helper: run the script capturing only stdout (stderr goes to debug log) +run_script() { + run bash -c "RELEASE_VERSION='$1' ${2:+DEFAULT_BRANCH='$2'} '$SCRIPT' 2>/dev/null" +} + +# --- Tests --- + +@test "tag on main returns main" { + make_commit "initial" + make_commit "second" + git tag v1.0.0 + push_all + + run_script v1.0.0 + [ "$status" -eq 0 ] + [ "$output" = "main" ] +} + +@test "tag on release branch returns that branch" { + make_commit "initial" + push_all + + git checkout -b release/v1.1 + make_commit "release work" + git tag v1.1.0 + push_all + + git checkout main + make_commit "main continues" + push_all + + run_script v1.1.0 + [ "$status" -eq 0 ] + [ "$output" = "release/v1.1" ] +} + +@test "tag on branch with extra commits still picks closest branch" { + make_commit "initial" + push_all + + git checkout -b release/v2.0 + make_commit "rel commit 1" + git tag v2.0.0 + make_commit "rel commit 2" + push_all + + git checkout main + make_commit "main work" + push_all + + run_script v2.0.0 + [ "$status" -eq 0 ] + [ "$output" = "release/v2.0" ] +} + +@test "picks branch with smallest distance when tag is on multiple branches" { + make_commit "initial" + push_all + + # Branch A: tag + 3 more commits after tag + git checkout -b branch-a + make_commit "a1" + git tag v3.0.0 + make_commit "a2" + make_commit "a3" + make_commit "a4" + push_all + + # Branch B: fork from tag, only 1 extra commit + git checkout v3.0.0 + git checkout -b branch-b + make_commit "b1" + push_all + + git checkout main + make_commit "main work" + push_all + + run_script v3.0.0 + [ "$status" -eq 0 ] + # branch-a distance=3, branch-b distance=1 → branch-b wins + [ "$output" = "branch-b" ] +} + +@test "defaults to main when no branches contain the tag" { + make_commit "initial" + push_all + + # Orphan branch — push only the tag, not the branch ref + git checkout --orphan orphan-branch + git rm -rf . >/dev/null 2>&1 + echo "orphan" > file.txt + git add file.txt + git commit -m "orphan" >/dev/null 2>&1 + git tag v0.0.1 + git push origin v0.0.1 >/dev/null 2>&1 + + run_script v0.0.1 + [ "$status" -eq 0 ] + [ "$output" = "main" ] +} + +@test "respects DEFAULT_BRANCH override" { + make_commit "initial" + push_all + + git checkout --orphan orphan-branch + git rm -rf . >/dev/null 2>&1 + echo "orphan" > file.txt + git add file.txt + git commit -m "orphan" >/dev/null 2>&1 + git tag v0.0.2 + git push origin v0.0.2 >/dev/null 2>&1 + + run_script v0.0.2 develop + [ "$status" -eq 0 ] + [ "$output" = "develop" ] +} + +@test "fails when RELEASE_VERSION is not set" { + make_commit "initial" + push_all + + run bash -c "'$SCRIPT' 2>/dev/null" + [ "$status" -ne 0 ] +} + +@test "origin/HEAD symref is ignored" { + make_commit "initial" + push_all + + git checkout -b release/v5.0 + make_commit "release work" + git tag v5.0.0 + push_all + + # Create origin/HEAD pointing at main — simulates what GitHub remotes do + git symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main + + git checkout main + make_commit "main continues" + push_all + + run_script v5.0.0 + [ "$status" -eq 0 ] + [ "$output" = "release/v5.0" ] +} + +@test "fails when tag does not exist" { + make_commit "initial" + push_all + + run_script v99.99.99 + [ "$status" -ne 0 ] +} + +@test "skips branch whose merge-base is not ancestor of tag" { + # Topology: + # main: A --- B --- D --- E + # release/v6: \--- C (tag v6.0.0) + # late-branch: \--- merge(C) --- F + # + # late-branch contains the tag commit via merge, but merge-base(main, late) + # is D which is NOT an ancestor of C. The is-ancestor guard should skip it. + + make_commit "A" + make_commit "B" + push_all + + git checkout -b release/v6.0 + make_commit "C" + git tag v6.0.0 + push_all + + git checkout main + make_commit "D" + make_commit "E" + push_all + + git checkout -b late-branch + git merge v6.0.0 -m "merge release tag" -X ours >/dev/null + make_commit "F" + push_all + + run_script v6.0.0 + [ "$status" -eq 0 ] + # release/v6.0 should win; late-branch should be skipped by the is-ancestor guard + [ "$output" = "release/v6.0" ] +} + +@test "tag at branch point shared by main and release picks main (distance 0)" { + make_commit "initial" + make_commit "second" + git tag v4.0.0 + push_all + + git checkout -b release/v4.0 + make_commit "release work" + push_all + + run_script v4.0.0 + [ "$status" -eq 0 ] + [ "$output" = "main" ] +} diff --git a/.github/workflows/notify-release.yaml b/.github/workflows/notify-release.yaml new file mode 100644 index 0000000..5359f71 --- /dev/null +++ b/.github/workflows/notify-release.yaml @@ -0,0 +1,55 @@ +name: Notify release + +on: + workflow_call: + inputs: + release_version: + description: 'The release version tag (e.g. v1.2.3)' + required: true + type: string + previous_tag: + description: 'The previous tag for changelog comparison' + required: true + type: string + target_repo: + description: 'Target repository (e.g. loft-sh/vcluster)' + required: true + type: string + product: + description: 'Product name (e.g. vCluster, vCluster Platform)' + required: true + type: string + ref: + description: 'The git ref to checkout (defaults to github.ref)' + required: false + type: string + default: '' + secrets: + SLACK_WEBHOOK_URL_PRODUCT_RELEASES: + description: 'Slack incoming webhook URL for #product-releases' + required: true + +permissions: + contents: read + +jobs: + notify_release: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: ${{ inputs.target_repo }} + ref: ${{ inputs.ref || github.ref }} + fetch-depth: 0 + fetch-tags: true + persist-credentials: false + - name: Notify #product-releases Slack channel + uses: loft-sh/github-actions/.github/actions/release-notification@378c7a375539f31f0b67565e8d959d8ea222ae3f # release-notification/v1 + with: + version: ${{ inputs.release_version }} + previous_tag: ${{ inputs.previous_tag }} + changes: "See changelog link below" + target_repo: ${{ inputs.target_repo }} + product: ${{ inputs.product }} + webhook_url: ${{ secrets.SLACK_WEBHOOK_URL_PRODUCT_RELEASES }} # zizmor: ignore[secrets-outside-env] -- webhook URL passed via workflow_call diff --git a/.github/workflows/test-release-notification.yaml b/.github/workflows/test-release-notification.yaml new file mode 100644 index 0000000..d01064f --- /dev/null +++ b/.github/workflows/test-release-notification.yaml @@ -0,0 +1,27 @@ +name: Test release-notification + +on: + push: + branches: [main] + paths: ['.github/actions/release-notification/**'] + pull_request: + paths: ['.github/actions/release-notification/**'] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: bats-core/bats-action@5b1e60c2ee94cb1b44a616ea4b1f466f9d6e38ef # 4.0.0 + with: + support-install: false + assert-install: false + detik-install: false + file-install: false + - name: Run detect-branch tests + run: bats .github/actions/release-notification/test/detect-branch.bats diff --git a/.gitignore b/.gitignore index 9fe868e..84dc8f8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +node_modules/ + # override global gitignore to track claude config !.claude/ !CLAUDE.md diff --git a/Makefile b/Makefile index 81bc093..e5868fd 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,11 @@ -.PHONY: test test-semver-validation test-linear-pr-commenter lint help +.PHONY: test test-semver-validation test-linear-pr-commenter test-release-notification lint help ACTIONS_DIR := .github/actions help: ## show this help @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " %-30s %s\n", $$1, $$2}' -test: test-semver-validation test-linear-pr-commenter ## run all action tests +test: test-semver-validation test-linear-pr-commenter test-release-notification ## run all action tests test-semver-validation: ## run semver-validation unit tests cd $(ACTIONS_DIR)/semver-validation && npm ci --silent && NODE_OPTIONS=--experimental-vm-modules npx jest --ci --coverage --watchAll=false @@ -13,6 +13,9 @@ test-semver-validation: ## run semver-validation unit tests test-linear-pr-commenter: ## run linear-pr-commenter unit tests cd $(ACTIONS_DIR)/linear-pr-commenter/src && go test -v ./... +test-release-notification: ## run release-notification detect-branch tests + bats $(ACTIONS_DIR)/release-notification/test/detect-branch.bats + lint: ## run actionlint and zizmor on workflows actionlint .github/workflows/*.yaml zizmor .github/ diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index 958f901..12401d7 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -54,7 +54,20 @@ requirements. The table below captures current status and guidance: - Tests must not require real API tokens or network access. - YAML-only composites are validated by actionlint + zizmor (no unit tests needed for now). - Testing frameworks: **vitest** for TypeScript, **uv + pytest** for Python, - standard `go test` for Go. + standard `go test` for Go, **[bats](https://github.com/bats-core/bats-core)** + for Bash scripts. CI uses + [`bats-core/bats-action`](https://github.com/bats-core/bats-action); locally + install bats with your package manager: + ```bash + # macOS + brew install bats-core + + # Ubuntu / Debian + sudo apt-get install bats + + # Arch Linux + sudo pacman -S bats + ``` ### Integration tests