Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions .github/actions/release-notification/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,25 @@ 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

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:
Expand Down Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions .github/actions/release-notification/detect-branch.sh
Original file line number Diff line number Diff line change
@@ -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"
238 changes: 238 additions & 0 deletions .github/actions/release-notification/test/detect-branch.bats
Original file line number Diff line number Diff line change
@@ -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" ]
}
55 changes: 55 additions & 0 deletions .github/workflows/notify-release.yaml
Original file line number Diff line number Diff line change
@@ -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
Loading