From ade0d396ed3a143dc1d5a9c597805b1ae9b73410 Mon Sep 17 00:00:00 2001 From: Aaron Bockelie Date: Tue, 16 Jun 2026 15:00:41 -0500 Subject: [PATCH 1/3] =?UTF-8?q?feat(appliance):=20--kernel=20generic=20var?= =?UTF-8?q?iant=20=E2=80=94=20hi-res=20console=20+=20AHCI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Debian cloud kernel ships no DRM/framebuffer (console locked at 80x25) or AHCI drivers — fine headless, but the homelab/desktop user reads the console in a VirtualBox/VMware window and it never occurs to them to switch to tty1. Add --kernel cloud|generic (default cloud, unchanged). generic swaps cloud→generic kernel, purges the cloud kernel, and sets GRUB_GFXMODE/gfxpayload=keep for a real framebuffer console (validated: 160x50 vs 80x25, fb0 + DRM present). Also brings AHCI/SATA — the ADR-119 CD-drive carrier lever. Generic artifacts get a '-generic' name suffix so both variants coexist. --- appliance/build-appliance.sh | 46 +++++++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/appliance/build-appliance.sh b/appliance/build-appliance.sh index 9a48b353d..a11017b14 100755 --- a/appliance/build-appliance.sh +++ b/appliance/build-appliance.sh @@ -28,6 +28,11 @@ # --size SIZE root disk size (default: 20G) # --output DIR output directory (default: ./appliance/out) # --ova also emit an OVA (qcow2 -> vmdk -> OVF wrap) +# --kernel FLAVOR cloud (default) | generic. cloud = lean, headless/cloud +# (console stays 80x25 VGA — nobody looks at it). generic = +# homelab/desktop: real framebuffer console (legible in a +# VirtualBox/VMware window) + AHCI/SATA. generic artifacts +# get a "-generic" name suffix. # --no-cockpit skip the Cockpit host console (:9090) # --help # @@ -48,6 +53,7 @@ DISK_SIZE="20G" OUTPUT_DIR="${SCRIPT_DIR}/out" MAKE_OVA="false" WITH_COCKPIT="true" +KERNEL="cloud" CACHE_DIR="${SCRIPT_DIR}/.cache" # --- Parse args -------------------------------------------------------------- @@ -59,12 +65,20 @@ while [ $# -gt 0 ]; do --size) DISK_SIZE="$2"; shift 2 ;; --output) OUTPUT_DIR="$2"; shift 2 ;; --ova) MAKE_OVA="true"; shift ;; + --kernel) KERNEL="$2"; shift 2 ;; --no-cockpit) WITH_COCKPIT="false"; shift ;; - --help|-h) sed -n '2,42p' "${BASH_SOURCE[0]}"; exit 0 ;; + --help|-h) sed -n '2,46p' "${BASH_SOURCE[0]}"; exit 0 ;; *) echo "Unknown option: $1" >&2; exit 1 ;; esac done +case "${KERNEL}" in + cloud|generic) ;; + *) echo "Unknown --kernel '${KERNEL}' (expected: cloud | generic)" >&2; exit 1 ;; +esac +# generic artifacts carry a "-generic" suffix so both variants coexist in out/. +KSUF=""; [ "${KERNEL}" = "generic" ] && KSUF="-generic" + [ -n "${VERSION}" ] || VERSION="$(cd "${REPO_ROOT}" && git describe --tags --always --dirty 2>/dev/null || echo dev)" log() { echo -e "\033[0;34m[build]\033[0m $*"; } @@ -102,7 +116,7 @@ log "archiving repo at ref '${REF}'..." (cd "${REPO_ROOT}" && git archive --format=tar --prefix=kg/ "${REF}") > "${REPO_TAR}" # --- 3. Copy base -> working disk and resize --------------------------------- -OUT_QCOW="${OUTPUT_DIR}/kg-appliance-${VERSION}.qcow2" +OUT_QCOW="${OUTPUT_DIR}/kg-appliance-${VERSION}${KSUF}.qcow2" log "preparing working disk ${OUT_QCOW} (${DISK_SIZE})..." qemu-img convert -O qcow2 "${BASE_IMG}" "${OUT_QCOW}" qemu-img resize "${OUT_QCOW}" "${DISK_SIZE}" @@ -125,12 +139,37 @@ PKGS="qemu-guest-agent,ca-certificates,curl,python3,openssl,git,jq" [ "${WITH_COCKPIT}" = "true" ] && PKGS="${PKGS},cockpit" DOCKER_PKGS="docker-ce,docker-ce-cli,containerd.io,docker-buildx-plugin,docker-compose-plugin" +# --- Kernel flavor (generic only) -------------------------------------------- +# The Debian *cloud* kernel ships NO DRM/framebuffer drivers, so the console is +# locked at 80x25 VGA and a higher GRUB_GFXMODE is silently ignored. That is fine +# for a headless cloud box nobody looks at — but the homelab/desktop user runs +# this in a VirtualBox/VMware window and reads the console *there* (it never +# occurs to them to switch to tty1 or SSH). For them, install the generic kernel +# (virtio-gpu / bochs / vmwgfx DRM) and request a real framebuffer console via +# GRUB; gfxpayload=keep carries the mode into the kernel. Generic also brings +# AHCI/SATA — the "attach the config ISO to a CD drive" lever (ADR-119). +# Expands to nothing for --kernel cloud. +KERNEL_ARGS=() +if [ "${KERNEL}" = "generic" ]; then + KERNEL_ARGS=( + --install 'linux-image-amd64' + # Purge whatever cloud-kernel packages are actually installed (meta + + # versioned), robust to the exact name; xargs -r no-ops if none match. + --run-command "dpkg-query -W -f='\${Package}\n' 'linux-image-*cloud*' 2>/dev/null | xargs -r apt-get purge -y; apt-get autoremove --purge -y" + --append-line '/etc/default/grub:GRUB_GFXMODE=1280x1024' + --append-line '/etc/default/grub:GRUB_GFXPAYLOAD_LINUX=keep' + --run-command 'update-grub' + ) +fi + VC_ARGS=( -a "${OUT_QCOW}" --network --hostname "kg-appliance" --run-command 'apt-get update' --install "${PKGS}" + # Kernel flavor: generic swaps cloud→generic + hi-res console; cloud = no-op. + "${KERNEL_ARGS[@]}" # Docker's official apt repo (keyring + source), then install docker-ce. --run-command 'install -m 0755 -d /etc/apt/keyrings' --run-command 'curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc' @@ -176,6 +215,7 @@ VC_ARGS=( log "customizing image (no VM boot; --network lets apt fetch base + docker repo)..." [ "${WITH_COCKPIT}" = "true" ] && log " including Cockpit host console (:9090)" || log " Cockpit disabled (--no-cockpit)" +[ "${KERNEL}" = "generic" ] && log " generic kernel + hi-res framebuffer console (homelab/desktop variant)" || log " cloud kernel (lean, headless — console stays 80x25)" virt-customize "${VC_ARGS[@]}" # --- 5. Sparsify ------------------------------------------------------------- @@ -210,7 +250,7 @@ if [ "${MAKE_OVA}" = "true" ]; then echo "SHA1(kg-appliance-disk1.vmdk)= $(sha1sum kg-appliance-disk1.vmdk | cut -d' ' -f1)" } > kg-appliance.mf ) - OUT_OVA="${OUTPUT_DIR}/kg-appliance-${VERSION}.ova" + OUT_OVA="${OUTPUT_DIR}/kg-appliance-${VERSION}${KSUF}.ova" ( cd "${OVA_TMP}" && tar -cf "${OUT_OVA}" kg-appliance.ovf kg-appliance.mf kg-appliance-disk1.vmdk ) log "OVA ready: ${OUT_OVA}" fi From 3da1573a863d9b2c615018b2ed63275c34be8d82 Mon Sep 17 00:00:00 2001 From: Aaron Bockelie Date: Tue, 16 Jun 2026 15:00:41 -0500 Subject: [PATCH 2/3] feat(publish): ship both kernel variants; document in ADR-119 + README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit publish.sh appliance now builds + attaches BOTH variants (cloud unsuffixed, generic -generic) to the release. ADR-119 §E records the two-variant decision (cloud default for the headless/cloud common case; generic for the console-window user) and the README documents --kernel + which variant for whom. --- appliance/README.md | 11 +++ ...n-delivery-and-first-boot-orchestration.md | 38 ++++++++-- scripts/publish.sh | 74 ++++++++++++------- 3 files changed, 89 insertions(+), 34 deletions(-) diff --git a/appliance/README.md b/appliance/README.md index 2b8f2fc3d..22b4814d6 100644 --- a/appliance/README.md +++ b/appliance/README.md @@ -41,6 +41,9 @@ boot during the build. # also emit an OVA (VMware / VirtualBox) ./appliance/build-appliance.sh --ova +# generic kernel — hi-res console for a hypervisor window (homelab/desktop) +./appliance/build-appliance.sh --ova --kernel generic + # pin a ref / label; opt out of the Cockpit host console ./appliance/build-appliance.sh --ref v0.15.1 --version 0.15.1 --ova --no-cockpit ``` @@ -48,6 +51,14 @@ boot during the build. Artifacts land in `appliance/out/`. The Debian base is cached in `appliance/.cache/` between builds (both are git-ignored). +**Kernel variants** (`--kernel`, default `cloud`) — the image ships in two +flavors, and `publish.sh appliance` builds both (ADR-119): + +| Variant | Console | Pick it if… | +|---------|---------|-------------| +| `cloud` (default, unsuffixed) | 80×25 VGA | headless / real-cloud — you never look at the VM console (least overhead). | +| `generic` (`-generic` suffix) | hi-res framebuffer (160×50+) | you run it in a **VirtualBox/VMware/Hyper-V window** and read the console there. Also enables AHCI/SATA (CD-drive config carrier). | + The qcow2/OVA is built **locally** and attached to the matching GitHub Release, together with a `SHA256SUMS` file — `./publish.sh appliance` automates the build, `.xz` compression, checksums, and upload (the previously-manual step). Same diff --git a/docs/architecture/infrastructure/ADR-119-appliance-configuration-delivery-and-first-boot-orchestration.md b/docs/architecture/infrastructure/ADR-119-appliance-configuration-delivery-and-first-boot-orchestration.md index f19a037ab..c7075f4c9 100644 --- a/docs/architecture/infrastructure/ADR-119-appliance-configuration-delivery-and-first-boot-orchestration.md +++ b/docs/architecture/infrastructure/ADR-119-appliance-configuration-delivery-and-first-boot-orchestration.md @@ -81,11 +81,13 @@ predictable address (`/dev/vdb`) so the user points an existing slot at their `.img` rather than adding a controller. *(Follow-on, not yet shipped: the current `ovf/kg-appliance.ovf.template` declares only the primary disk; until the empty slot lands, the carrier is attached as an added virtio disk — the same friction -this is meant to remove. Tracked with the first-boot orchestration work below.)* The cloud kernel has no AHCI, so the bus is -virtio ("attach this `.img` as a disk"); we accept that over a one-click -"attach ISO to CD" because broad compatibility beats the convenience of a kernel -swap. (Revisitable — an AHCI kernel later would unlock the CD-drive UX without -changing this model.) +this is meant to remove. Tracked with the first-boot orchestration work below.)* + +The bus the carrier hangs off is governed by the **kernel flavor**, which is now a +shipped distribution variant (see "Kernel variants" below). The default **cloud** +kernel has no AHCI, so its carrier is a virtio disk ("attach this `.img` as a +disk"). The **generic** variant brings AHCI/SATA, unlocking the one-click +"attach the config ISO to a CD drive" UX for the hypervisors that expect it. ### B — First boot is presence-driven, not interactive @@ -146,6 +148,27 @@ in the console's *export* flow ("pack and download the config out for re-use"). emits a stock NoCloud seed any cloud-init consumes — it is "a tool to generate cloud-init user-data easily," not a custom format. +### E — Kernel variants: cloud (default) and generic (homelab) + +The Debian *cloud* kernel ships no DRM/framebuffer or AHCI drivers — fine for a +headless cloud instance, but it locks the console to 80×25 VGA. That matters +because of *who looks at the console*: the homelab/desktop user runs the appliance +in a VirtualBox/VMware/Hyper-V window and reads the console **there** — it never +occurs to them to switch to tty1 or SSH. For them the cramped 80×25 *is* the +product. So the image ships as **two variants**, selected at build time +(`build-appliance.sh --kernel`): + +| Variant | Kernel | Console | For | +|---------|--------|---------|-----| +| **cloud** (default) | `linux-image-cloud-amd64` | 80×25 VGA (nobody looks) | headless / real-cloud; least overhead. The "expected shape." | +| **generic** (`-generic` suffix) | `linux-image-amd64` + `GRUB_GFXMODE`/`gfxpayload=keep` | real hi-res framebuffer (legible in a hypervisor window) | homelab/desktop: the person who needs a hand and reads the console window. Also gains AHCI/SATA → the CD-drive carrier UX (§A). | + +cloud stays the default because the appliance's headless/edge path is the common +one and cloud users have other access; generic is the opt-in for the +console-window user. `publish.sh appliance` ships **both** OVAs/qcow2s on the +release, and the install docs point VirtualBox/VMware users (the console-window +crowd) at the **generic** artifact. + ## Consequences ### Positive @@ -178,8 +201,9 @@ cloud-init user-data easily," not a custom format. ### Neutral - cloud-init is used as-is (standard datasources), neither demoted nor extended. -- The bus/kernel trade is revisitable (AHCI kernel → CD-drive UX) without touching - this model. +- The bus/kernel trade is resolved by shipping **two kernel variants** (§E): cloud + (default, virtio-only) and generic (AHCI + hi-res console). Neither changes this + config-delivery model; they differ only in drivers and console. - The builder lands as an operator verb (ADR-211 sink), not a new tool. - **Convergence contract (ADR-103).** The OVA is a thin *bootstrap seed*: downloaded once, run, then kept current by `operator.sh upgrade` pulling fresh diff --git a/scripts/publish.sh b/scripts/publish.sh index d2d7d1703..92acfeb93 100755 --- a/scripts/publish.sh +++ b/scripts/publish.sh @@ -1094,11 +1094,13 @@ cmd_appliance() { local out_dir="$PROJECT_ROOT/appliance/out" local label="$VERSION" local tag="v${VERSION}" - local qcow2="$out_dir/kg-appliance-${label}.qcow2" - local qcow2_xz="${qcow2}.xz" - local ova="$out_dir/kg-appliance-${label}.ova" - echo -e "${BOLD}Publishing appliance bootstrap image${NC}" + # Two kernel variants ship per release (ADR-119): cloud (default, unsuffixed) + # and generic (homelab/desktop, "-generic" suffix, hi-res console + AHCI). + # Each entry is ":<--kernel flavor>". + local -a variants=( ":cloud" "-generic:generic" ) + + echo -e "${BOLD}Publishing appliance bootstrap images (cloud + generic)${NC}" echo -e " Version: ${BLUE}${label}${NC} → GitHub release ${BLUE}${tag}${NC}" [ "$DRY_RUN" = "true" ] && echo -e " Mode: ${YELLOW}DRY RUN${NC}" echo "" @@ -1111,41 +1113,59 @@ cmd_appliance() { echo -e "${RED}gh (GitHub CLI) not found${NC}"; echo " Install: https://cli.github.com/"; exit 1 fi - # --- Build (unless --skip-build) ----------------------------------------- + # --- Build each variant (unless --skip-build) ---------------------------- if [ "$SKIP_BUILD" = "false" ]; then - echo -e "${BLUE}→ Building OVA (kg-appliance-${label})...${NC}" - "$PROJECT_ROOT/appliance/build-appliance.sh" --ova --version "$label" - echo -e "${GREEN}✓ Build complete${NC}" + local v suf kern + for v in "${variants[@]}"; do + suf="${v%%:*}"; kern="${v##*:}" + echo -e "${BLUE}→ Building ${kern} variant (kg-appliance-${label}${suf})...${NC}" + "$PROJECT_ROOT/appliance/build-appliance.sh" --ova --version "$label" --kernel "$kern" + done + echo -e "${GREEN}✓ Builds complete${NC}" echo "" fi - if [ ! -f "$ova" ]; then - echo -e "${RED}OVA not found: $ova${NC}" - echo -e " ${DIM}Build it: appliance/build-appliance.sh --ova --version $label${NC}" - exit 1 - fi + + # --- Resolve the asset set for both variants ----------------------------- + # OVA always; the qcow2's .xz only when a source qcow2 exists this run (a + # --skip-build OVA-only run ships just the OVA, never a stale leftover .xz). + local -a assets=() + local v suf base ova qcow2 + for v in "${variants[@]}"; do + suf="${v%%:*}"; base="kg-appliance-${label}${suf}" + ova="$out_dir/${base}.ova"; qcow2="$out_dir/${base}.qcow2" + if [ ! -f "$ova" ]; then + echo -e "${RED}OVA not found: $ova${NC}" + echo -e " ${DIM}Build it: appliance/build-appliance.sh --ova --version $label --kernel ${v##*:}${NC}" + exit 1 + fi + assets+=( "$ova" ) + [ -f "$qcow2" ] && assets+=( "${qcow2}.xz" ) + done # --- Dry run: report intent only, NO side effects (no xz, no SHA file) --- if [ "$DRY_RUN" = "true" ]; then echo -e "${DIM}Would compress, checksum, and upload to release ${tag}:${NC}" - echo " $(basename "$ova") ($(du -h "$ova" | cut -f1))" - [ -f "$qcow2" ] && echo " $(basename "$qcow2_xz") (compressed from $(du -h "$qcow2" | cut -f1) qcow2)" + for v in "${variants[@]}"; do + suf="${v%%:*}"; base="kg-appliance-${label}${suf}" + echo " ${base}.ova" + [ -f "$out_dir/${base}.qcow2" ] && echo " ${base}.qcow2.xz" + done echo " SHA256SUMS" return 0 fi - # --- Compress the qcow2 FRESH every run ----------------------------------- - # The .xz name is keyed on the clean VERSION, so a stale same-VERSION .xz - # from a prior build must never be reused (xz -f overwrites). Only publish a - # .xz when we have a source qcow2 *this* run — a --skip-build OVA-only run - # ships just the OVA, never a leftover .xz. - if [ -f "$qcow2" ]; then - echo -e "${BLUE}→ Compressing qcow2 → .xz (a few minutes)...${NC}" - xz -T0 -k -f "$qcow2" - fi + # --- Compress each variant's qcow2 FRESH every run ------------------------ + # The .xz name is keyed on the clean VERSION+variant, so a stale same-name .xz + # from a prior build must never be reused (xz -f overwrites). + for v in "${variants[@]}"; do + suf="${v%%:*}"; qcow2="$out_dir/kg-appliance-${label}${suf}.qcow2" + if [ -f "$qcow2" ]; then + echo -e "${BLUE}→ Compressing $(basename "$qcow2") → .xz (a few minutes)...${NC}" + xz -T0 -k -f "$qcow2" + fi + done # --- Checksums over exactly the binary assets we publish ----------------- - local -a assets=( "$ova" ) - [ -f "$qcow2" ] && assets+=( "$qcow2_xz" ) echo -e "${BLUE}→ Writing SHA256SUMS${NC}" ( cd "$out_dir" && sha256sum $(for a in "${assets[@]}"; do basename "$a"; done) > SHA256SUMS ) sed 's/^/ /' "$out_dir/SHA256SUMS" @@ -1163,7 +1183,7 @@ cmd_appliance() { echo -e "${BLUE}→ Uploading ${#assets[@]} image asset(s) to ${tag}${NC}" gh release upload "$tag" "${assets[@]}" --clobber gh release upload "$tag" "$out_dir/SHA256SUMS" --clobber - echo -e "${GREEN}✓ Published appliance bootstrap image to release ${tag}${NC}" + echo -e "${GREEN}✓ Published ${#assets[@]} appliance asset(s) — cloud + generic — to release ${tag}${NC}" echo -e " ${DIM}Verify: gh release download ${tag} -p 'kg-appliance-*' -p SHA256SUMS && sha256sum -c SHA256SUMS${NC}" } From b6847f66d15aa1ab6f4c80e609fea02709ff2ac8 Mon Sep 17 00:00:00 2001 From: Aaron Bockelie Date: Tue, 16 Jun 2026 17:11:31 -0500 Subject: [PATCH 3/3] =?UTF-8?q?fix(appliance):=20address=20#533=20review?= =?UTF-8?q?=20=E2=80=94=20guard=20kernel=20swap,=20graceful=20skip-build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - C1: cloud-kernel purge uses '&&' (not ';') so a failed purge aborts instead of being masked by autoremove; add a hard post-condition that a bootable non-cloud kernel survives the swap (fail loud rather than ship a kernel-less image). - M1: publish.sh --skip-build now warns+skips a variant whose OVA is absent and publishes whatever IS ready (was: abort the whole run); hard-fail only post-build or when zero OVAs found. Dry-run reports the resolved asset set. --- appliance/build-appliance.sh | 8 +++++++- scripts/publish.sh | 15 ++++++++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/appliance/build-appliance.sh b/appliance/build-appliance.sh index a11017b14..9e25c1887 100755 --- a/appliance/build-appliance.sh +++ b/appliance/build-appliance.sh @@ -155,7 +155,13 @@ if [ "${KERNEL}" = "generic" ]; then --install 'linux-image-amd64' # Purge whatever cloud-kernel packages are actually installed (meta + # versioned), robust to the exact name; xargs -r no-ops if none match. - --run-command "dpkg-query -W -f='\${Package}\n' 'linux-image-*cloud*' 2>/dev/null | xargs -r apt-get purge -y; apt-get autoremove --purge -y" + # Use '&&' (not ';') so a failed purge aborts the build rather than being + # masked by autoremove's exit status. + --run-command "dpkg-query -W -f='\${Package}\n' 'linux-image-*cloud*' 2>/dev/null | xargs -r apt-get purge -y && apt-get autoremove --purge -y" + # Hard post-condition (C1): a bootable NON-cloud kernel must survive the + # swap, or we'd ship a kernel-less / cloud-only image that the build still + # reports as success. Fail loud — independent of the op ordering above. + --run-command 'ls /boot/vmlinuz-*-amd64 2>/dev/null | grep -qv cloud || { echo "FATAL: generic kernel swap left no bootable non-cloud kernel"; exit 1; }' --append-line '/etc/default/grub:GRUB_GFXMODE=1280x1024' --append-line '/etc/default/grub:GRUB_GFXPAYLOAD_LINUX=keep' --run-command 'update-grub' diff --git a/scripts/publish.sh b/scripts/publish.sh index 92acfeb93..29339b414 100755 --- a/scripts/publish.sh +++ b/scripts/publish.sh @@ -1134,6 +1134,12 @@ cmd_appliance() { suf="${v%%:*}"; base="kg-appliance-${label}${suf}" ova="$out_dir/${base}.ova"; qcow2="$out_dir/${base}.qcow2" if [ ! -f "$ova" ]; then + # Post-build, a missing OVA means the build silently failed → hard stop. + # With --skip-build, publish whatever IS ready: warn and skip (M1). + if [ "$SKIP_BUILD" = "true" ]; then + echo -e "${YELLOW}⚠ ${base}.ova not present — skipping ${v##*:} variant (--skip-build)${NC}" + continue + fi echo -e "${RED}OVA not found: $ova${NC}" echo -e " ${DIM}Build it: appliance/build-appliance.sh --ova --version $label --kernel ${v##*:}${NC}" exit 1 @@ -1141,15 +1147,14 @@ cmd_appliance() { assets+=( "$ova" ) [ -f "$qcow2" ] && assets+=( "${qcow2}.xz" ) done + if [ "${#assets[@]}" -eq 0 ]; then + echo -e "${RED}No appliance OVAs found to publish (build first, or drop --skip-build).${NC}"; exit 1 + fi # --- Dry run: report intent only, NO side effects (no xz, no SHA file) --- if [ "$DRY_RUN" = "true" ]; then echo -e "${DIM}Would compress, checksum, and upload to release ${tag}:${NC}" - for v in "${variants[@]}"; do - suf="${v%%:*}"; base="kg-appliance-${label}${suf}" - echo " ${base}.ova" - [ -f "$out_dir/${base}.qcow2" ] && echo " ${base}.qcow2.xz" - done + for a in "${assets[@]}"; do echo " $(basename "$a")"; done echo " SHA256SUMS" return 0 fi