Splux Linux

source-based Linux using SPS

SPS · log · files · refs · tags · releases · people · languages · GitHub · commit

f2d3f1b


sget: stream mkpkg output instead of capturing stdout

Date
Commitf2d3f1bd350431d23359849342f2f45344905bac
GitHubmirror

Parents

Stat

 PACKAGE-MAINTAINER.md         |  14 +-
 README.md                     |   4 +
 bin/mkpkg                     | 105 +++++++++++-
 bin/pkin                      |   7 +-
 bin/sget                      | 135 ++++++++++++---
 man/mkpkg.1                   |  35 +++-
 man/sget.1                    |  39 ++++-
 tests/test_build.sh           |  66 ++++----
 tests/test_compression.sh     |  23 +--
 tests/test_dependencies.sh    |  41 ++++-
 tests/test_highlevel.sh       |   4 +-
 tests/test_output.sh          | 373 ++++++++++++++++++++++++++++++++++++++++++
 tests/test_recipe_contract.sh |   6 +-
 tests/test_release.sh         |  19 ++-
 14 files changed, 763 insertions(+), 108 deletions(-)

Diff

diff --git a/PACKAGE-MAINTAINER.md b/PACKAGE-MAINTAINER.md
index 89c2802..e826418 100644
--- a/PACKAGE-MAINTAINER.md
+++ b/PACKAGE-MAINTAINER.md
@@ -251,7 +251,9 @@ run_sps()
     "$@"
 }
 
-artifact=$(run_sps mkpkg --output "$tmp/out" ./recipe)
+result=$(mktemp)
+run_sps mkpkg --artifact-file "$result" --output "$tmp/out" ./recipe
+artifact=$(cat "$result")
 tar -tf "$artifact"
 run_sps pkin "$artifact"
 run_sps pkcheck package-name
@@ -266,11 +268,11 @@ run_sps pkcheck --database
 find "$root" -mindepth 1 -print
 ```
 
-Capture the artifact path printed by `mkpkg`; compression depends on the local
-configuration. Use a fresh output directory because `mkpkg` does not overwrite
-an existing artifact. `--keep-build` retains work and staging paths for failure
-inspection; redirect command output to a log outside Git when preserving a
-build log.
+Read the archive path from `--artifact-file` after `mkpkg` exits 0. Compression
+depends on the local configuration. Use a fresh output directory because `mkpkg`
+does not overwrite an existing artifact. `--keep-build` retains work and staging
+paths for failure inspection; redirect command output to a log outside Git when
+preserving a build log.
 
 `SPS_ROOT` redirects installation but does not chroot a smoke test. Running a
 dynamically linked executable under `$root/usr/bin` may still use host runtime
diff --git a/README.md b/README.md
index 22aed70..b94517b 100644
--- a/README.md
+++ b/README.md
@@ -119,6 +119,10 @@ sget why firefox libxml2
 sget remove neovim
 ```
 
+`sget install` prints each package as it begins, then leaves `mkpkg` and the
+upstream build attached to the terminal. An exact cached archive is reused
+instead of rebuilding.
+
 The lower-level tools are always available:
 
 ```sh
diff --git a/bin/mkpkg b/bin/mkpkg
index b83e655..c35bdf3 100755
--- a/bin/mkpkg
+++ b/bin/mkpkg
@@ -13,6 +13,10 @@ recipe=
 build_dir=
 stage_dir=
 build_root=
+artifact_file=
+print_artifact=0
+artifact_tmp=
+download_tmp=
 
 usage()
 {
@@ -28,6 +32,8 @@ options:
                             first on PATH and expose it as \$SYSROOT
       --no-download        use only local sources and cached downloads
       --keep-build         retain build and staging directories
+      --artifact-file PATH write the package archive path to PATH
+      --print-artifact     print the package archive path on stdout
   -h, --help               show this help
 EOF
 }
@@ -55,6 +61,8 @@ cleanup()
 {
 	cleanup_status=$?
 	trap - 0 HUP INT TERM
+	[ -z "$download_tmp" ] || rm -f "$download_tmp"
+	[ -z "$artifact_tmp" ] || rm -f "$artifact_tmp"
 	if [ "$keep_build" -eq 1 ]; then
 		[ -z "$build_dir" ] || say "build directory retained at $build_dir"
 		[ -z "$stage_dir" ] || say "stage directory retained at $stage_dir"
@@ -112,6 +120,21 @@ while [ "$#" -gt 0 ]; do
 			keep_build=1
 			shift
 			;;
+		--artifact-file)
+			[ "$#" -ge 2 ] || die_status 2 "$1 requires a path"
+			artifact_file=$2
+			[ -n "$artifact_file" ] || die_status 2 "--artifact-file requires a path"
+			shift 2
+			;;
+		--artifact-file=*)
+			artifact_file=${1#*=}
+			[ -n "$artifact_file" ] || die_status 2 "--artifact-file requires a path"
+			shift
+			;;
+		--print-artifact)
+			print_artifact=1
+			shift
+			;;
 		-h|--help)
 			usage
 			exit 0
@@ -144,6 +167,24 @@ fi
 [ -f "$recipe" ] || die_status 3 "recipe not found: $recipe"
 [ -r "$recipe" ] || die_status 8 "recipe is not readable: $recipe"
 
+if [ -n "$artifact_file" ]; then
+	case $artifact_file in
+		/*) ;;
+		*) artifact_file=$PWD/$artifact_file ;;
+	esac
+	artifact_parent=${artifact_file%/*}
+	[ -n "$artifact_parent" ] || artifact_parent=/
+	[ -d "$artifact_parent" ] ||
+		die_status 8 "--artifact-file directory does not exist: $artifact_parent"
+	if [ -e "$artifact_file" ] || [ -L "$artifact_file" ]; then
+		if [ -d "$artifact_file" ] && [ ! -L "$artifact_file" ]; then
+			die_status 8 "--artifact-file is a directory: $artifact_file"
+		fi
+		rm -f "$artifact_file" ||
+			die_status 8 "cannot replace artifact result file: $artifact_file"
+	fi
+fi
+
 script_dir=$(CDPATH= cd "$(dirname "$0")" 2>/dev/null && pwd) ||
 	die "cannot determine program directory"
 common_file=
@@ -403,6 +444,7 @@ for field in name version release arch; do
 		*) die_status 4 "$field must begin with an alphanumeric character" ;;
 	esac
 done
+say "$name $version-$release"
 
 sources=$build_dir/sources.list
 hashes=$build_dir/source-hashes.list
@@ -443,8 +485,13 @@ verify_source()
 	verify_actual=$(sha256_file "$verify_file") ||
 		die_status 10 "cannot hash source $verify_file"
 	verify_actual=$(printf '%s\n' "$verify_actual" | tr 'ABCDEF' 'abcdef')
-	[ "$verify_actual" = "$verify_expected" ] ||
-		die_status 6 "checksum failure for ${verify_file##*/}: expected $verify_expected, got $verify_actual"
+	if [ "$verify_actual" != "$verify_expected" ]; then
+		say "checksum mismatch for ${verify_file##*/}"
+		say "expected $verify_expected"
+		say "got      $verify_actual"
+		exit 6
+	fi
+	say "checksum ok"
 }
 
 source_name()
@@ -472,10 +519,20 @@ download_http()
 {
 	download_url=$1
 	download_dest=$2
+	# Progress belongs to the downloader. Use a TTY progress display when
+	# stderr is a terminal; otherwise stay quiet enough for log files.
 	if command -v curl >/dev/null 2>&1; then
-		curl -fL -o "$download_dest" "$download_url"
+		if [ -t 2 ]; then
+			curl -fL -# -o "$download_dest" "$download_url"
+		else
+			curl -fL -sS -o "$download_dest" "$download_url"
+		fi
 	elif command -v wget >/dev/null 2>&1; then
-		wget -O "$download_dest" "$download_url"
+		if [ -t 2 ]; then
+			wget -O "$download_dest" "$download_url"
+		else
+			wget -q -O "$download_dest" "$download_url"
+		fi
 	else
 		die_status 9 "cannot download $download_url: curl or wget is required"
 	fi
@@ -503,32 +560,39 @@ while IFS='	' read -r source hash_record; do
 			expected=${hash_record#sha256:}
 			cache_file=$SPS_CACHE/sources/$expected-$leaf
 			if [ -f "$cache_file" ]; then
+				say "source cache hit $leaf"
 				verify_source "$cache_file" "$hash_record"
 				cp "$cache_file" "$destination" || die "cannot copy cached source $leaf"
 			else
 				[ "$no_download" -eq 0 ] ||
 					die_status 9 "source is not cached and downloads are disabled: $source"
+				say "fetching $leaf"
 				download_tmp=$(mktemp "$SPS_CACHE/sources/.download.XXXXXX") ||
 					die_status 8 "cannot create source download file"
 				if ! download_http "$source" "$download_tmp"; then
 					rm -f "$download_tmp"
+					download_tmp=
 					die_status 9 "download failed: $source"
 				fi
 				if ! verify_source "$download_tmp" "$hash_record"; then
 					rm -f "$download_tmp"
+					download_tmp=
 					exit 1
 				fi
 				chmod 644 "$download_tmp"
 				if ! mv "$download_tmp" "$cache_file"; then
 					rm -f "$download_tmp"
+					download_tmp=
 					die "cannot commit source to cache: $cache_file"
 				fi
+				download_tmp=
 				cp "$cache_file" "$destination" || die "cannot copy downloaded source $leaf"
 			fi
 			;;
 		file://*)
 			local_source=${source#file://}
 			[ -f "$local_source" ] || die_status 3 "local source not found: $source"
+			say "source $leaf"
 			cp "$local_source" "$destination" || die "cannot copy source $source"
 			verify_source "$destination" "$hash_record"
 			;;
@@ -538,6 +602,7 @@ while IFS='	' read -r source hash_record; do
 				*) local_source=$recipe_dir/$source ;;
 			esac
 			[ -f "$local_source" ] || die_status 3 "local source not found: $source"
+			say "source $leaf"
 			cp "$local_source" "$destination" || die "cannot copy source $source"
 			verify_source "$destination" "$hash_record"
 			;;
@@ -627,9 +692,13 @@ else
 fi
 
 # Write each build phase to a temporary script. These are the recipe fields we
-# actually execute.
+# actually execute.  The scripts live in a dedicated run directory rather than
+# at the top of the build directory: names such as install.sh are exactly what
+# autotools' auxiliary-directory heuristics look for, and a matching file in a
+# parent directory would corrupt generated build systems.
+mkdir -p "$build_dir/run"
 for phase in prepare configure build install; do
-	record_values "$phase" >"$build_dir/$phase.sh"
+	record_values "$phase" >"$build_dir/run/$phase.sh"
 done
 
 PKG=$stage_dir
@@ -652,7 +721,7 @@ fi
 run_phase()
 {
 	phase_name=$1
-	phase_file=$build_dir/$phase_name.sh
+	phase_file=$build_dir/run/$phase_name.sh
 	[ -s "$phase_file" ] || return 0
 	say "running $phase_name phase"
 	if [ -n "$run_env" ]; then
@@ -666,6 +735,7 @@ run_phase()
 	fi
 }
 
+printf '\n' >&2
 run_phase prepare
 run_phase configure
 run_phase build
@@ -836,5 +906,22 @@ esac
 chmod 644 "$output_tmp"
 mv "$output_tmp" "$artifact" || die "cannot commit package archive: $artifact"
 
-# Keep stdout clean for sget: one artifact path.
-printf '%s\n' "$artifact"
+# sget needs the package path without swallowing build output.
+if [ -n "$artifact_file" ]; then
+	artifact_tmp=$(mktemp "$artifact_file.XXXXXX") ||
+		die_status 8 "cannot create artifact result file"
+	if ! printf '%s\n' "$artifact" >"$artifact_tmp"; then
+		rm -f "$artifact_tmp"
+		artifact_tmp=
+		die_status 8 "cannot write artifact result file"
+	fi
+	chmod 644 "$artifact_tmp"
+	mv "$artifact_tmp" "$artifact_file" ||
+		die "cannot commit artifact result file: $artifact_file"
+	artifact_tmp=
+fi
+
+say "built ${artifact##*/}"
+if [ "$print_artifact" -eq 1 ]; then
+	printf '%s\n' "$artifact"
+fi
diff --git a/bin/pkin b/bin/pkin
index b29e85e..03e39f4 100755
--- a/bin/pkin
+++ b/bin/pkin
@@ -384,6 +384,7 @@ fi
 # old manifest must never turn into a removal path.
 sps_old_record=$SPS_DB/installed/$sps_name
 sps_old_version=
+sps_old_release=
 : >"$sps_work/old-owned"
 if sps_exists "$sps_old_record" &&
    { [ ! -d "$sps_old_record" ] || [ -L "$sps_old_record" ]; }; then
@@ -412,6 +413,9 @@ if [ -d "$sps_old_record" ]; then
     sps_old_version=$(awk -v sps_action=meta-get -v sps_key_name=version \
         -v sps_program=pkin -f "$SPS_LIBDIR/package.awk" "$sps_old_record/meta") ||
         sps_fail 11 "installed metadata for '$sps_name' is malformed"
+    sps_old_release=$(awk -v sps_action=meta-get -v sps_key_name=release \
+        -v sps_program=pkin -f "$SPS_LIBDIR/package.awk" "$sps_old_record/meta") ||
+        sps_fail 11 "installed metadata for '$sps_name' is malformed"
     while IFS= read -r sps_entry || [ -n "$sps_entry" ]; do
         case $sps_entry in */) ;; *) printf '%s\n' "$sps_entry" >>"$sps_work/old-owned" ;; esac
     done <"$sps_old_record/files"
@@ -772,7 +776,8 @@ if ! sps_run_hook post-install; then
 fi
 
 if [ -n "$sps_old_version" ]; then
-    printf '%s: upgraded %s %s -> %s-%s\n' "$sps_prog" "$sps_name" "$sps_old_version" "$sps_version" "$sps_release"
+    printf '%s: upgraded %s %s-%s -> %s-%s\n' "$sps_prog" "$sps_name" \
+        "$sps_old_version" "$sps_old_release" "$sps_version" "$sps_release"
 else
     printf '%s: installed %s %s-%s\n' "$sps_prog" "$sps_name" "$sps_version" "$sps_release"
 fi
diff --git a/bin/sget b/bin/sget
index 5816423..f81e11b 100755
--- a/bin/sget
+++ b/bin/sget
@@ -38,17 +38,23 @@ sget_begin_transaction()
 sget_usage()
 {
     cat <<'EOF_USAGE'
-usage: sget search PATTERN [--raw]
-       sget info PACKAGE [--raw]
-       sget depends PACKAGE [--raw] [--build]
-       sget dependees PACKAGE [--build]
-       sget why PACKAGE DEPENDENCY [--build]
-       sget install [--plan] [--nodeps] [--reinstall] PACKAGE...
-       sget upgrade [--plan] [--downgrade]
-       sget remove [--plan] [--nodeps] PACKAGE...
+usage: sget [-q|--quiet] [-v|--verbose] search PATTERN [--raw]
+       sget [-q|--quiet] [-v|--verbose] info PACKAGE [--raw]
+       sget [-q|--quiet] [-v|--verbose] depends PACKAGE [--raw] [--build]
+       sget [-q|--quiet] [-v|--verbose] dependees PACKAGE [--build]
+       sget [-q|--quiet] [-v|--verbose] why PACKAGE DEPENDENCY [--build]
+       sget [-q|--quiet] [-v|--verbose] install [--plan] [--nodeps] [--reinstall] PACKAGE...
+       sget [-q|--quiet] [-v|--verbose] upgrade [--plan] [--downgrade]
+       sget [-q|--quiet] [-v|--verbose] remove [--plan] [--nodeps] PACKAGE...
 EOF_USAGE
 }
 
+sget_note()
+{
+    [ "${sget_verbose:-0}" -eq 1 ] || return 0
+    printf '%s: %s\n' "$sps_prog" "$*" >&2
+}
+
 sget_need_index()
 {
     [ -r "$sps_index" ] ||
@@ -251,6 +257,8 @@ sget_install()
             --nodeps) sget_nodeps=1; shift ;;
             --reinstall) sget_reinstall=1; shift ;;
             --downgrade) sget_allow_downgrade=1; shift ;;
+            -q|--quiet) sget_quiet=1; shift ;;
+            -v|--verbose) sget_verbose=1; shift ;;
             --) shift; break ;;
             -*) sps_die "$SPS_EX_USAGE" "unknown option '$1'" ;;
             *) break ;;
@@ -325,6 +333,7 @@ sget_install()
                 if [ "$sget_reinstall" -eq 1 ] && [ "$sget_upgrade_mode" -eq 0 ]; then
                     sget_action=reinstall
                 else
+                    sget_note "$sget_package satisfied by $sget_installed_version-$sget_installed_release"
                     continue
                 fi
             elif [ "$sget_cmp" -gt 0 ]; then
@@ -339,9 +348,18 @@ sget_install()
                 fi
             fi
         fi
-        printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
+        sget_old_version=
+        sget_old_release=
+        case $sget_action in
+            upgrade|downgrade|reinstall)
+                sget_old_version=$sget_installed_version
+                sget_old_release=$sget_installed_release
+                ;;
+        esac
+        printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
             "$sget_action" "$sget_package" "$sget_recipe" "$sget_recipe_hash" \
-            "$sget_version" "$sget_release" "$sget_arch" >>"$sget_work/todo"
+            "$sget_version" "$sget_release" "$sget_arch" \
+            "$sget_old_version" "$sget_old_release" >>"$sget_work/todo"
         sget_count=$((sget_count + 1))
     done <"$sget_work/resolution"
 
@@ -356,15 +374,28 @@ sget_install()
             printf '%s\n' 'No changes are needed.'
         else
             while IFS="$sps_tab" read -r sget_action sget_package sget_recipe \
-                  sget_recipe_hash sget_version sget_release sget_arch || [ -n "$sget_action" ]
+                  sget_recipe_hash sget_version sget_release sget_arch \
+                  sget_old_version sget_old_release || [ -n "$sget_action" ]
             do
-                [ -n "$sget_action" ] && printf '  %-10s %s %s-%s\n' \
-                    "$sget_action" "$sget_package" "$sget_version" "$sget_release"
+                [ -n "$sget_action" ] || continue
+                if [ "$sget_action" = upgrade ] || [ "$sget_action" = downgrade ]; then
+                    printf '  %-10s %s %s-%s -> %s-%s\n' \
+                        "$sget_action" "$sget_package" \
+                        "$sget_old_version" "$sget_old_release" \
+                        "$sget_version" "$sget_release"
+                else
+                    printf '  %-10s %s %s-%s\n' \
+                        "$sget_action" "$sget_package" "$sget_version" "$sget_release"
+                fi
             done <"$sget_work/todo"
             while IFS= read -r sget_package || [ -n "$sget_package" ]; do
                 [ -n "$sget_package" ] && printf '  %-10s %s\n' explicit "$sget_package"
             done <"$sget_work/mark-explicit"
-            printf '\n%d package builds, %d reason changes\n' "$sget_count" "$sget_mark_count"
+            if [ "$sget_mark_count" -gt 0 ]; then
+                printf '\n%d packages, %d reason changes\n' "$sget_count" "$sget_mark_count"
+            else
+                printf '\n%d packages\n' "$sget_count"
+            fi
         fi
         sget_cleanup_install
         trap - 0 1 2 3 15
@@ -374,8 +405,10 @@ sget_install()
     if [ "$sget_count" -gt 0 ]; then
         sps_require_cmd mkpkg
         sps_require_cmd pkin
+        sget_seen=0
         while IFS="$sps_tab" read -r sget_action sget_package sget_recipe \
-              sget_recipe_hash sget_version sget_release sget_arch || [ -n "$sget_action" ]
+              sget_recipe_hash sget_version sget_release sget_arch \
+              sget_old_version sget_old_release || [ -n "$sget_action" ]
         do
             [ -n "$sget_action" ] || continue
             [ -f "$sget_recipe" ] ||
@@ -386,8 +419,23 @@ sget_install()
 				sps_die "$SPS_EX_CHECKSUM" "package definition for '$sget_package' changed since indexing; run 'src update'"
             sget_recipe_dir=${sget_recipe%/*}
             sget_recipe_name=${sget_recipe##*/}
+            sget_note "selected $sget_recipe $sget_version-$sget_release"
 
-            printf '%s %s\n' "$sget_action" "$sget_package"
+            if [ "$sget_seen" -eq 1 ]; then
+                printf '\n'
+            fi
+            sget_seen=1
+            case $sget_action in
+                upgrade|downgrade)
+                    printf '%s %s %s-%s -> %s-%s\n' "$sget_action" "$sget_package" \
+                        "$sget_old_version" "$sget_old_release" \
+                        "$sget_version" "$sget_release"
+                    ;;
+                *)
+                    printf '%s %s %s-%s\n' "$sget_action" "$sget_package" \
+                        "$sget_version" "$sget_release"
+                    ;;
+            esac
             sget_cache_base=$SPS_CACHE/packages/$sget_package-$sget_version-$sget_release-$sget_arch.pkg.tar
             sget_artifact=
             for sget_candidate in "$sget_cache_base.zst" "$sget_cache_base.xz" \
@@ -401,18 +449,35 @@ sget_install()
                 sget_verify_artifact_identity "$sget_artifact" "$sget_package" \
                     "$sget_version" "$sget_release" "$sget_arch" ||
                     sps_die "$SPS_EX_PACKAGE" "cached artifact identity is invalid for '$sget_package': $sget_artifact"
-                printf 'using cached %s\n' "${sget_artifact##*/}"
+                sget_note "binary cache hit"
+                printf '%s: using cached %s\n' "$sps_prog" "${sget_artifact##*/}"
             else
-                sget_mkpkg_output=$(cd "$sget_recipe_dir" &&
-                                    mkpkg --output "$SPS_CACHE/packages" "./$sget_recipe_name")
-                sget_mkpkg_status=$?
-                [ "$sget_mkpkg_status" -eq 0 ] ||
-                    sps_die "$SPS_EX_BUILD" "build failed for '$sget_package'"
-                sget_artifact=$(printf '%s\n' "$sget_mkpkg_output" |
-                               awk 'NF { result = $0 } END { print result }')
+                sget_note "binary cache miss"
+                sget_note "building $sget_recipe_dir"
+                sget_artifact_file=$sget_work/artifact-$sget_package
+                sget_build_log=$sget_work/build-$sget_package.log
+                if [ "${sget_quiet:-0}" -eq 1 ]; then
+                    if (CDPATH= cd "$sget_recipe_dir" &&
+                        mkpkg --artifact-file "$sget_artifact_file" \
+                            --output "$SPS_CACHE/packages" "./$sget_recipe_name" \
+                            >"$sget_build_log" 2>&1)
+                    then
+                        :
+                    else
+                        [ ! -s "$sget_build_log" ] || cat "$sget_build_log" >&2
+                        sps_die "$SPS_EX_BUILD" \
+                            "failed to build $sget_package $sget_version-$sget_release"
+                    fi
+                else
+                    (CDPATH= cd "$sget_recipe_dir" &&
+                     mkpkg --artifact-file "$sget_artifact_file" \
+                         --output "$SPS_CACHE/packages" "./$sget_recipe_name") ||
+                        sps_die "$SPS_EX_BUILD" \
+                            "failed to build $sget_package $sget_version-$sget_release"
+                fi
+                sget_artifact=$(sed -n '1p' "$sget_artifact_file" 2>/dev/null) || sget_artifact=
                 [ -n "$sget_artifact" ] ||
                     sps_die "$SPS_EX_BUILD" "mkpkg did not report an artifact for '$sget_package'"
-                case $sget_artifact in /*) ;; *) sget_artifact=$sget_recipe_dir/$sget_artifact ;; esac
                 [ -f "$sget_artifact" ] ||
                     sps_die "$SPS_EX_BUILD" "mkpkg artifact for '$sget_package' is missing: $sget_artifact"
                 sget_verify_artifact_identity "$sget_artifact" "$sget_package" \
@@ -449,6 +514,8 @@ sget_upgrade()
         case $1 in
             --plan) sget_upgrade_plan=1 ;;
             --downgrade) sget_upgrade_downgrade=1 ;;
+            -q|--quiet) sget_quiet=1 ;;
+            -v|--verbose) sget_verbose=1 ;;
             *) sget_usage >&2; exit "$SPS_EX_USAGE" ;;
         esac
         shift
@@ -477,6 +544,8 @@ sget_remove()
         case $1 in
             --plan) sget_remove_plan=1; shift ;;
             --nodeps) sget_remove_nodeps=1; shift ;;
+            -q|--quiet) sget_quiet=1; shift ;;
+            -v|--verbose) sget_verbose=1; shift ;;
             --) shift; break ;;
             -*) sps_die "$SPS_EX_USAGE" "unknown option '$1'" ;;
             *) break ;;
@@ -577,6 +646,22 @@ sget_remove()
 
 # Keep a whole sget install/upgrade/remove from interleaving with another one.
 # Low-level tools still use their shorter database lock. Queries and plans do not.
+sget_quiet=0
+sget_verbose=0
+while [ "$#" -gt 0 ]; do
+    case $1 in
+        -q|--quiet) sget_quiet=1; shift ;;
+        -v|--verbose) sget_verbose=1; shift ;;
+        -h|--help) sget_usage; exit 0 ;;
+        --) shift; break ;;
+        -*)
+            sget_usage >&2
+            exit "$SPS_EX_USAGE"
+            ;;
+        *) break ;;
+    esac
+done
+
 sget_top_command=${1-}
 case $sget_top_command in
     install|upgrade|remove)
diff --git a/man/mkpkg.1 b/man/mkpkg.1
index 51eb049..da8ea54 100644
--- a/man/mkpkg.1
+++ b/man/mkpkg.1
@@ -63,6 +63,21 @@ Require remote sources to already exist in the source cache.
 .TP
 .B --keep-build
 Keep build and staging directories for inspection after exit.
+.TP
+.BI "--artifact-file " path
+After a successful build, write the absolute package archive path to PATH.
+The file is replaced only when the archive exists. A previous value is removed
+at the start of the run so a failed build cannot leave a stale success. Callers
+such as
+.BR sget (1)
+use this to obtain the artifact without capturing build output.
+.TP
+.B --print-artifact
+Print the absolute package archive path as the final stdout line. Build
+commands also write to stdout, so this is only reliable for recipes whose
+phases produce no output. Prefer
+.B --artifact-file
+for scripts.
 .SH RECIPES
 Required keys are
 .BR name ,
@@ -143,9 +158,23 @@ With
 .B SYSROOT
 is the absolute controlled root directory.
 .SH OUTPUT
-On success the final stdout line is the absolute or requested path of the
-created package artifact, suitable for consumption by
-.BR sget (1).
+SPS status messages go to stderr in the form
+.BR "mkpkg: text" .
+Upstream configure, compile, and install commands keep their own stdout and
+stderr. Remote fetches print
+.B mkpkg: fetching
+then use curl or wget progress when stderr is a terminal, and a quieter
+downloader mode when it is not.
+.PP
+On success,
+.B mkpkg
+reports
+.B mkpkg: built
+followed by the archive basename. The archive path is not printed on stdout
+unless
+.B --print-artifact
+is given. Machine callers should use
+.BR --artifact-file .
 .SH EXIT STATUS
 Returns 0 on success, 2 for usage, 3 for missing recipe/source, 4 for invalid
 metadata/package content, 6 for checksum failure, 7 when the output artifact
diff --git a/man/sget.1 b/man/sget.1
index 77a97dc..6738115 100644
--- a/man/sget.1
+++ b/man/sget.1
@@ -3,14 +3,14 @@
 sget \- resolve and coordinate SPS source packages
 .SH SYNOPSIS
 .nf
-sget search PATTERN [--raw]
-sget info PACKAGE [--raw]
-sget depends PACKAGE [--raw] [--build]
-sget dependees PACKAGE [--build]
-sget why PACKAGE DEPENDENCY [--build]
-sget install [--plan] [--nodeps] [--reinstall] PACKAGE...
-sget upgrade [--plan] [--downgrade]
-sget remove [--plan] [--nodeps] PACKAGE...
+sget [-q|--quiet] [-v|--verbose] search PATTERN [--raw]
+sget [-q|--quiet] [-v|--verbose] info PACKAGE [--raw]
+sget [-q|--quiet] [-v|--verbose] depends PACKAGE [--raw] [--build]
+sget [-q|--quiet] [-v|--verbose] dependees PACKAGE [--build]
+sget [-q|--quiet] [-v|--verbose] why PACKAGE DEPENDENCY [--build]
+sget [-q|--quiet] [-v|--verbose] install [--plan] [--nodeps] [--reinstall] PACKAGE...
+sget [-q|--quiet] [-v|--verbose] upgrade [--plan] [--downgrade]
+sget [-q|--quiet] [-v|--verbose] remove [--plan] [--nodeps] PACKAGE...
 .fi
 .SH DESCRIPTION
 .B sget
@@ -21,6 +21,16 @@ resolves package graphs, calls
 when a binary artifact is not already cached, and hands archives to
 .BR pkin (1).
 It does not refresh repositories implicitly.
+.PP
+During install and upgrade,
+.B sget
+prints one line for the package being processed, then runs
+.BR mkpkg (1)
+with the terminal attached so compiler and build-system output appear live.
+.B mkpkg
+reports the finished archive;
+.BR pkin (1)
+reports installation. An exact cached archive is reused without a build.
 .SH COMMANDS
 .TP
 .BI search " pattern"
@@ -66,6 +76,19 @@ By default SPS refuses a removal when another installed package outside the
 requested removal set directly requires it.
 .SH OPTIONS
 .TP
+.BR -q ", " --quiet
+Hide routine
+.B mkpkg
+status and upstream build output. Package-level
+.B sget
+and
+.B pkin
+lines remain. If a build fails, the captured log is written to stderr.
+.TP
+.BR -v ", " --verbose
+Print resolver decisions such as selected recipes, already-satisfied
+dependencies, and binary cache hits or misses.
+.TP
 .B --plan
 Print package-level actions without building, installing, removing, or changing
 package state.
diff --git a/tests/test_build.sh b/tests/test_build.sh
index 042b30e..6afa655 100755
--- a/tests/test_build.sh
+++ b/tests/test_build.sh
@@ -34,6 +34,21 @@ sha256_file()
 	fi
 }
 
+run_mkpkg()
+{
+	result=$tmp/artifact.path
+	rm -f "$result"
+	SPS_ROOT=$tmp/root \
+	SPS_DB=$tmp/db \
+	SPS_CACHE=$tmp/cache \
+	SPS_BUILD=$tmp/build \
+	SPS_CONFIG=/dev/null \
+	SPS_REPOS_CONFIG=/dev/null \
+	SPS_COMPRESSION=none \
+		"$mkpkg" --artifact-file "$result" "$@" || return $?
+	sed -n '1p' "$result"
+}
+
 tmp=$(mktemp -d "${TMPDIR:-/tmp}/sps-test-build.XXXXXX") ||
 	fail "cannot create temporary directory"
 trap 'rm -rf "$tmp"' 0 HUP INT TERM
@@ -42,16 +57,8 @@ mkdir -p "$tmp/recipe" "$tmp/out" "$tmp/root"
 cp "$project_dir/examples/hello/recipe" "$tmp/recipe/recipe"
 cp "$project_dir/examples/hello/hello.sh" "$tmp/recipe/hello.sh"
 
-artifact=$(
-	SPS_ROOT=$tmp/root \
-	SPS_DB=$tmp/db \
-	SPS_CACHE=$tmp/cache \
-	SPS_BUILD=$tmp/build \
-	SPS_CONFIG=/dev/null \
-	SPS_REPOS_CONFIG=/dev/null \
-	SPS_COMPRESSION=none \
-	"$mkpkg" --no-download --output "$tmp/out" "$tmp/recipe/recipe"
-) || fail "offline hello package build failed"
+artifact=$(run_mkpkg --no-download --output "$tmp/out" "$tmp/recipe/recipe") ||
+	fail "offline hello package build failed"
 
 expected_artifact=$tmp/out/hello-sps-1.0-1-any.pkg.tar
 assert_equal "$expected_artifact" "$artifact" "mkpkg did not print the artifact path"
@@ -102,6 +109,7 @@ mkdir "$tmp/bad-recipe" "$tmp/bad-out"
 cp "$tmp/recipe/hello.sh" "$tmp/bad-recipe/hello.sh"
 sed 's/sha256:2/sha256:3/' "$tmp/recipe/recipe" >"$tmp/bad-recipe/recipe"
 set +e
+printf 'stale\n' >"$tmp/bad-artifact"
 SPS_ROOT=$tmp/root \
 SPS_DB=$tmp/db \
 SPS_CACHE=$tmp/cache \
@@ -109,13 +117,21 @@ SPS_BUILD=$tmp/build \
 SPS_CONFIG=/dev/null \
 SPS_REPOS_CONFIG=/dev/null \
 SPS_COMPRESSION=none \
-"$mkpkg" --no-download --output "$tmp/bad-out" "$tmp/bad-recipe/recipe" \
+"$mkpkg" --artifact-file "$tmp/bad-artifact" --no-download --output "$tmp/bad-out" \
+	"$tmp/bad-recipe/recipe" \
 	>"$tmp/bad.stdout" 2>"$tmp/bad.stderr"
 bad_status=$?
 set -e
 assert_equal 6 "$bad_status" "checksum failure returned the wrong status"
-grep -q 'checksum failure' "$tmp/bad.stderr" ||
+grep -q 'checksum mismatch' "$tmp/bad.stderr" ||
 	fail "checksum diagnostic did not explain the failure"
+grep -q 'expected ' "$tmp/bad.stderr" ||
+	fail "checksum diagnostic omitted the expected digest"
+grep -q 'got      ' "$tmp/bad.stderr" ||
+	fail "checksum diagnostic omitted the actual digest"
+[ ! -s "$tmp/bad.stdout" ] || fail "failed build wrote artifact path to stdout"
+[ ! -e "$tmp/bad-artifact" ] || [ ! -s "$tmp/bad-artifact" ] ||
+	fail "failed build left a successful artifact result"
 
 # Local tar sources unpack below WORK. Recipe variables expand as data and
 # missing arch falls back to uname -m.
@@ -137,16 +153,8 @@ install     chmod 755 "$PKG/usr/bin/archive-hello"
 EOF
 sed "s/SOURCE_HASH/$archive_hash/" "$tmp/tar-recipe/recipe.in" >"$tmp/tar-recipe/recipe"
 
-tar_artifact=$(
-	SPS_ROOT=$tmp/root \
-	SPS_DB=$tmp/db \
-	SPS_CACHE=$tmp/cache \
-	SPS_BUILD=$tmp/build \
-	SPS_CONFIG=/dev/null \
-	SPS_REPOS_CONFIG=/dev/null \
-	SPS_COMPRESSION=none \
-	"$mkpkg" --no-download --output "$tmp/tar-out" "$tmp/tar-recipe/recipe"
-) || fail "local tar source build failed"
+tar_artifact=$(run_mkpkg --no-download --output "$tmp/tar-out" "$tmp/tar-recipe/recipe") ||
+	fail "local tar source build failed"
 default_arch=$(uname -m)
 assert_equal "$tmp/tar-out/archive-hello-1.0-1-$default_arch.pkg.tar" \
 	"$tar_artifact" "default architecture or metadata expansion is incorrect"
@@ -171,17 +179,9 @@ install     mkdir -p "$PKG/usr/share/sysroot-probe"
 install     printf '%s\n' "${SYSROOT-}" >"$PKG/usr/share/sysroot-probe/sysroot"
 install     printf '%s\n' "$PATH" >"$PKG/usr/share/sysroot-probe/path"
 EOF
-sysroot_artifact=$(
-	SPS_ROOT=$tmp/root \
-	SPS_DB=$tmp/db \
-	SPS_CACHE=$tmp/cache \
-	SPS_BUILD=$tmp/build \
-	SPS_CONFIG=/dev/null \
-	SPS_REPOS_CONFIG=/dev/null \
-	SPS_COMPRESSION=none \
-	"$mkpkg" --no-download --build-root "$tmp/buildroot" \
-		--output "$tmp/sysroot-out" "$tmp/sysroot-recipe/recipe"
-) || fail "sysroot build failed"
+sysroot_artifact=$(run_mkpkg --no-download --build-root "$tmp/buildroot" \
+	--output "$tmp/sysroot-out" "$tmp/sysroot-recipe/recipe") ||
+	fail "sysroot build failed"
 mkdir "$tmp/sysroot-extract"
 tar -xf "$sysroot_artifact" -C "$tmp/sysroot-extract"
 assert_equal "$tmp/buildroot" \
diff --git a/tests/test_compression.sh b/tests/test_compression.sh
index 66023d5..6ee5be0 100755
--- a/tests/test_compression.sh
+++ b/tests/test_compression.sh
@@ -37,17 +37,18 @@ for compression in gzip xz zstd; do
     out=$tmp/out-$compression
     mkdir -p "$root" "$out"
 
-    artifact=$(
-        SPS_ROOT=$root \
-        SPS_DB=$db \
-        SPS_CACHE=$cache \
-        SPS_BUILD=$build \
-        SPS_CONFIG=/dev/null \
-        SPS_REPOS_CONFIG=/dev/null \
-        SPS_LIBDIR=$project_dir/lib \
-        "$mkpkg" --no-download --compression "$compression" \
-            --output "$out" "$tmp/recipe/recipe"
-    ) || fail "$compression package build failed"
+    SPS_ROOT=$root \
+    SPS_DB=$db \
+    SPS_CACHE=$cache \
+    SPS_BUILD=$build \
+    SPS_CONFIG=/dev/null \
+    SPS_REPOS_CONFIG=/dev/null \
+    SPS_LIBDIR=$project_dir/lib \
+        "$mkpkg" --artifact-file "$out/artifact" --no-download \
+            --compression "$compression" \
+            --output "$out" "$tmp/recipe/recipe" ||
+        fail "$compression package build failed"
+    artifact=$(sed -n '1p' "$out/artifact")
 
     expected=$out/hello-sps-1.0-1-any.pkg.tar$suffix
     [ "$artifact" = "$expected" ] ||
diff --git a/tests/test_dependencies.sh b/tests/test_dependencies.sh
index b973ac5..cb724f0 100755
--- a/tests/test_dependencies.sh
+++ b/tests/test_dependencies.sh
@@ -148,7 +148,7 @@ mkdir -p "$tmp/db/installed/base"
 : >"$tmp/db/installed/base/hashes"
 outdated_plan=$("$sget" install --plan app)
 case $outdated_plan in
-    *'base 1.0-1'*) ;;
+    *'base 0.9-1 -> 1.0-1'*) ;;
     *) fail 'outdated installed dependency was incorrectly skipped' ;;
 esac
 sed 's/version\t0.9/version\t1.0/' "$tmp/db/installed/base/meta" >"$tmp/base.meta"
@@ -168,10 +168,37 @@ contains "$(cat "$tmp/explicit.out")" 'explicit'
 cat > "$tmp/fakebin/mkpkg" <<'EOF_FAKE_MKPKG'
 #!/bin/sh
 printf "mkpkg\t%s\n" "$PWD" >> "$TEST_LOG"
-name=$(awk '$1=="name" {print $2; exit}' recipe)
-version=$(awk '$1=="version" {print $2; exit}' recipe)
-release=$(awk '$1=="release" {print $2; exit}' recipe)
-arch=$(awk '$1=="arch" {print $2; exit}' recipe)
+artifact_file=
+recipe=recipe
+while [ "$#" -gt 0 ]; do
+    case $1 in
+        --output|--output=*)
+            case $1 in
+                --output) shift 2 ;;
+                *) shift ;;
+            esac
+            ;;
+        --artifact-file)
+            artifact_file=$2
+            shift 2
+            ;;
+        --artifact-file=*)
+            artifact_file=${1#*=}
+            shift
+            ;;
+        --print-artifact)
+            shift
+            ;;
+        *)
+            recipe=$1
+            shift
+            ;;
+    esac
+done
+name=$(awk '$1=="name" {print $2; exit}' "$recipe")
+version=$(awk '$1=="version" {print $2; exit}' "$recipe")
+release=$(awk '$1=="release" {print $2; exit}' "$recipe")
+arch=$(awk '$1=="arch" {print $2; exit}' "$recipe")
 [ -n "$arch" ] || arch=$(uname -m)
 work=$PWD/.fakepkg.$$
 mkdir -p "$work/.SPS"
@@ -182,7 +209,9 @@ printf "format\t1\nname\t%s\nversion\t%s\nrelease\t%s\narch\t%s\n" \
 artifact=$PWD/$name-$version-$release-$arch.pkg.tar
 tar -cf "$artifact" -C "$work" .
 rm -rf "$work"
-printf "%s\n" "$artifact"
+if [ -n "$artifact_file" ]; then
+    printf "%s\n" "$artifact" > "$artifact_file"
+fi
 EOF_FAKE_MKPKG
 cat > "$tmp/fakebin/pkin" <<'EOF_FAKE_PKIN'
 #!/bin/sh
diff --git a/tests/test_highlevel.sh b/tests/test_highlevel.sh
index 957c123..c501e4a 100755
--- a/tests/test_highlevel.sh
+++ b/tests/test_highlevel.sh
@@ -63,8 +63,8 @@ recipe "$repo/base" base 2.0
 recipe "$repo/lib" lib 2.0 base
 src update >/dev/null
 plan=$(sget upgrade --plan)
-contains "$plan" 'upgrade    base 2.0-1'
-contains "$plan" 'upgrade    lib 2.0-1'
+contains "$plan" 'upgrade    base 1.0-1 -> 2.0-1'
+contains "$plan" 'upgrade    lib 1.0-1 -> 2.0-1'
 sget upgrade >"$tmp/upgrade.out"
 [ "$(cat "$root/usr/share/sps-test/base")" = 2.0 ] || fail 'base was not upgraded'
 [ "$(cat "$root/usr/share/sps-test/lib")" = 2.0 ] || fail 'lib was not upgraded'
diff --git a/tests/test_output.sh b/tests/test_output.sh
new file mode 100755
index 0000000..dddb737
--- /dev/null
+++ b/tests/test_output.sh
@@ -0,0 +1,373 @@
+#!/bin/sh
+# Artifact handoff, live build output, and fetch command construction.
+
+set -eu
+
+case $0 in */*) test_dir=${0%/*} ;; *) test_dir=. ;; esac
+project_dir=$(CDPATH= cd "$test_dir/.." 2>/dev/null && pwd) || exit 1
+mkpkg=$project_dir/bin/mkpkg
+
+fail()
+{
+	printf 'test_output: %s\n' "$*" >&2
+	exit 1
+}
+
+contains()
+{
+	case $1 in
+		*"$2"*) return 0 ;;
+		*) fail "expected output to contain '$2'" ;;
+	esac
+}
+
+omits()
+{
+	case $1 in
+		*"$2"*) fail "output unexpectedly contained '$2'" ;;
+	esac
+}
+
+sha256_file()
+{
+	if command -v sha256sum >/dev/null 2>&1; then
+		sha256sum "$1" | awk '{ print $1 }'
+	elif command -v shasum >/dev/null 2>&1; then
+		shasum -a 256 "$1" | awk '{ print $1 }'
+	elif command -v openssl >/dev/null 2>&1; then
+		openssl dgst -sha256 "$1" | awk '{ print $NF }'
+	else
+		fail "no SHA-256 utility available for test fixture"
+	fi
+}
+
+tmp=$(mktemp -d "${TMPDIR:-/tmp}/sps-test-output.XXXXXX") || fail mktemp
+trap 'rm -rf "$tmp"' 0 HUP INT TERM
+
+export SPS_ROOT=$tmp/root
+export SPS_DB=$tmp/db
+export SPS_CACHE=$tmp/cache
+export SPS_BUILD=$tmp/build
+export SPS_CONFIG=/dev/null
+export SPS_REPOS_CONFIG=/dev/null
+export SPS_LIBDIR=$project_dir/lib
+export SPS_COMPRESSION=none
+PATH=$project_dir/bin:$PATH
+export PATH
+mkdir -p "$tmp/root" "$tmp/out" "$tmp/repo"
+
+run_env()
+{
+	SPS_ROOT=$tmp/root SPS_DB=$tmp/db SPS_CACHE=$tmp/cache \
+	SPS_BUILD=$tmp/build SPS_CONFIG=/dev/null SPS_REPOS_CONFIG=/dev/null \
+	SPS_LIBDIR=$project_dir/lib SPS_COMPRESSION=none \
+		"$@"
+}
+
+# --- mkpkg --artifact-file on success ---
+mkdir -p "$tmp/ok"
+cat >"$tmp/ok/recipe" <<'EOF'
+name        stream-ok
+version     1.0
+release     1
+arch        any
+description Artifact handoff success
+build       printf '%s\n' BUILD-LINE-ONE
+build       printf '%s\n' BUILD-LINE-TWO
+install     mkdir -p "$PKG/usr/share"
+install     printf '%s\n' ok >"$PKG/usr/share/stream-ok"
+EOF
+
+printf 'stale-success\n' >"$tmp/out/result"
+run_env "$mkpkg" --artifact-file "$tmp/out/result" --no-download \
+	--output "$tmp/out" "$tmp/ok/recipe" \
+	>"$tmp/ok.stdout" 2>"$tmp/ok.stderr" || fail 'successful mkpkg failed'
+expected=$tmp/out/stream-ok-1.0-1-any.pkg.tar
+[ -f "$expected" ] || fail 'package archive was not created'
+[ "$(sed -n '1p' "$tmp/out/result")" = "$expected" ] ||
+	fail "artifact file did not contain the package path"
+[ "$(wc -l <"$tmp/out/result" | awk '{ print $1 }')" = 1 ] ||
+	fail 'artifact file was not a single pathname'
+omits "$(cat "$tmp/ok.stdout")" "$expected"
+contains "$(cat "$tmp/ok.stdout")" BUILD-LINE-ONE
+contains "$(cat "$tmp/ok.stdout")" BUILD-LINE-TWO
+contains "$(cat "$tmp/ok.stderr")" 'mkpkg: stream-ok 1.0-1'
+contains "$(cat "$tmp/ok.stderr")" 'mkpkg: running build phase'
+contains "$(cat "$tmp/ok.stderr")" 'mkpkg: built stream-ok-1.0-1-any.pkg.tar'
+omits "$(cat "$tmp/ok.stdout")" 'mkpkg:'
+
+# Direct human mode must not require another frontend, and --print-artifact
+# keeps the old stdout pathname contract when a script asks for it.
+run_env "$mkpkg" --print-artifact --no-download --output "$tmp/out-print" \
+	"$tmp/ok/recipe" >"$tmp/print.stdout" 2>"$tmp/print.stderr" ||
+	fail '--print-artifact build failed'
+print_path=$(awk 'NF { path = $0 } END { print path }' "$tmp/print.stdout")
+[ "$print_path" = "$tmp/out-print/stream-ok-1.0-1-any.pkg.tar" ] ||
+	fail '--print-artifact did not print the archive path'
+
+# --- failed build does not commit a result ---
+mkdir -p "$tmp/fail"
+cat >"$tmp/fail/recipe" <<'EOF'
+name        stream-fail
+version     1.0
+release     1
+arch        any
+description Artifact handoff failure
+build       printf '%s\n' BUILD-FAIL-LINE
+build       printf '%s\n' "runtime.c:381:17: error: 'struct context' has no member named 'state'" >&2
+build       exit 1
+install     mkdir -p "$PKG/usr/share"
+install     printf '%s\n' fail >"$PKG/usr/share/stream-fail"
+EOF
+
+printf 'stale-failure\n' >"$tmp/out/fail-result"
+set +e
+run_env "$mkpkg" --artifact-file "$tmp/out/fail-result" --no-download \
+	--output "$tmp/out" "$tmp/fail/recipe" \
+	>"$tmp/fail.stdout" 2>"$tmp/fail.stderr"
+fail_status=$?
+set -e
+[ "$fail_status" -eq 10 ] || fail "failed build returned $fail_status"
+contains "$(cat "$tmp/fail.stdout")" BUILD-FAIL-LINE
+contains "$(cat "$tmp/fail.stderr")" "has no member named 'state'"
+contains "$(cat "$tmp/fail.stderr")" 'mkpkg: build phase failed'
+[ ! -e "$tmp/out/fail-result" ] || [ ! -s "$tmp/out/fail-result" ] ||
+	fail 'failed build left a successful artifact result'
+
+# --- fetch command construction; no public network ---
+mkdir -p "$tmp/fakebin" "$tmp/fetch"
+payload=$tmp/payload.dat
+printf 'downloaded-bytes\n' >"$payload"
+payload_hash=$(sha256_file "$payload")
+cat >"$tmp/fetch/recipe" <<EOF
+name        fetchpkg
+version     1.0
+release     1
+arch        any
+description Download command construction
+source      https://example.invalid/payload.dat
+hash        sha256:$payload_hash
+install     mkdir -p "\$PKG/usr/share"
+install     cp "\$SRC/payload.dat" "\$PKG/usr/share/payload.dat"
+EOF
+
+cat >"$tmp/fakebin/curl" <<'EOF'
+#!/bin/sh
+printf '%s\n' "$*" >> "$CURL_LOG"
+out=
+prev=
+for arg
+do
+	if [ "$prev" = -o ]; then
+		out=$arg
+	fi
+	prev=$arg
+done
+[ -n "$out" ] || exit 2
+cp "$FAKE_PAYLOAD" "$out"
+EOF
+chmod +x "$tmp/fakebin/curl"
+
+CURL_LOG=$tmp/curl.log
+FAKE_PAYLOAD=$payload
+export CURL_LOG FAKE_PAYLOAD
+: >"$CURL_LOG"
+PATH="$tmp/fakebin:$PATH" run_env "$mkpkg" --artifact-file "$tmp/out/fetch-result" \
+	--output "$tmp/out" "$tmp/fetch/recipe" \
+	>"$tmp/fetch.stdout" 2>"$tmp/fetch.stderr" || fail 'fake-curl fetch build failed'
+contains "$(cat "$tmp/fetch.stderr")" 'mkpkg: fetching payload.dat'
+contains "$(cat "$tmp/fetch.stderr")" 'mkpkg: checksum ok'
+contains "$(cat "$CURL_LOG")" '-fL'
+contains "$(cat "$CURL_LOG")" '-sS'
+omits "$(cat "$CURL_LOG")" ' -# '
+[ "$(sed -n '1p' "$tmp/out/fetch-result")" = \
+	"$tmp/out/fetchpkg-1.0-1-any.pkg.tar" ] ||
+	fail 'fetched package did not publish its artifact path'
+
+# Cached source must not invoke the downloader again.
+: >"$CURL_LOG"
+PATH="$tmp/fakebin:$PATH" run_env "$mkpkg" --artifact-file "$tmp/out/fetch-result2" \
+	--output "$tmp/out2" "$tmp/fetch/recipe" \
+	>"$tmp/fetch2.stdout" 2>"$tmp/fetch2.stderr" || fail 'cached source rebuild failed'
+[ ! -s "$CURL_LOG" ] || fail 'cached source rebuild invoked curl'
+contains "$(cat "$tmp/fetch2.stderr")" 'mkpkg: source cache hit payload.dat'
+
+# Download failure must stay visible and must not publish an artifact.
+cat >"$tmp/fakebin/curl" <<'EOF'
+#!/bin/sh
+printf '%s\n' "$*" >> "$CURL_LOG"
+printf '%s\n' 'curl: (22) The requested URL returned error: 404' >&2
+exit 22
+EOF
+chmod +x "$tmp/fakebin/curl"
+printf 'stale-download\n' >"$tmp/out/dlfail-result"
+rm -rf "$tmp/cache/sources"
+mkdir -p "$tmp/cache/sources"
+set +e
+PATH="$tmp/fakebin:$PATH" run_env "$mkpkg" --artifact-file "$tmp/out/dlfail-result" \
+	--output "$tmp/out-dlfail" "$tmp/fetch/recipe" \
+	>"$tmp/dlfail.stdout" 2>"$tmp/dlfail.stderr"
+dl_status=$?
+set -e
+[ "$dl_status" -eq 9 ] || fail "download failure returned $dl_status"
+contains "$(cat "$tmp/dlfail.stderr")" 'download failed'
+[ ! -e "$tmp/out/dlfail-result" ] || [ ! -s "$tmp/out/dlfail-result" ] ||
+	fail 'download failure left a successful artifact result'
+
+# --- sget live output, cache, quiet, verbose, dependencies ---
+mkdir -p "$tmp/repo/libbar" "$tmp/repo/foo" "$tmp/repo/broken" "$tmp/repo/quietpkg"
+cat >"$tmp/repo/libbar/recipe" <<'EOF'
+name        libbar
+version     2.1
+release     1
+arch        any
+description Dependency with live build output
+build       printf '%s\n' BUILD-LINE-ONE
+build       printf '%s\n' BUILD-LINE-TWO
+install     mkdir -p "$PKG/usr/share/sps-test"
+install     printf '%s\n' libbar >"$PKG/usr/share/sps-test/libbar"
+EOF
+cat >"$tmp/repo/foo/recipe" <<'EOF'
+name        foo
+version     4.7
+release     2
+arch        any
+description Root package with a dependency
+depend      libbar
+build       printf '%s\n' FOO-BUILD-LINE
+install     mkdir -p "$PKG/usr/share/sps-test"
+install     printf '%s\n' foo >"$PKG/usr/share/sps-test/foo"
+EOF
+cat >"$tmp/repo/quietpkg/recipe" <<'EOF'
+name        quietpkg
+version     1.0
+release     1
+arch        any
+description Quiet-mode build output
+build       printf '%s\n' QUIET-SHOULD-HIDE
+install     mkdir -p "$PKG/usr/share/sps-test"
+install     printf '%s\n' quiet >"$PKG/usr/share/sps-test/quietpkg"
+EOF
+cat >"$tmp/repo/broken/recipe" <<'EOF'
+name        broken
+version     1.0
+release     1
+arch        any
+description Build failure through sget
+build       printf '%s\n' BROKEN-BUILD-LINE
+build       printf '%s\n' "runtime.c:381:17: error: 'struct context' has no member named 'state'" >&2
+build       exit 1
+install     mkdir -p "$PKG/usr/share/sps-test"
+install     printf '%s\n' broken >"$PKG/usr/share/sps-test/broken"
+EOF
+printf 'repo test %s 10\n' "$tmp/repo" >"$tmp/repos.conf"
+: >"$tmp/sps.conf"
+export SPS_CONFIG=$tmp/sps.conf
+export SPS_REPOS_CONFIG=$tmp/repos.conf
+rm -rf "$tmp/db" "$tmp/cache/packages" "$tmp/root/usr"
+mkdir -p "$tmp/db/installed" "$tmp/cache/packages" "$tmp/root"
+: >"$tmp/db/owners"
+: >"$tmp/db/world"
+: >"$tmp/db/history"
+
+src update >/dev/null
+sget install foo >"$tmp/sget.stdout" 2>"$tmp/sget.stderr" ||
+	fail 'sget install foo failed'
+sget_all=$(cat "$tmp/sget.stdout"; cat "$tmp/sget.stderr")
+contains "$sget_all" 'install libbar 2.1-1'
+contains "$sget_all" 'install foo 4.7-2'
+contains "$sget_all" BUILD-LINE-ONE
+contains "$sget_all" BUILD-LINE-TWO
+contains "$sget_all" FOO-BUILD-LINE
+contains "$sget_all" 'mkpkg: built libbar-2.1-1-any.pkg.tar'
+contains "$sget_all" 'pkin: installed libbar 2.1-1'
+contains "$sget_all" 'pkin: installed foo 4.7-2'
+[ "$(cat "$tmp/root/usr/share/sps-test/foo")" = foo ] || fail 'foo payload missing'
+
+# Cached binary package: no build phases, no fake progress.
+sget install --reinstall foo >"$tmp/cache.stdout" 2>"$tmp/cache.stderr" ||
+	fail 'cached reinstall failed'
+cache_all=$(cat "$tmp/cache.stdout"; cat "$tmp/cache.stderr")
+contains "$cache_all" 'sget: using cached foo-4.7-2-any.pkg.tar'
+omits "$cache_all" FOO-BUILD-LINE
+omits "$cache_all" 'running build phase'
+
+# Failed sget build keeps the compiler diagnostic and a nonzero status.
+set +e
+sget install broken >"$tmp/broken.stdout" 2>"$tmp/broken.stderr"
+broken_status=$?
+set -e
+[ "$broken_status" -eq 10 ] || fail "sget failed build returned $broken_status"
+broken_all=$(cat "$tmp/broken.stdout"; cat "$tmp/broken.stderr")
+contains "$broken_all" BROKEN-BUILD-LINE
+contains "$broken_all" "has no member named 'state'"
+contains "$broken_all" 'mkpkg: build phase failed'
+contains "$broken_all" 'sget: failed to build broken 1.0-1'
+
+# Quiet mode hides routine build output; failure still dumps the log.
+sget -q install quietpkg >"$tmp/quiet.stdout" 2>"$tmp/quiet.stderr" ||
+	fail 'quiet install failed'
+quiet_all=$(cat "$tmp/quiet.stdout"; cat "$tmp/quiet.stderr")
+contains "$quiet_all" 'install quietpkg 1.0-1'
+contains "$quiet_all" 'pkin: installed quietpkg 1.0-1'
+omits "$quiet_all" QUIET-SHOULD-HIDE
+omits "$quiet_all" 'running build phase'
+
+set +e
+sget -q install --reinstall --nodeps broken >"$tmp/qfail.stdout" 2>"$tmp/qfail.stderr"
+qfail_status=$?
+set -e
+[ "$qfail_status" -eq 10 ] || fail "quiet failed build returned $qfail_status"
+qfail_all=$(cat "$tmp/qfail.stdout"; cat "$tmp/qfail.stderr")
+contains "$qfail_all" BROKEN-BUILD-LINE
+contains "$qfail_all" 'sget: failed to build broken 1.0-1'
+
+# Verbose mode reports cache and recipe decisions without wrapping compiler lines.
+sget -v install --reinstall foo >"$tmp/verbose.stdout" 2>"$tmp/verbose.stderr" ||
+	fail 'verbose reinstall failed'
+contains "$(cat "$tmp/verbose.stderr")" 'sget: selected '
+contains "$(cat "$tmp/verbose.stderr")" 'sget: binary cache hit'
+
+# Interrupted mkpkg must not leave a successful artifact indication.
+mkdir -p "$tmp/slow"
+cat >"$tmp/slow/recipe" <<'EOF'
+name        slowpkg
+version     1.0
+release     1
+arch        any
+description Interrupt during build
+build       printf '%s\n' started >"$SPS_ROOT/build-started"
+build       sleep 5
+install     mkdir -p "$PKG/usr/share"
+install     printf '%s\n' slow >"$PKG/usr/share/slowpkg"
+EOF
+printf 'stale-int\n' >"$tmp/out/int-result"
+# Background jobs ignore SIGINT in some shells, so this uses TERM. mkpkg's
+# TERM and INT traps both exit through the same cleanup path.
+SPS_ROOT=$tmp/root SPS_DB=$tmp/db SPS_CACHE=$tmp/cache \
+SPS_BUILD=$tmp/build SPS_CONFIG=/dev/null SPS_REPOS_CONFIG=/dev/null \
+SPS_LIBDIR=$project_dir/lib SPS_COMPRESSION=none \
+	"$mkpkg" --artifact-file "$tmp/out/int-result" --no-download \
+	--output "$tmp/out-int" "$tmp/slow/recipe" \
+	>"$tmp/int.stdout" 2>"$tmp/int.stderr" &
+int_pid=$!
+i=0
+while [ "$i" -lt 20 ]; do
+	if [ -f "$tmp/root/build-started" ]; then
+		break
+	fi
+	sleep 1
+	i=$((i + 1))
+done
+[ -f "$tmp/root/build-started" ] ||
+	fail 'slow build did not start before interrupt'
+kill -TERM "$int_pid" 2>/dev/null || :
+set +e
+wait "$int_pid"
+int_status=$?
+set -e
+[ "$int_status" -ne 0 ] || fail 'interrupted mkpkg returned success'
+[ ! -e "$tmp/out/int-result" ] || [ ! -s "$tmp/out/int-result" ] ||
+	fail 'interrupted mkpkg left a successful artifact result'
+
+printf '%s\n' 'test_output: ok'
diff --git a/tests/test_recipe_contract.sh b/tests/test_recipe_contract.sh
old mode 100644
new mode 100755
index dd61eaa..4fc62b0
--- a/tests/test_recipe_contract.sh
+++ b/tests/test_recipe_contract.sh
@@ -46,6 +46,8 @@ run_builder()
     build_label=$1
     build_recipe=$2
     mkdir -p "$tmp/out-$build_label"
+    result=$tmp/out-$build_label/artifact
+    rm -f "$result"
     SPS_ROOT=$tmp/root \
     SPS_DB=$tmp/db \
     SPS_CACHE=$tmp/cache \
@@ -54,7 +56,9 @@ run_builder()
     SPS_REPOS_CONFIG=/dev/null \
     SPS_COMPRESSION=none \
     SPS_ARCH=testarch \
-        "$mkpkg" --no-download --output "$tmp/out-$build_label" "$build_recipe"
+        "$mkpkg" --artifact-file "$result" --no-download \
+            --output "$tmp/out-$build_label" "$build_recipe" || return $?
+    sed -n '1p' "$result"
 }
 
 expect_builder_failure()
diff --git a/tests/test_release.sh b/tests/test_release.sh
index b588d58..51d6fd7 100755
--- a/tests/test_release.sh
+++ b/tests/test_release.sh
@@ -37,7 +37,10 @@ install printf '%s\\n' '$config_text' > "\$PKG/etc/$name.conf"
 install printf '%s\\n' '$version' > "\$PKG/var/lib/$name/data"
 install chmod 0700 "\$PKG/var/lib/$name"
 EOF_RECIPE
-    run "$project_dir/bin/mkpkg" --compression none --output "$tmp/pkg" "$dir/recipe"
+    rm -f "$tmp/pkg/artifact"
+    run "$project_dir/bin/mkpkg" --artifact-file "$tmp/pkg/artifact" \
+        --compression none --output "$tmp/pkg" "$dir/recipe" || return $?
+    sed -n '1p' "$tmp/pkg/artifact"
 }
 
 recipe=$tmp/recipe
@@ -80,7 +83,12 @@ EOF_HOOK
 done
 sed 's/version 2.0/version 3.0/' "$recipe/recipe" >"$recipe/recipe.new"
 mv "$recipe/recipe.new" "$recipe/recipe"
-artifact3=$(run "$project_dir/bin/mkpkg" --compression none --output "$tmp/pkg" "$recipe/recipe")
+artifact3=$(
+    rm -f "$tmp/pkg/artifact"
+    run "$project_dir/bin/mkpkg" --artifact-file "$tmp/pkg/artifact" \
+        --compression none --output "$tmp/pkg" "$recipe/recipe" || exit $?
+    sed -n '1p' "$tmp/pkg/artifact"
+)
 run "$project_dir/bin/pkin" "$artifact3" >/dev/null
 [ "$(sed -n '1p' "$root/hook.log")" = pre-install ] || fail 'pre-install hook did not run'
 [ "$(sed -n '2p' "$root/hook.log")" = post-install ] || fail 'post-install hook did not run'
@@ -123,7 +131,12 @@ install printf '%s\n' hookfail > "$PKG/usr/share/hookfail"
 EOF_HOOKFAIL_RECIPE
 printf '%s\n' 'exit 1' >"$hookfail_dir/hooks/post-install"
 printf '%s\n' 'exit 1' >"$hookfail_dir/hooks/post-remove"
-hookfail_artifact=$(run "$project_dir/bin/mkpkg" --compression none --output "$tmp/pkg" "$hookfail_dir/recipe")
+hookfail_artifact=$(
+    rm -f "$tmp/pkg/artifact"
+    run "$project_dir/bin/mkpkg" --artifact-file "$tmp/pkg/artifact" \
+        --compression none --output "$tmp/pkg" "$hookfail_dir/recipe" || exit $?
+    sed -n '1p' "$tmp/pkg/artifact"
+)
 set +e
 run "$project_dir/bin/pkin" "$hookfail_artifact" >/dev/null 2>"$tmp/hookfail-install.err"
 status=$?