ソースを参照

Merge branch 'goal/bump-hook' of Tilo15/usm into master

Billy Barrow 12 時間 前
親
コミット
e98ca05efe

+ 22 - 0
PACKAGING_GUIDE.md

@@ -316,6 +316,28 @@ usm manifest bump patch --no-rebuild-dependants
 
 Only set `rebuildDependants` when the update includes C API changes that break downstream compilation. Don't set it for bug fixes or internal changes.
 
+### The bump hook
+
+When your package's version lives in more places than MANIFEST.usm — a `meson.build` `project(version:)` line, a version string in source code — declare a `bump` exec so `usm manifest bump` updates them for you:
+
+```json
+"execs": {
+  "build": "usm-scripts/build.sh",
+  "bump": "usm-scripts/bump.sh"
+}
+```
+
+```bash
+#!/bin/bash
+# usm-scripts/bump.sh — receives <old-version> <new-version>
+set -e
+sed -i "s/version: '$1'/version: '$2'/" meson.build
+```
+
+The hook runs from the source directory (the one holding MANIFEST.usm), invoked as `<path> <old-version> <new-version>`, and only when the version actually changes — a bump to the version the manifest already carries, a flags-only bump (`--rebuild-dependants`), or a bare `--git` release runs nothing. It runs *before* MANIFEST.usm is rewritten: a non-zero exit (or a missing script) aborts the whole bump with exit code 251 and leaves the tree untouched, so a broken hook cannot half-update your tree.
+
+With `--git`, the release commit stages the whole working tree rather than just MANIFEST.usm — what the hook edited cannot be known ahead of time — so its changes land in the `Version <x.y.z>` commit and the tag.
+
 ---
 
 ## Testing your package

+ 3 - 1
SPECIFICATION.md

@@ -72,6 +72,7 @@ The USM manifest file is contains a JSON object with the following properties:
     - `remove` (string, optional): Path to the removal script relative to the package root.
     - `postInstall` (string, optional): Path to the post-install script relative to the package root.
     - `acquire` (string, optional): Path to the acquire script relative to the package root.
+    - `bump` (string, optional): Path to the version-bump script relative to the package root.
 - `md` (string, optional): Path to a markdown file containing a detailed description of the package, relative to the package root.
 - `url` (string, optional): URL to the project's website or repository.
 - `screenshots` (string array, optional): Array of paths to screenshot files, relative to the package root.
@@ -189,4 +190,5 @@ All executable scripts except for `remove` and `postInstall` are run from the so
 - `install` when specified will install the files into the install directory, in the same structure that it would if that directory were the filesystem root. The build directory is passed to this exec as the first argument, the install directory is passed as the second argument, and the install type ("fresh", "upgrade", "downgrade") is passed as the third argument.
 - `remove` is called just before USM deletes the resources of a package on uninstallation. The remove type ("final", "upgrade", "downgrade") is passed as the first argument.
 - `postInstall` is called after USM has installed the packages resources to the system. The build directory is passed to this exec as the first argument, and the install type ("fresh", "upgrade", "downgrade") is passed as the second argument.
-- `acquire` is used to download the authoritative sources. It is usually used by package maintainers when a project does not natively have `MANIFEST.usm` support to enable putting many USM manifests into a repository, and using tooling to download and extract the sources to make `.usmc` (USM Complete) packages. This script usually consists of either a `git clone` command, or a `wget` call followed by an archive extraction command. No parameters are passed to this exec.
+- `acquire` is used to download the authoritative sources. It is usually used by package maintainers when a project does not natively have `MANIFEST.usm` support to enable putting many USM manifests into a repository, and using tooling to download and extract the sources to make `.usmc` (USM Complete) packages. This script usually consists of either a `git clone` command, or a `wget` call followed by an archive extraction command. No parameters are passed to this exec.
+- `bump` is a maintainer-side hook `usm manifest bump` runs when the version actually changes — not on flags-only bumps, bare `--git` releases, or a bump to the version the manifest already carries. It runs from the source directory (the directory holding MANIFEST.usm), invoked as `<path> <old-version> <new-version>`, before MANIFEST.usm is rewritten, and is meant for updating version numbers that live outside the manifest (source code, `meson.build`, …). A non-zero exit (or a failure to spawn) aborts the whole bump, leaving the tree untouched. With `--git`, whatever the hook edited is staged into the release commit alongside the manifest.

+ 312 - 0
scripts/bump-e2e.sh

@@ -0,0 +1,312 @@
+#!/bin/bash
+#
+# execs.bump end-to-end verification — the manifest version-bump hook.
+#
+# Builds USM, then drives scratch fixture packages through the whole
+# bump-hook story. Every fixture lives OUTSIDE the checkout: the bump
+# verb rewrites the CWD's MANIFEST.usm and creates git tags, so it must
+# never run inside the USM source tree.
+#
+# Assertions (each printed PASS/FAIL, exit non-zero on any failure):
+#   a. a version bump runs the hook as `<path> <old> <new>` (the hook
+#      records its arguments), MANIFEST.usm's version is rewritten, and
+#      the version line the hook rewrites in a fixture meson.build is
+#      updated
+#   b. a failing hook (non-zero exit) aborts the bump with exit 251,
+#      leaving MANIFEST.usm and the fixture meson.build untouched
+#   c. an explicit bump to the version the manifest already carries
+#      does NOT run the hook
+#   d. a flags-only bump (--rebuild-dependants, version unchanged) does
+#      NOT run the hook, and the flag IS written to the manifest
+#   e. a bare --git release (no version argument, no version change)
+#      does NOT run the hook, and still commits + tags
+#   f. --git with a hook that edits a file: the release commit contains
+#      both MANIFEST.usm and the hook-edited file, and tag v<version>
+#      exists
+#   g. a data-package manifest (flags: ["dataPackage"], acquire + bump
+#      execs) may declare the hook; it runs and the version bumps
+#
+# Environment quirks this script works around (keep in mind when
+# running it elsewhere):
+#   * The freshly built usm binary links libusm-0.1.so by SONAME, and
+#     the host carries an older /opt/usm/lib/libusm-0.1.so — every
+#     invocation needs LD_LIBRARY_PATH=<build>/lib or it silently
+#     talks to the stale library.
+#   * meson only finds the Invercargill libraries through
+#     PKG_CONFIG_PATH=/opt/usm/lib/pkgconfig.
+#   * The hook is spawned through GLib, which PATH-searches a path
+#     with no slash in it: a manifest declaring "bump": "bump.sh"
+#     fails to spawn even with the file executable in the CWD. Real
+#     manifests place scripts under usm-scripts/, whose slash makes
+#     the path CWD-relative — the fixtures do the same.
+#   * usm manifest bump --git commits through plain git: without a
+#     configured identity it dies with "unable to auto-detect email
+#     address". The GIT_AUTHOR_*/GIT_COMMITTER_* exports below give
+#     both usm's internal commits and ours one.
+#
+# Usage: scripts/bump-e2e.sh [build-dir]
+#   build-dir defaults to a fresh temp directory; pass an existing meson
+#   build directory to skip the (slow) compile.
+
+set -e
+set -u
+set -o pipefail
+
+SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
+SOURCE_DIR=$(cd -- "$SCRIPT_DIR/.." && pwd)
+
+WORK=$(mktemp -d /tmp/usm-bump-e2e.XXXXXX)
+trap 'rm -rf "$WORK"' EXIT
+
+PASS_COUNT=0
+FAIL_COUNT=0
+
+report() {
+    if [ "$1" = 0 ]; then
+        printf 'PASS  %s\n' "$2"
+        PASS_COUNT=$((PASS_COUNT + 1))
+    else
+        printf 'FAIL  %s\n' "$2"
+        FAIL_COUNT=$((FAIL_COUNT + 1))
+    fi
+}
+
+# Asserts <description> against <command...>: the command succeeding is
+# a PASS, failing is a FAIL (set -e must not abort on a failed
+# assertion — the failure is the observation, reported like any other).
+try() {
+    local description=$1
+    shift
+    if "$@" >/dev/null 2>&1; then
+        report 0 "$description"
+    else
+        report 1 "$description"
+    fi
+}
+
+# ---------------------------------------------------------------------------
+# 0. Build
+# ---------------------------------------------------------------------------
+
+BUILD_DIR=${1:-}
+
+if [ -z "$BUILD_DIR" ]; then
+    BUILD_DIR="$WORK/usm-build"
+    printf '== Building USM into %s\n' "$BUILD_DIR"
+    PKG_CONFIG_PATH=/opt/usm/lib/pkgconfig meson setup "$BUILD_DIR" "$SOURCE_DIR/src" >/dev/null 2>&1 \
+        || { echo "meson setup failed"; exit 1; }
+    PKG_CONFIG_PATH=/opt/usm/lib/pkgconfig ninja -C "$BUILD_DIR" >/dev/null 2>&1 \
+        || { echo "ninja failed"; exit 1; }
+fi
+
+USM_BIN="$BUILD_DIR/cli/usm"
+[ -x "$USM_BIN" ] || { echo "no usm binary at $USM_BIN"; exit 1; }
+
+# The stale-host-library workaround described in the header
+export LD_LIBRARY_PATH="$BUILD_DIR/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
+
+# One identity for our git calls and usm's internal release commits
+export GIT_AUTHOR_NAME="USM Bump E2E"
+export GIT_AUTHOR_EMAIL="bump-e2e@example.invalid"
+export GIT_COMMITTER_NAME="$GIT_AUTHOR_NAME"
+export GIT_COMMITTER_EMAIL="$GIT_AUTHOR_EMAIL"
+
+# ---------------------------------------------------------------------------
+# 1. Fixtures
+# ---------------------------------------------------------------------------
+
+# Runs the built usm against one fixture directory; the exit code lands
+# in BUMP_EXIT (never aborts the script — a non-zero bump IS the test).
+BUMP_EXIT=0
+bump() {
+    BUMP_EXIT=0
+    (cd "$1" && "$USM_BIN" manifest bump "${@:2}") || BUMP_EXIT=$?
+}
+
+# A code-package fixture: a manifest declaring the bump hook, the hook
+# itself (records "<old> <new>", rewrites the meson.build version line),
+# a meson.build carrying the version, and the provided resource.
+make_fixture() {
+    # make_fixture <dir> <name> <version> <hook>  (hook: record | fail)
+    local dir=$1 name=$2 version=$3 hook=$4
+    rm -rf "$dir"
+    mkdir -p "$dir/usm-scripts"
+    cat > "$dir/MANIFEST.usm" <<EOF
+{
+ "name": "$name",
+ "version": "$version",
+ "summary": "execs.bump end-to-end fixture",
+ "licences": [],
+ "flags": [],
+ "provides": { "res:$name/hello.txt": "source:hello.txt" },
+ "depends": { "runtime": [], "build": [], "manage": ["bin:bash"] },
+ "execs": { "bump": "usm-scripts/bump.sh" }
+}
+EOF
+    printf 'fixture payload\n' > "$dir/hello.txt"
+    printf "project('%s', 'c', version: '%s')\n" "$name" "$version" > "$dir/meson.build"
+    if [ "$hook" = fail ]; then
+        cat > "$dir/usm-scripts/bump.sh" <<'EOF'
+#!/bin/bash
+# Records its arguments, then fails — the bump must abort.
+echo "$1 $2" > hook-args.txt
+echo "deliberate bump-hook failure" >&2
+exit 1
+EOF
+    else
+        cat > "$dir/usm-scripts/bump.sh" <<'EOF'
+#!/bin/bash
+# Records "<old> <new>", then rewrites the meson.build project version.
+echo "$1 $2" > hook-args.txt
+sed -i "s/version: '$1'/version: '$2'/" meson.build
+EOF
+    fi
+    chmod +x "$dir/usm-scripts/bump.sh"
+}
+
+# Wraps a fixture into a throwaway git repository with everything
+# committed, so --git releases have something to tag.
+git_init_fixture() {
+    (cd "$1" && git init -q && git add -A && git commit -qm "fixture base")
+}
+
+FX_A="$WORK/fixture-a"
+FX_B="$WORK/fixture-b"
+FX_C="$WORK/fixture-c"
+FX_E="$WORK/fixture-e"
+FX_F="$WORK/fixture-f"
+FX_G="$WORK/fixture-g"
+
+make_fixture "$FX_A" bump-e2e-demo 1.0.0 record
+make_fixture "$FX_B" bump-e2e-fail 1.0.0 fail
+make_fixture "$FX_C" bump-e2e-same 1.0.1 record
+make_fixture "$FX_E" bump-e2e-release 1.0.1 record
+make_fixture "$FX_F" bump-e2e-git 1.0.1 record
+git_init_fixture "$FX_E"
+git_init_fixture "$FX_F"
+
+# The data-package fixture: the DATA_MANIFEST shape (acquire + bump are
+# the maintainer-side execs a data package may declare) with a VERSION
+# file in place of meson.build for the hook to rewrite.
+mkdir -p "$FX_G/usm-scripts"
+cat > "$FX_G/MANIFEST.usm" <<'EOF'
+{
+ "name": "bump-e2e-fonts",
+ "version": "1.0.0",
+ "summary": "Example fonts",
+ "licences": [],
+ "flags": ["dataPackage"],
+ "provides": { "res:bump-e2e-fonts/Example.ttf": "source:Example.ttf" },
+ "depends": { "runtime": [], "build": [], "manage": ["bin:bash"] },
+ "execs": { "acquire": "usm-scripts/acquire.sh", "bump": "usm-scripts/bump.sh" }
+}
+EOF
+printf 'stub font payload\n' > "$FX_G/Example.ttf"
+printf "data-version: '1.0.0'\n" > "$FX_G/VERSION"
+printf '#!/bin/bash\nexit 0\n' > "$FX_G/usm-scripts/acquire.sh"
+cat > "$FX_G/usm-scripts/bump.sh" <<'EOF'
+#!/bin/bash
+echo "$1 $2" > hook-args.txt
+sed -i "s/data-version: '$1'/data-version: '$2'/" VERSION
+EOF
+chmod +x "$FX_G/usm-scripts/acquire.sh" "$FX_G/usm-scripts/bump.sh"
+
+# ---------------------------------------------------------------------------
+# 2. (a) A version bump runs the hook and rewrites every version line
+# ---------------------------------------------------------------------------
+
+printf '== (a) version bump runs the hook\n'
+bump "$FX_A" 1.0.1
+try "(a) usm manifest bump 1.0.1 exits 0" test "$BUMP_EXIT" = 0
+try '(a) hook received the arguments "1.0.0 1.0.1"' \
+    test "$(cat "$FX_A/hook-args.txt" 2>/dev/null || true)" = "1.0.0 1.0.1"
+try "(a) MANIFEST.usm version rewritten to 1.0.1" \
+    grep -Eq '"version"[[:space:]]*:[[:space:]]*"1\.0\.1"' "$FX_A/MANIFEST.usm"
+try "(a) meson.build version line rewritten to 1.0.1" \
+    grep -q "version: '1.0.1'" "$FX_A/meson.build"
+
+# ---------------------------------------------------------------------------
+# 3. (b) A failing hook aborts the bump, leaving the tree untouched
+# ---------------------------------------------------------------------------
+
+printf '== (b) failing hook aborts the bump\n'
+bump "$FX_B" 1.0.1
+try "(b) bump exits 251 when the hook fails" test "$BUMP_EXIT" = 251
+try '(b) the hook did run (its arguments were recorded)' \
+    test "$(cat "$FX_B/hook-args.txt" 2>/dev/null || true)" = "1.0.0 1.0.1"
+try "(b) MANIFEST.usm untouched (still 1.0.0)" \
+    grep -Eq '"version"[[:space:]]*:[[:space:]]*"1\.0\.0"' "$FX_B/MANIFEST.usm"
+try "(b) meson.build untouched (still 1.0.0)" \
+    grep -q "version: '1.0.0'" "$FX_B/meson.build"
+
+# ---------------------------------------------------------------------------
+# 4. (c)/(d) No version change — no hook
+# ---------------------------------------------------------------------------
+
+printf '== (c) explicit same-version bump\n'
+bump "$FX_C" 1.0.1
+try "(c) bump 1.0.1 on a 1.0.1 manifest exits 0" test "$BUMP_EXIT" = 0
+try "(c) the hook did NOT run" test ! -e "$FX_C/hook-args.txt"
+try "(c) MANIFEST.usm still carries 1.0.1" \
+    grep -Eq '"version"[[:space:]]*:[[:space:]]*"1\.0\.1"' "$FX_C/MANIFEST.usm"
+
+printf '== (d) flags-only bump\n'
+# A flags-only invocation needs the version argument to match the
+# manifest (no version and no --git is a usage error), so this bump
+# changes nothing but the flags array.
+bump "$FX_C" 1.0.1 --rebuild-dependants
+try "(d) bump 1.0.1 --rebuild-dependants exits 0" test "$BUMP_EXIT" = 0
+try "(d) the hook did NOT run" test ! -e "$FX_C/hook-args.txt"
+try "(d) the rebuildDependants flag IS in the manifest" \
+    grep -q '"rebuildDependants"' "$FX_C/MANIFEST.usm"
+
+# ---------------------------------------------------------------------------
+# 5. (e) A bare --git release does not run the hook
+# ---------------------------------------------------------------------------
+
+printf '== (e) bare --git release\n'
+bump "$FX_E" --git
+try "(e) usm manifest bump --git exits 0" test "$BUMP_EXIT" = 0
+try "(e) the hook did NOT run" test ! -e "$FX_E/hook-args.txt"
+try "(e) tag v1.0.1 exists" test -n "$(git -C "$FX_E" tag -l "v1.0.1")"
+try "(e) meson.build untouched (still 1.0.1)" \
+    grep -q "version: '1.0.1'" "$FX_E/meson.build"
+
+# ---------------------------------------------------------------------------
+# 6. (f) --git with a hook: the release commit carries the hook's edits
+# ---------------------------------------------------------------------------
+
+printf '== (f) --git release after a hook run\n'
+bump "$FX_F" patch --git
+try "(f) usm manifest bump patch --git exits 0" test "$BUMP_EXIT" = 0
+try '(f) hook received the arguments "1.0.1 1.0.2"' \
+    test "$(cat "$FX_F/hook-args.txt" 2>/dev/null || true)" = "1.0.1 1.0.2"
+try "(f) tag v1.0.2 exists" test -n "$(git -C "$FX_F" tag -l "v1.0.2")"
+try "(f) the release commit contains MANIFEST.usm" \
+    test -n "$(git -C "$FX_F" show --name-only --format= "v1.0.2" -- MANIFEST.usm)"
+try "(f) the release commit contains the hook-edited meson.build" \
+    test -n "$(git -C "$FX_F" show --name-only --format= "v1.0.2" -- meson.build)"
+try "(f) the tagged meson.build carries the new version" \
+    test -n "$(git -C "$FX_F" show "v1.0.2":meson.build | grep "version: '1.0.2'")"
+
+# ---------------------------------------------------------------------------
+# 7. (g) A data package may declare and run the bump hook
+# ---------------------------------------------------------------------------
+
+printf '== (g) data-package bump hook\n'
+bump "$FX_G" 1.0.1
+try "(g) data-package bump 1.0.1 exits 0" test "$BUMP_EXIT" = 0
+try '(g) hook received the arguments "1.0.0 1.0.1"' \
+    test "$(cat "$FX_G/hook-args.txt" 2>/dev/null || true)" = "1.0.0 1.0.1"
+try "(g) MANIFEST.usm version rewritten to 1.0.1" \
+    grep -Eq '"version"[[:space:]]*:[[:space:]]*"1\.0\.1"' "$FX_G/MANIFEST.usm"
+try "(g) the hook's VERSION file rewritten to 1.0.1" \
+    grep -q "data-version: '1.0.1'" "$FX_G/VERSION"
+
+# ---------------------------------------------------------------------------
+
+printf '\n%d passed, %d failed\n' "$PASS_COUNT" "$FAIL_COUNT"
+if [ "$FAIL_COUNT" -gt 0 ]; then
+    exit 1
+fi
+exit 0

+ 25 - 0
slopdocs/structure.usm.manifest.executable-scripts.md

@@ -199,6 +199,31 @@ cd ${build_dir}
 echo "All tests passed"
 ```
 
+### bump
+Optional maintainer-side hook that `usm manifest bump` runs when the version actually changes.
+
+**Arguments**: `[old-version] [new-version]`
+- `old-version`: The manifest's version before the bump
+- `new-version`: The version being bumped to
+
+**Working Directory**: Source directory (the directory holding MANIFEST.usm)
+
+**Runs**: Only when the version actually changes — not on a bump to the version the manifest already carries, a flags-only bump (`--rebuild-dependants`), or a bare `--git` release. It runs before MANIFEST.usm is rewritten; a non-zero exit (or a failure to spawn) aborts the whole bump with exit code 251, leaving the tree untouched. With `--git`, whatever the hook edited is staged into the release commit (the whole working tree) alongside the manifest.
+
+**Use Case**: Updating version numbers that live outside the manifest — a `meson.build` `project(version:)` line, a version string in source code — so the tree stays in concordance with the manifest.
+
+**Example**:
+```bash
+#!/bin/bash
+# usm-scripts/bump.sh
+set -e
+
+old_version=$1
+new_version=$2
+
+sed -i "s/version: '${old_version}'/version: '${new_version}'/" meson.build
+```
+
 ## Script Requirements
 
 ### Permissions

+ 3 - 1
slopdocs/structure.usm.manifest.md

@@ -84,13 +84,15 @@ Executable scripts for different package lifecycle phases.
 - `postInstall` (string, optional): Post-install script path
 - `acquire` (string, optional): Source acquisition script path
 - `test` (string, optional): Test script path
+- `bump` (string, optional): Version-bump hook script path
 
 ```json
 "execs": {
   "build": "usm-scripts/build.sh",
   "install": "usm-scripts/install.sh",
   "acquire": "usm-scripts/acquire.sh",
-  "test": "usm-scripts/test.sh"
+  "test": "usm-scripts/test.sh",
+  "bump": "usm-scripts/bump.sh"
 }
 ```
 

+ 64 - 2
src/cli/ManifestBump.vala

@@ -20,6 +20,15 @@ private const string BUMP_REBUILD_DEPENDANTS_FLAG = "rebuildDependants";
  * pending so the tag captures the release state. `--push` pushes branch and
  * tag, implying `--git`.
  *
+ * When the manifest's `execs` object declares a `bump` executable and the
+ * version actually changes, the hook runs before anything is rewritten —
+ * invoked as `<bump> <old-version> <new-version>` from the current (source)
+ * directory — so it can update version strings elsewhere in the tree
+ * (source files, meson.build, …). A non-zero exit or a failure to spawn the
+ * hook aborts the whole bump, leaving the manifest and tree untouched. A
+ * `--git` release after a hook run stages the whole working tree, since what
+ * the hook edited cannot be known.
+ *
  * The manifest is parsed and rewritten as raw JSON rather than through
  * {@link Usm.Manifest}'s mapper, so a bump neither depends on manifest
  * validation nor rewrites flags the lib's flag enum has to know about.
@@ -122,7 +131,19 @@ public int manifest_bump(string[] args) {
     }
 
     bool changed = false;
+    bool hook_ran = false;
     if(new_version != null && new_version != current_version) {
+        // The bump hook runs after the new version is computed but before any
+        // file is touched, so a failing hook leaves the tree exactly as it was
+        string? bump_hook = bump_executable(root);
+        if(bump_hook != null) {
+            printerr(@"Running bump hook \"$bump_hook\" \"$current_version\" \"$new_version\"\n");
+            if(!bump_hook_run(bump_hook, current_version, new_version)) {
+                printerr(@"Error running bump exec: the bump hook \"$bump_hook\" failed, aborting the bump of \"$current_version\" → \"$new_version\"\n");
+                return 251;
+            }
+            hook_ran = true;
+        }
         try {
             root.set_native("version", new_version);
         }
@@ -208,8 +229,11 @@ public int manifest_bump(string[] args) {
     }
 
     // A version bump commits the manifest alone; a bare --git releases the
-    // current version, so every pending change belongs in the release commit
-    if(!bump_git_run(new string[] { "git", "add", spec != null ? "MANIFEST.usm" : "-A" })) {
+    // current version, so every pending change belongs in the release commit.
+    // A bump hook that ran may have edited anything in the tree, so its
+    // release commit stages the whole working tree too.
+    bool stage_everything = spec == null || hook_ran;
+    if(!bump_git_run(new string[] { "git", "add", stage_everything ? "-A" : "MANIFEST.usm" })) {
         printerr("Could not stage the release changes\n");
         return 250;
     }
@@ -249,6 +273,7 @@ public int manifest_bump(string[] args) {
 
 private int bump_usage() {
     printerr("USAGE:\n\tusm manifest bump [major|minor|patch|<version>] [--git] [--push] [--rebuild-dependants] [--no-rebuild-dependants]\n");
+    printerr("\n\tWhen the manifest's execs object declares a bump executable it runs as\n\t<bump> <old-version> <new-version> before MANIFEST.usm is rewritten; a\n\tnon-zero exit aborts the bump and leaves the tree untouched.\n");
     return 255;
 }
 
@@ -305,3 +330,40 @@ private bool bump_git_run(string[] command) {
         return false;
     }
 }
+
+
+/**
+ * Reads the manifest's `execs.bump` executable from the raw JSON tree, or
+ * null when the manifest declares none. The path is relative to the source
+ * directory, which is the current directory like acquire's.
+ */
+private string? bump_executable(InvercargillJson.JsonObject root) {
+    if(!root.has("execs")) {
+        return null;
+    }
+    var execs = root.get_object("execs");
+    if(execs == null || !execs.has("bump")) {
+        return null;
+    }
+    return execs.get_string("bump");
+}
+
+
+/**
+ * Runs the bump hook {@link hook} in the current (source) directory with the
+ * old and new version as arguments; its output flows to the terminal
+ * unchanged. Returns false — diagnostics already printed — when it fails to
+ * spawn or exits non-zero.
+ */
+private bool bump_hook_run(string hook, string old_version, string new_version) {
+    try {
+        return new Subprocess.newv(
+            { hook, old_version, new_version },
+            SubprocessFlags.INHERIT_FDS
+        ).wait_check();
+    }
+    catch(Error e) {
+        printerr(@"Could not run the bump hook \"$hook\": $(e.message)\n");
+        return false;
+    }
+}

+ 4 - 0
src/lib/Exectuables.vala

@@ -11,6 +11,7 @@ namespace Usm {
         public string? post_install { get; set; }
         public string? acquire { get; set; }
         public string? test { get; set; }
+        public string? bump { get; set; }
 
         public static PropertyMapper<Executables> get_mapper() {
             return PropertyMapper.build_for<Executables>(cfg => {
@@ -35,6 +36,9 @@ namespace Usm {
                 cfg.map<string?>("test", o => o.test, (o, v) => o.test = v)
                     .undefined_when(o => o.test == null)
                     .when_undefined(o => o.test = null);
+                cfg.map<string?>("bump", o => o.bump, (o, v) => o.bump = v)
+                    .undefined_when(o => o.bump == null)
+                    .when_undefined(o => o.bump = null);
                 cfg.set_constructor(() => new Executables());
             });
         }

+ 4 - 0
src/lib/Manifest.vala

@@ -145,6 +145,10 @@ namespace Usm {
             }
 
             var defined = new Vector<string>();
+            // bump, like acquire, is a maintainer-side executable rather than
+            // a lifecycle one (data packages have versions too, and their
+            // version strings live in files the bump hook rewrites), so it is
+            // deliberately absent from the forbidden list below
             if(executables.build != null) {
                 defined.add("build");
             }

+ 18 - 0
src/tests/TestMain.vala

@@ -142,6 +142,10 @@ namespace Usm.Tests {
         check(clean.executables.acquire != null, "acquire executable is honoured on data packages");
         check(clean.executables.build == null, "data package without build parses");
 
+        var bumping = manifest_from_json(DATA_MANIFEST.printf("\"execs\": { \"acquire\": \"fetch.sh\", \"bump\": \"usm-scripts/bump.sh\" }"));
+        bumping.validate();
+        check(bumping.executables.bump != null, "bump executable is permitted on data packages");
+
         var no_execs = manifest_from_json(DATA_MANIFEST.printf("\"execs\": { }"));
         no_execs.validate();
         check(no_execs.executables.build == null, "empty execs parse without a build executable");
@@ -175,6 +179,19 @@ namespace Usm.Tests {
         }
     }
 
+    void test_bump_executable() throws Error {
+        var manifest = manifest_from_json(DATA_MANIFEST.printf("\"execs\": { \"acquire\": \"fetch.sh\", \"bump\": \"usm-scripts/bump.sh\" }"));
+        check(manifest.executables.bump == "usm-scripts/bump.sh", "execs.bump parses through the manifest mapper");
+        check(manifest.executables.acquire == "fetch.sh", "execs.bump leaves neighbouring executables intact");
+
+        var serialised = ((!)new JsonElement.from_properties(Usm.Manifest.get_mapper().map_from(manifest))).stringify_pretty();
+        check(manifest_from_json(serialised).executables.bump == "usm-scripts/bump.sh",
+            "execs.bump round-trips through serialisation");
+
+        var without = manifest_from_json(DATA_MANIFEST.printf("\"execs\": { }"));
+        check(without.executables.bump == null, "absent execs.bump parses as null");
+    }
+
     void test_data_package_from_file() throws Error {
         var root = File.new_build_filename("/tmp", Uuid.string_random());
         root.make_directory();
@@ -3427,6 +3444,7 @@ echo "Installing libmeson-pkg.so to /usr/lib"
             test_data_package_flag();
             test_rebuild_dependants_flag();
             test_data_package_validation();
+            test_bump_executable();
             test_data_package_from_file();
             test_configuration_system_package_manager();
             test_gio_module_ref();