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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ jobs:
- name: Run bats (ublue-image-info.sh)
run: bats tests/test_ublue_image_info.bats

- name: Run bats (ublue-image-resolve)
run: bats tests/test_ublue_image_resolve.bats

- name: Run bats (profile.d — caffeinate.sh + uutils.sh)
run: bats tests/test_profile_d.bats

Expand Down
1 change: 1 addition & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ test:
bats tests/test_ublue_fastfetch.bats
bats tests/test_ublue_motd.bats
bats tests/test_ublue_image_info.bats
bats tests/test_ublue_image_resolve.bats
bats tests/test_profile_d.bats
bats tests/test_dynamic_wallpaper.bats
bats tests/test_geoclue_latitude.bats
Expand Down
3 changes: 2 additions & 1 deletion docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ Scripts exempt from behavioral testing (shellcheck-only):
| `usr/share/ublue-os/bling/env.sh` | Sourced helper — sets env vars only |
| `usr/share/ublue-os/user-setup.hooks.d/20-dynamic-wallpaper.sh` | One-shot hook — logic tested indirectly via setup integration tests |
| `usr/bin/ublue-motd` | Display-only wrapper — cosmetic tput/glow call, no decision logic |
| `usr/bin/ublue-image-info.sh` | Read-only reporting wrapper — jq + rpm-ostree status, no branching that affects system state |
| `usr/bin/ublue-image-info.sh` | Read-only reporting wrapper — delegates resolution to `ublue-image-resolve` (tested separately) plus rpm-ostree status |

**Adding an exemption:** add a row to this table with a one-sentence justification.
Do not add exemptions for scripts with branching logic.
Expand Down Expand Up @@ -90,6 +90,7 @@ Do not add exemptions for scripts with branching logic.
| `tests/test_changelog.bats` | `changelog.just` — LTS/non-LTS repo selection, URL construction, exit behaviour |
| `tests/test_ublue_fastfetch.bats` | `ublue-fastfetch` — config reads, shuffle branch, DEFAULT_THEME export to ublue-bling-fastfetch |
| `tests/test_theming_hook.bats` | `10-theming.sh` — Framework/Thelio branches and setup idempotency |
| `tests/test_ublue_image_resolve.bats` | `ublue-image-resolve` — bootc-status-first tag/name/ref resolution, digest and ported-registry refs, image-info.json fallback |

## Quality Epic

Expand Down
24 changes: 24 additions & 0 deletions docs/skills/shell-scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,30 @@ Apply to any script reading system files via stdin redirect.

---

### image-info.json is build-time state — use `ublue-image-resolve` for live identity

`/usr/share/ublue-os/image-info.json` is baked when the image is built, so it
describes what the filesystem was *built* as, not what is booted. After a rebase
it lies (see [#820](https://github.com/projectbluefin/common/issues/820)).

Ask `/usr/libexec/ublue-image-resolve` instead. It reads
`.status.booted.image.image.image` from `bootc status --json` and falls back to
the baked file when bootc is absent, non-zero, or reports no booted image.

```bash
IMAGE_RESOLVE="${IMAGE_RESOLVE:-/usr/libexec/ublue-image-resolve}"
IMAGE_TAG="$("${IMAGE_RESOLVE}" image-tag)" # stable
IMAGE_NAME="$("${IMAGE_RESOLVE}" image-name)" # bluefin-lts
IMAGE_PATH="$("${IMAGE_RESOLVE}" image-path)" # ghcr.io/projectbluefin/bluefin-lts
```

Never split a ref on the first `:` — registry ports (`registry:5000/foo:tag`)
and digests (`repo:tag@sha256:...`) both break that. The resolver handles both.

In bats, mock `bootc` onto PATH and point `IMAGE_RESOLVE` at the real script.

---

### Assert env-var export against the subshell consumer, not exec

`exec` inherits all shell variables whether exported or not — asserting `DEFAULT_THEME`
Expand Down
9 changes: 5 additions & 4 deletions system_files/bluefin/usr/share/ublue-os/just/changelog.just
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@ changelogs:
exec bctl changelogs
fi

# Get Image Tag
IMAGE_INFO_FILE="${IMAGE_INFO_FILE:-/usr/share/ublue-os/image-info.json}"
TAG="$(jq -r '.["image-tag"]' < "${IMAGE_INFO_FILE}")"
# Get Image Tag from the live system (bootc status), not the build-time
# image-info.json, which goes stale after a rebase.
IMAGE_RESOLVE="${IMAGE_RESOLVE:-/usr/libexec/ublue-image-resolve}"
TAG="$("${IMAGE_RESOLVE}" image-tag || true)"

# Select the correct upstream repo based on image stream
IMAGE_NAME="$(jq -r '.["image-name"] // empty' < "${IMAGE_INFO_FILE}")"
IMAGE_NAME="$("${IMAGE_RESOLVE}" image-name || true)"
if [[ "$IMAGE_NAME" == "dakota" ]]; then
REPO="projectbluefin/dakota"
elif [[ "$IMAGE_NAME" =~ ^bluefin-lts ]] || [[ "$TAG" =~ ^lts ]]; then
Expand Down
10 changes: 5 additions & 5 deletions system_files/bluefin/usr/share/ublue-os/just/system.just
Original file line number Diff line number Diff line change
Expand Up @@ -245,11 +245,11 @@ toggle-testing:
exec bctl toggle-testing
fi
set -euo pipefail
IMAGE_INFO_FILE="${IMAGE_INFO_FILE:-/usr/share/ublue-os/image-info.json}"
IMAGE_TAG="$(jq -r '."image-tag"' < "${IMAGE_INFO_FILE}")"
IMAGE_REF="$(jq -r '."image-ref"' < "${IMAGE_INFO_FILE}")"
# Strip transport prefix (ostree-image-signed:docker://, ostree-unverified-registry:, etc.)
IMAGE_PATH="$(sed -E 's|^.*://||; s|^[a-z-]+:||' <<< "${IMAGE_REF}")"
# Resolve the live image (bootc status) rather than the build-time
# image-info.json, which goes stale after a rebase.
IMAGE_RESOLVE="${IMAGE_RESOLVE:-/usr/libexec/ublue-image-resolve}"
IMAGE_TAG="$("${IMAGE_RESOLVE}" image-tag)"
IMAGE_PATH="$("${IMAGE_RESOLVE}" image-path)"
if [[ "${IMAGE_TAG}" == *testing* ]]; then
if [[ "${IMAGE_TAG}" == "testing" ]]; then
NEW_TAG="stable"
Expand Down
11 changes: 8 additions & 3 deletions system_files/shared/usr/bin/ublue-image-info.sh
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
#!/usr/bin/bash

# shellcheck disable=2046
IMAGE_INFO_FILE="${IMAGE_INFO_FILE:-/usr/share/ublue-os/image-info.json}"
echo -n "$(jq -r '"\(.["image-name"]):\(.["image-tag"])"' < "${IMAGE_INFO_FILE}")"
# Resolve from the live system (bootc status) so a rebase is reflected here,
# falling back to the build-time image-info.json inside the resolver.
IMAGE_RESOLVE="${IMAGE_RESOLVE:-/usr/libexec/ublue-image-resolve}"
IMAGE_NAME="$("${IMAGE_RESOLVE}" image-name 2>/dev/null || true)"
IMAGE_TAG="$("${IMAGE_RESOLVE}" image-tag 2>/dev/null || true)"
if [[ -n "${IMAGE_NAME}" || -n "${IMAGE_TAG}" ]]; then
echo -n "${IMAGE_NAME}:${IMAGE_TAG}"
fi

if [[ "$(rpm-ostree status --booted)" =~ "signed" ]]; then
echo -n " 🔐"
Expand Down
128 changes: 128 additions & 0 deletions system_files/shared/usr/libexec/ublue-image-resolve
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
#!/usr/bin/bash
# Resolve the identity of the image the system is actually running.
#
# `/usr/share/ublue-os/image-info.json` is baked at build time and describes the
# image the filesystem was *built* as. After a rebase it no longer matches what
# is booted, so `bootc status` is the source of truth here and the baked file is
# only a fallback for non-bootc hosts, containers, and early boot.
#
# Usage: ublue-image-resolve <image-name|image-tag|image-path>
# Prints the resolved value on stdout, or nothing (exit 1) if unresolvable.

set -uo pipefail

IMAGE_INFO_FILE="${IMAGE_INFO_FILE:-/usr/share/ublue-os/image-info.json}"

have() { command -v "$1" >/dev/null 2>&1; }

# Read a field from the build-time image-info.json.
read_baked_field() {
local key="$1" value

have jq || return 1
[[ -r "${IMAGE_INFO_FILE}" ]] || return 1

value="$(jq -r --arg k "${key}" '.[$k] // empty' <"${IMAGE_INFO_FILE}" 2>/dev/null)" || return 1
[[ -n "${value}" && "${value}" != "null" ]] || return 1

printf '%s' "${value}"
}

# Full ref of the booted image according to bootc, e.g.
# ghcr.io/projectbluefin/bluefin-lts:stable
# Schema: .status.booted.image.image.image (bootc HostStatus -> BootEntry ->
# ImageStatus -> ImageReference).
read_live_ref() {
local json ref

have bootc || return 1
have jq || return 1

json="$(bootc status --json 2>/dev/null)" || return 1
[[ -n "${json}" ]] || return 1

ref="$(jq -r '.status.booted.image.image.image // empty' <<<"${json}" 2>/dev/null)" || return 1
[[ -n "${ref}" && "${ref}" != "null" ]] || return 1

printf '%s' "${ref}"
}

# Last path component of a ref, with any @digest suffix removed. Splitting on
# the final "/" keeps registry ports (registry:5000/foo/bar:tag) intact.
ref_basename() {
local ref="${1%@*}"
printf '%s' "${ref##*/}"
}

ref_tag() {
local base
base="$(ref_basename "$1")"
[[ "${base}" == *:* ]] || return 1
printf '%s' "${base##*:}"
}

ref_name() {
local base
base="$(ref_basename "$1")"
printf '%s' "${base%%:*}"
}

# Registry path of a ref with any @digest and :tag removed, e.g.
# ghcr.io/projectbluefin/bluefin-lts. Only the final path component is
# considered, so a registry port survives.
ref_path() {
local ref="${1%@*}" base
base="${ref##*/}"
if [[ "${base}" == *:* ]]; then
printf '%s' "${ref%:*}"
else
printf '%s' "${ref}"
fi
}

# The baked image-ref carries an ostree transport prefix
# (ostree-image-signed:docker://...). bootc refs never do, so this is only ever
# applied to the fallback value.
strip_transport() {
local ref="$1"
ref="${ref##*://}"
[[ "${ref}" =~ ^[a-z][a-z0-9-]*:[^0-9] ]] && ref="${ref#*:}"
printf '%s' "${ref}"
}

main() {
local field="${1:-}" ref="" value=""

case "${field}" in
image-name | image-tag | image-path) ;;
*)
printf 'usage: %s <image-name|image-tag|image-path>\n' "${0##*/}" >&2
return 2
;;
esac

ref="$(read_live_ref)" || ref=""

if [[ -n "${ref}" ]]; then
case "${field}" in
image-path) value="$(ref_path "${ref}")" || value="" ;;
image-name) value="$(ref_name "${ref}")" || value="" ;;
image-tag) value="$(ref_tag "${ref}")" || value="" ;;
esac
fi

# Digest-pinned refs carry no tag, and non-bootc hosts carry no ref at all.
if [[ -z "${value}" ]]; then
if [[ "${field}" == "image-path" ]]; then
ref="$(read_baked_field image-ref)" || ref=""
[[ -z "${ref}" ]] || value="$(ref_path "$(strip_transport "${ref}")")"
else
value="$(read_baked_field "${field}")" || value=""
fi
fi
[[ -n "${value}" ]] || return 1

printf '%s' "${value}"
}

main "$@"
20 changes: 18 additions & 2 deletions tests/test_changelog.bats
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,24 @@ EOF
chmod +x "${MOCKDIR}/grep"

# Keep the fallback recipe under test; a developer's host bctl must not
# short-circuit repository selection before the mocked curl calls.
export PATH="${MOCKDIR}:$(printf '%s' "${PATH}" | tr ':' '\n' | grep -v '/.local/bin' | paste -sd: -)"
# short-circuit repository selection before the mocked curl calls. Drop
# every PATH entry that actually provides bctl, not just ~/.local/bin.
export PATH="${MOCKDIR}:$(printf '%s' "${PATH}" | tr ':' '\n' | while read -r d; do
[ -x "${d}/bctl" ] || printf '%s\n' "${d}"
done | paste -sd: -)"

# Mock the live-image resolver (see ublue-image-resolve). changelog.just
# asks it for the booted tag/name instead of reading image-info.json.
cat > "${MOCKDIR}/ublue-image-resolve" << 'RESOLVE_MOCK'
#!/bin/bash
case "$1" in
image-tag) echo "${MOCK_TAG:-latest}" ;;
image-name) echo "${MOCK_NAME:-bluefin}" ;;
*) exit 2 ;;
esac
RESOLVE_MOCK
chmod +x "${MOCKDIR}/ublue-image-resolve"
export IMAGE_RESOLVE="${MOCKDIR}/ublue-image-resolve"

SCRIPT_FILE="${WORKDIR}/changelog.sh"
_extract_script "${SCRIPT_FILE}"
Expand Down
31 changes: 26 additions & 5 deletions tests/test_ublue_image_info.bats
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env bats

SCRIPT_UNDER_TEST="${BATS_TEST_DIRNAME}/../system_files/shared/usr/bin/ublue-image-info.sh"
IMAGE_RESOLVE_SCRIPT="${BATS_TEST_DIRNAME}/../system_files/shared/usr/libexec/ublue-image-resolve"
WORKDIR=""
MOCKDIR=""
FIXTURE=""
Expand Down Expand Up @@ -50,11 +51,21 @@ write_image_info_fixture() {
EOF
}

write_bootc_mock() {
local ref="$1"

printf '{"status":{"booted":{"image":{"image":{"image":"%s"}}}}}\n' "${ref}" \
> "${WORKDIR}/bootc-status.json"
write_mock "bootc" "#!/usr/bin/bash
cat '${WORKDIR}/bootc-status.json'"
}

run_script() {
local image_info_file="$1"
local path_value="$2"

run env IMAGE_INFO_FILE="${image_info_file}" PATH="${path_value}" /usr/bin/bash "${SCRIPT_UNDER_TEST}"
run env IMAGE_INFO_FILE="${image_info_file}" IMAGE_RESOLVE="${IMAGE_RESOLVE_SCRIPT}" \
PATH="${path_value}" /usr/bin/bash "${SCRIPT_UNDER_TEST}"
}

@test "ublue-image-info: prints image name, tag, and locked status with fixture data" {
Expand Down Expand Up @@ -86,8 +97,7 @@ run_script() {
run_script "${WORKDIR}/missing-image-info.json" "${MOCKDIR}:${PATH}"

[ "${status}" -eq 0 ]
[[ "${output}" == *"${WORKDIR}/missing-image-info.json: No such file or directory"* ]]
[[ "${output}" == *" 🔐" ]]
[ "${output}" = " 🔐" ]
}

@test "ublue-image-info: handles missing jq without failing" {
Expand All @@ -97,6 +107,17 @@ run_script() {
run_script "${FIXTURE}" "${MOCKDIR}"

[ "${status}" -eq 0 ]
[[ "${output}" == *"jq: command not found"* ]]
[[ "${output}" == *" 🔐" ]]
[ "${output}" = " 🔐" ]
}

@test "ublue-image-info: live bootc tag wins over stale image-info.json" {
write_jq_mock
write_rpm_ostree_mock 'echo "State: booted deployment signed"'
write_image_info_fixture "bluefin" "latest"
write_bootc_mock "ghcr.io/projectbluefin/bluefin-lts:stable"

run_script "${FIXTURE}" "${MOCKDIR}:${PATH}"

[ "${status}" -eq 0 ]
[ "${output}" = "bluefin-lts:stable 🔐" ]
}
Loading
Loading