Skip to content
Merged
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
17 changes: 9 additions & 8 deletions .github/workflows/version-bump-notify.yml
Original file line number Diff line number Diff line change
@@ -1,27 +1,28 @@
# Reusable workflow: posts a Slack alert when a plugin's composer.json `version` is bumped
# (an early "a release is being prepared" signal). Only a strict increase notifies — no-op
# edits, downgrades and reverts stay silent. Add a caller to the plugin repo, e.g.:
# Reusable workflow: posts a Slack alert when an extension's version is bumped — an early
# "a release is being prepared" signal. Works for plugins (composer.json) and apps
# (manifest.xml). Only a strict increase notifies — no-op edits, downgrades and reverts stay
# silent. Add a caller to the extension repo, using the version file it ships:
#
# name: Release prep alert
# on:
# pull_request:
# types: [opened, reopened, synchronize]
# branches: [main, master, trunk] # your release branch(es)
# paths: ['composer.json']
# paths: ['composer.json'] # apps: ['manifest.xml']
# push:
# branches: [main, master, trunk]
# paths: ['composer.json']
# paths: ['composer.json'] # apps: ['manifest.xml']
# jobs:
# notify:
# uses: shopware/github-actions/.github/workflows/version-bump-notify.yml@main
# secrets:
# SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} # secret stays in the plugin repo
# SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} # secret stays in the extension repo
name: Version Bump Notify
on:
workflow_call:
inputs:
path:
description: "Path to the extension root containing composer.json"
description: "Path to the extension root containing composer.json (plugin) or manifest.xml (app)"
required: false
type: string
default: "."
Expand Down Expand Up @@ -84,7 +85,7 @@ jobs:
type: "section",
text: {
type: "mrkdwn",
text: "*\($product)* is preparing a release\n`composer.json` version bumped *\($prev)* → *\($cur)*"
text: "*\($product)* is preparing a release\nversion bumped *\($prev)* → *\($cur)*"
}
} | if $url != "" then .accessory = {
type: "button",
Expand Down
8 changes: 4 additions & 4 deletions version-bump/README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# Version Bump

Detects whether the root `version` field in `composer.json` strictly increased between two git references.
Detects whether an extension's `version` strictly increased between two git references. Works for plugins (`composer.json`) and apps (`manifest.xml`).

## What it does

1. Reads the `version` field from `composer.json` at `old-ref` and at `new-ref`
1. Reads the version at `old-ref` and at `new-ref` — from `composer.json` (`.version`) for plugins, or from `manifest.xml` (`/manifest/meta/version`) for apps. `composer.json` takes precedence when both are present.
2. Reports `bumped: true` only when the version strictly increased (version-aware compare, handles 3- and 4-segment versions and treats a pre-release like `1.0.0-rc1` as lower than the final `1.0.0`)
3. Ignores no-op edits, downgrades/reverts, and cases where the version cannot be read (e.g. a newly created branch)

Expand All @@ -14,7 +14,7 @@ Detects whether the root `version` field in `composer.json` strictly increased b
|-------|-------------|----------|---------|
| `old-ref` | The git ref/sha for the previous state (e.g. the PR base sha) | Yes | - |
| `new-ref` | The git ref/sha for the new state (e.g. the PR head sha) | Yes | - |
| `path` | Path to the extension root containing `composer.json` | No | `.` |
| `path` | Path to the extension root containing `composer.json` (plugin) or `manifest.xml` (app) | No | `.` |

## Outputs

Expand All @@ -26,7 +26,7 @@ Detects whether the root `version` field in `composer.json` strictly increased b

> **Note:** the caller must check out the repository with `fetch-depth: 0` so both refs are available to `git show`.
>
> **Note:** this action requires `jq` and GNU `sort` (for `sort -V`) to be available on the runner.
> **Note:** this action requires `jq` and GNU `sort` (for `sort -V`) on the runner; apps additionally need `python3` (preinstalled on GitHub-hosted runners) to read `manifest.xml`.

## Usage

Expand Down
6 changes: 3 additions & 3 deletions version-bump/action.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: version-bump
description: "Detects whether the root version field in composer.json strictly increased between two git refs."
description: "Detects whether an extension's version strictly increased between two git refs (composer.json for plugins, manifest.xml for apps)."
author: "shopware AG"
branding:
color: "yellow"
Expand All @@ -13,13 +13,13 @@ inputs:
description: The git ref/sha for the new state (e.g. the PR head sha)
required: true
path:
description: Path to the extension root containing composer.json
description: Path to the extension root containing composer.json (plugin) or manifest.xml (app)
required: false
default: "."

outputs:
bumped:
description: "'true' if the composer.json version strictly increased, otherwise 'false'"
description: "'true' if the extension version strictly increased, otherwise 'false'"
value: ${{ steps.detect.outputs.bumped }}
previous-version:
description: The version at old-ref (empty if not found)
Expand Down
36 changes: 28 additions & 8 deletions version-bump/version-bump.bash
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
#!/usr/bin/env bash

# Detects whether the root `version` field in composer.json strictly increased
# between two git references.
# Detects whether the extension version strictly increased between two git references.
# Plugins carry the version in composer.json, apps in manifest.xml (<manifest><meta><version>).
#
# Global environment variables required:
# OLD_REF - git ref/sha for the previous state (e.g. the PR base sha)
# NEW_REF - git ref/sha for the new state (e.g. the PR head sha)
# EXT_PATH - path to the extension root containing composer.json (default: .)
# EXT_PATH - path to the extension root containing composer.json or manifest.xml (default: .)

set -euo pipefail

EXT_PATH="${EXT_PATH:-.}"
if [[ "${EXT_PATH}" == "." || -z "${EXT_PATH}" ]]; then
COMPOSER_PATH="composer.json"
prefix=""
else
COMPOSER_PATH="${EXT_PATH%/}/composer.json"
prefix="${EXT_PATH%/}/"
fi
COMPOSER_PATH="${prefix}composer.json"
MANIFEST_PATH="${prefix}manifest.xml"

if ! command -v jq >/dev/null 2>&1; then
echo "Error: jq is required but was not found on PATH" >&2
Expand All @@ -24,11 +26,29 @@ fi

read_version() {
# Missing ref/file -> empty (no notification); a parse error propagates (fail loud).
local json
if ! json=$(git show "${1}:${COMPOSER_PATH}" 2>/dev/null); then
# composer.json (plugin) takes precedence over manifest.xml (app) when both exist.
local ref="${1}" content
if content=$(git show "${ref}:${COMPOSER_PATH}" 2>/dev/null); then
jq -r '.version // empty' <<<"${content}"
return 0
fi
jq -r '.version // empty' <<<"${json}"
if content=$(git show "${ref}:${MANIFEST_PATH}" 2>/dev/null); then
if ! command -v python3 >/dev/null 2>&1; then
echo "Error: python3 is required to read manifest.xml but was not found on PATH" >&2
exit 1
fi
# Apps keep the version in <manifest><meta><version>; there is no default namespace,
# so a plain element path works. A missing node prints empty (no notification); a
# malformed manifest raises and propagates (fail loud), matching the composer.json path.
printf '%s' "${content}" | python3 -c '
import sys, xml.etree.ElementTree as ET
root = ET.fromstring(sys.stdin.read())
el = root.find("./meta/version")
sys.stdout.write((el.text or "").strip() if el is not None else "")
'
return 0
fi
return 0
}

OLD_VERSION=$(read_version "${OLD_REF}")
Expand Down
121 changes: 83 additions & 38 deletions version-bump/version-bump.test.bash
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,21 @@ TMP_DIR=$(mktemp -d)

trap 'rm -rf "${TMP_DIR}"' EXIT

# Stub `git show <ref>:<path>` -> composer.json fixture for that ref (STUB_OLD/STUB_NEW; empty = missing).
# Stub `git show <ref>:<path>` -> fixture keyed on both ref and file:
# composer.json fixtures come from STUB_OLD/STUB_NEW, manifest.xml from STUB_OLD_XML/STUB_NEW_XML
# (empty = missing file at that ref -> exit 128, so the script falls through / reports empty).
cat > "${TMP_DIR}/git" <<'EOF'
#!/usr/bin/env bash
if [[ "${1}" == "show" ]]; then
ref="${2%%:*}"
case "${ref}" in
old) content="${STUB_OLD-}" ;;
new) content="${STUB_NEW-}" ;;
*) content="" ;;
spec="${2}"
ref="${spec%%:*}"
file="${spec#*:}"
content=""
case "${file}" in
*composer.json)
case "${ref}" in old) content="${STUB_OLD-}" ;; new) content="${STUB_NEW-}" ;; esac ;;
*manifest.xml)
case "${ref}" in old) content="${STUB_OLD_XML-}" ;; new) content="${STUB_NEW_XML-}" ;; esac ;;
esac
if [[ -z "${content}" ]]; then
exit 128
Expand All @@ -26,54 +32,64 @@ exit 1
EOF
chmod +x "${TMP_DIR}/git"

run_case() {
local name="${1}"
local old_json="${2}"
local new_json="${3}"
local expected_bumped="${4}"
local out
out=$(mktemp)

local output code
set +e
output=$(PATH="${TMP_DIR}:${PATH}" \
STUB_OLD="${old_json}" \
STUB_NEW="${new_json}" \
OLD_REF=old NEW_REF=new EXT_PATH=. \
GITHUB_OUTPUT="${out}" \
bash "${SCRIPT_DIR}/version-bump.bash" 2>&1)
code=$?
set -e
# Asserts the script wrote `bumped=<expected>` to GITHUB_OUTPUT. Args: name code output out expected.
_assert_bumped() {
local name="${1}" code="${2}" output="${3}" out="${4}" expected="${5}"

if [[ "${code}" -ne 0 ]]; then
echo "FAIL ${name}: version-bump.bash exited with ${code}"
echo "${output}"
cat "${out}"
rm -f "${out}"
exit 1
echo "${output}"; cat "${out}"; rm -f "${out}"; exit 1
fi

local got
got=$(grep -m1 '^bumped=' "${out}" | cut -d= -f2 || true)
if [[ -z "${got}" ]]; then
echo "FAIL ${name}: did not write bumped=... to GITHUB_OUTPUT"
echo "${output}"
cat "${out}"
rm -f "${out}"
exit 1
echo "${output}"; cat "${out}"; rm -f "${out}"; exit 1
fi
if [[ "${got}" != "${expected_bumped}" ]]; then
echo "FAIL ${name}: expected bumped=${expected_bumped}, got '${got}'"
echo "${output}"
cat "${out}"
rm -f "${out}"
exit 1
if [[ "${got}" != "${expected}" ]]; then
echo "FAIL ${name}: expected bumped=${expected}, got '${got}'"
echo "${output}"; cat "${out}"; rm -f "${out}"; exit 1
fi

echo "ok - ${name} (bumped=${got})"
rm -f "${out}"
}

# Plugin case: composer.json fixtures at old/new.
run_case() {
local name="${1}" old_json="${2}" new_json="${3}" expected_bumped="${4}"
local out; out=$(mktemp)
local output code
set +e
output=$(PATH="${TMP_DIR}:${PATH}" \
STUB_OLD="${old_json}" \
STUB_NEW="${new_json}" \
OLD_REF=old NEW_REF=new EXT_PATH=. \
GITHUB_OUTPUT="${out}" \
bash "${SCRIPT_DIR}/version-bump.bash" 2>&1)
code=$?
set -e
_assert_bumped "${name}" "${code}" "${output}" "${out}" "${expected_bumped}"
}

# App case: manifest.xml fixtures at old/new (no composer.json present -> app path taken).
run_case_app() {
local name="${1}" old_xml="${2}" new_xml="${3}" expected_bumped="${4}"
local out; out=$(mktemp)
local output code
set +e
output=$(PATH="${TMP_DIR}:${PATH}" \
STUB_OLD_XML="${old_xml}" \
STUB_NEW_XML="${new_xml}" \
OLD_REF=old NEW_REF=new EXT_PATH=. \
GITHUB_OUTPUT="${out}" \
bash "${SCRIPT_DIR}/version-bump.bash" 2>&1)
code=$?
set -e
_assert_bumped "${name}" "${code}" "${output}" "${out}" "${expected_bumped}"
}

run_case "minor bump" '{"version":"5.3.0"}' '{"version":"5.4.0"}' true
run_case "patch bump" '{"version":"5.3.0"}' '{"version":"5.3.1"}' true
run_case "no change" '{"version":"5.3.0"}' '{"version":"5.3.0"}' false
Expand All @@ -90,4 +106,33 @@ run_case "rc to newer rc" '{"version":"1.0.0-rc1"}' '{"version":"1.0.0-rc
run_case "rc to older rc" '{"version":"1.0.0-rc2"}' '{"version":"1.0.0-rc1"}' false
run_case "final to next rc" '{"version":"1.0.0"}' '{"version":"1.1.0-rc1"}' true

# --- app (manifest.xml) cases ---
manifest() { printf '<manifest><meta><name>swag/foo</name><version>%s</version></meta></manifest>' "${1}"; }
manifest_nover='<manifest><meta><name>swag/foo</name></meta></manifest>'

run_case_app "app minor bump" "$(manifest 1.0.0)" "$(manifest 1.1.0)" true
run_case_app "app patch bump" "$(manifest 4.0.0)" "$(manifest 4.0.1)" true
run_case_app "app no change" "$(manifest 3.1.0)" "$(manifest 3.1.0)" false
run_case_app "app downgrade" "$(manifest 4.0.0)" "$(manifest 3.9.9)" false
run_case_app "app version missing" "${manifest_nover}" "$(manifest 1.0.0)" false
run_case_app "app new manifest" '' "$(manifest 1.0.0)" false

# real manifests carry xmlns:xsi / xsi:noNamespaceSchemaLocation on the root — must not break the XPath
run_case_app "app with xsi namespace attrs" \
'<manifest xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="x.xsd"><meta><version>1.0.0</version></meta></manifest>' \
'<manifest xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="x.xsd"><meta><version>1.1.0</version></meta></manifest>' \
true

# composer.json takes precedence when both files exist: no composer change -> false, despite a manifest bump
out=$(mktemp)
set +e
output=$(PATH="${TMP_DIR}:${PATH}" \
STUB_OLD='{"version":"5.3.0"}' STUB_NEW='{"version":"5.3.0"}' \
STUB_OLD_XML="$(manifest 1.0.0)" STUB_NEW_XML="$(manifest 2.0.0)" \
OLD_REF=old NEW_REF=new EXT_PATH=. GITHUB_OUTPUT="${out}" \
bash "${SCRIPT_DIR}/version-bump.bash" 2>&1)
code=$?
set -e
_assert_bumped "composer.json precedence over manifest.xml" "${code}" "${output}" "${out}" false

echo "All version-bump tests passed."
Loading