summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authortranch <tranch.xiao@gmail.com>2026-09-13 19:18:04 +0800
committertranch <tranch.xiao@gmail.com>2026-09-13 19:18:04 +0800
commit796c6c8a70c92d4675df7c2ec2d25ecda139d723 (patch)
tree06c6d3e7788d7b2ad5ddd0565e2651c0c8d9d151
parentab070aec5f1d760a2539e14cb19da61593bded15 (diff)
parentd734f2c746632772b794143762f2fcea78e62011 (diff)
downloadAeroCore-OS-796c6c8a70c92d4675df7c2ec2d25ecda139d723.tar.gz
AeroCore-OS-796c6c8a70c92d4675df7c2ec2d25ecda139d723.zip
Merge testing into main for 44.x stable release44.20260913
-rw-r--r--.github/scripts/generate_changelog.py143
-rw-r--r--.github/workflows/build-disk.yml3
-rw-r--r--.github/workflows/build-installer-iso.yml22
-rw-r--r--.github/workflows/build.yml116
-rw-r--r--.github/workflows/generate_release.yml47
-rw-r--r--.github/workflows/promote-acr.yml8
-rw-r--r--.github/workflows/validate-installer-iso.yml31
-rw-r--r--.gitignore1
-rw-r--r--Containerfile11
-rw-r--r--Justfile31
-rw-r--r--README.md12
-rwxr-xr-xbuild_files/build.sh99
-rwxr-xr-xbuild_files/image-info33
-rwxr-xr-xbuild_files/patch-bazzite-deck-identity.sh58
-rwxr-xr-xbuild_files/patch-bazzite-rollback-helper.sh52
-rw-r--r--image-template.env4
-rw-r--r--installer/Containerfile2
-rwxr-xr-xinstaller/build.sh67
-rwxr-xr-xinstaller/configure-target-gpt-root.sh39
-rw-r--r--installer/system_files/overrides/etc/anaconda/profile.d/aerocore.conf36
-rwxr-xr-xjust_scripts/build-installer-iso.sh31
-rwxr-xr-xsystem_files/usr/libexec/aerocore-desktop-branding16
-rwxr-xr-xsystem_files/usr/libexec/aerocore-desktop-branding-privileged12
-rw-r--r--system_files/usr/share/aerocore/desktop-branding/kcm-about-distrorc5
-rw-r--r--system_files/usr/share/plasma/look-and-feel/com.valve.vapor.desktop/contents/plasmoidsetupscripts/org.kde.plasma.kickoff.js5
-rw-r--r--system_files/usr/share/plasma/look-and-feel/com.valve.vgui.desktop/contents/plasmoidsetupscripts/org.kde.plasma.kickoff.js5
-rw-r--r--system_files/usr/share/ublue-os/just/92-aerocore-decky-mirror.just172
-rw-r--r--tests/fixtures/bazzite-44/usr/bin/bazzite-steam10
-rw-r--r--tests/fixtures/bazzite-44/usr/libexec/bazzite-hardware-setup10
-rw-r--r--tests/fixtures/bazzite-44/usr/libexec/bazzite-user-setup16
-rwxr-xr-xtests/test-aerocore-anaconda-profile.sh16
-rw-r--r--tests/test-build-channel-metadata.sh20
-rwxr-xr-xtests/test-configure-target-gpt-root.sh51
-rwxr-xr-xtests/test-decky-mirror-ujust.sh51
-rwxr-xr-xtests/test-desktop-branding-migration.sh59
-rwxr-xr-xtests/test-dotenv-precedence.sh28
-rwxr-xr-xtests/test-patch-bazzite-deck-identity.sh58
37 files changed, 1306 insertions, 74 deletions
diff --git a/.github/scripts/generate_changelog.py b/.github/scripts/generate_changelog.py
new file mode 100644
index 0000000..57f7ba9
--- /dev/null
+++ b/.github/scripts/generate_changelog.py
@@ -0,0 +1,143 @@
+#!/usr/bin/env python3
+"""Generate an SBOM/package and commit changelog for an image release."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import re
+import subprocess
+import tempfile
+from pathlib import Path
+
+
+def run(*args: str) -> str:
+ return subprocess.check_output(args, text=True)
+
+
+def package_map(data: dict) -> dict[str, str]:
+ packages: dict[str, str] = {}
+ for artifact in data.get("artifacts", []):
+ if artifact.get("type") != "rpm":
+ continue
+ name = artifact.get("name")
+ version = artifact.get("version")
+ if name and version:
+ packages[name] = version
+ return packages
+
+
+def image_info(image: str, tag: str) -> dict:
+ return json.loads(run("skopeo", "inspect", f"docker://{image}:{tag}"))
+
+
+def fetch_sbom(image: str, digest: str) -> dict:
+ subject = f"{image}@{digest}"
+ discovery = json.loads(run("oras", "discover", "--format", "json", subject))
+ sbom_digest = next(
+ (
+ referrer["digest"]
+ for referrer in discovery.get("referrers", [])
+ if "spdx+json" in referrer.get("artifactType", "")
+ ),
+ None,
+ )
+ if not sbom_digest:
+ raise RuntimeError(f"No SPDX SBOM is attached to {subject}")
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ subprocess.run(["oras", "pull", f"{image}@{sbom_digest}"], cwd=temp_dir, check=True)
+ for filename in os.listdir(temp_dir):
+ if filename.endswith(".json"):
+ return json.loads((Path(temp_dir) / filename).read_text())
+ raise RuntimeError(f"The SBOM artifact {sbom_digest} did not contain JSON")
+
+
+def release_tags(image: str, channel: str) -> list[str]:
+ raw = run("skopeo", "list-tags", f"docker://{image}")
+ tags = json.loads(raw).get("Tags", [])
+ if channel == "stable":
+ pattern = re.compile(r"^\d+\.\d{8}(?:\.\d+)?$")
+ else:
+ pattern = re.compile(rf"^{re.escape(channel)}-\d+\.\d{{8}}(?:\.\d+)?$")
+ return sorted((tag for tag in tags if pattern.fullmatch(tag)), key=version_key)
+
+
+def version_key(tag: str) -> tuple[int, int, int]:
+ numbers = [int(value) for value in re.findall(r"\d+", tag)]
+ return tuple((numbers + [0, 0, 0])[:3])
+
+
+def markdown_version(value: str | None) -> str:
+ return value or "—"
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--image", required=True, help="registry/repository without tag")
+ parser.add_argument("--repository", required=True, help="GitHub owner/repository")
+ parser.add_argument("--current", required=True)
+ parser.add_argument("--channel", required=True)
+ parser.add_argument("--output", required=True, type=Path)
+ parser.add_argument("--handwritten", default="")
+ args = parser.parse_args()
+
+ tags = release_tags(args.image, args.channel)
+ current_index = tags.index(args.current) if args.current in tags else len(tags)
+ previous = tags[current_index - 1] if current_index > 0 else None
+
+ current_info = image_info(args.image, args.current)
+ current_packages = package_map(fetch_sbom(args.image, current_info["Digest"]))
+ previous_packages: dict[str, str] | None = None
+ if previous:
+ try:
+ previous_packages = package_map(
+ fetch_sbom(args.image, image_info(args.image, previous)["Digest"])
+ )
+ except RuntimeError as error:
+ print(f"Warning: {error}")
+
+ changes = []
+ if previous_packages is not None:
+ for name in sorted(set(current_packages) | set(previous_packages)):
+ old = previous_packages.get(name)
+ new = current_packages.get(name)
+ if old != new:
+ changes.append((name, old, new))
+
+ previous_info = image_info(args.image, previous) if previous else {}
+ start_revision = previous_info.get("Labels", {}).get("org.opencontainers.image.revision")
+ end_revision = current_info.get("Labels", {}).get("org.opencontainers.image.revision")
+ commits = (
+ run("git", "log", "--format=%H%x09%s%x09%an", f"{start_revision}..{end_revision}").splitlines()
+ if start_revision and end_revision
+ else []
+ )
+
+ lines = [args.handwritten.strip() or f"This is an automatically generated changelog for release `{args.current}`.", ""]
+ if previous:
+ lines.append(f"From previous `{args.channel}` version `{previous}` there have been the following package changes.")
+ else:
+ lines.append("No previous release was found for this channel; package changes are shown against an empty baseline.")
+ lines += ["", "### Package changes", "", "| Name | Previous | New |", "| --- | --- | --- |"]
+ if previous and previous_packages is None:
+ lines.append("| — | Previous release has no attached SBOM | Diff starts with the next release |")
+ elif not changes:
+ lines.append("| — | — | — |")
+ else:
+ lines += [f"| `{name}` | {markdown_version(old)} | {markdown_version(new)} |" for name, old, new in changes]
+
+ lines += ["", "### Commits", "", "| Hash | Subject | Author |", "| --- | --- | --- |"]
+ for commit in commits:
+ commit_hash, subject, author = commit.split("\t", 2)
+ lines.append(f"| [{commit_hash[:7]}](https://github.com/{args.repository}/commit/{commit_hash}) | {subject} | {author} |")
+ if not commits:
+ lines.append("| — | No commit range available | — |")
+
+ lines += ["", "### How to switch", "", "For current users, switch to the channel image with:", "", "```bash", f"sudo bootc switch {args.image}:{args.channel}", "", "# Or switch to this exact image:", f"sudo bootc switch {args.image}:{args.current}", "```", ""]
+ args.output.write_text("\n".join(lines))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.github/workflows/build-disk.yml b/.github/workflows/build-disk.yml
index cef58be..30882e1 100644
--- a/.github/workflows/build-disk.yml
+++ b/.github/workflows/build-disk.yml
@@ -81,9 +81,10 @@ jobs:
- name: Build disk images
id: build
- uses: osbuild/bootc-image-builder-action@30960621b85c9e76c653082f9ef4cab5a10210d5 # main
+ uses: osbuild/bootc-image-builder-action@cb4b3e9c112077ea96a191fad229d51d2a012885 # main
with:
builder-image: ${{ env.BIB_IMAGE }}
+ rootfs: btrfs
config-file: ${{ matrix.disk-type == 'anaconda-iso' && './disk_config/iso-kde.toml' || './disk_config/disk.toml' }}
image: ${{ env.IMAGE_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.DEFAULT_TAG }}
chown: ${{ env.USER_UID }}:${{ env.USER_GID }}
diff --git a/.github/workflows/build-installer-iso.yml b/.github/workflows/build-installer-iso.yml
index 1352080..da3ef10 100644
--- a/.github/workflows/build-installer-iso.yml
+++ b/.github/workflows/build-installer-iso.yml
@@ -26,11 +26,12 @@ jobs:
env:
IMAGE_REGISTRY: ghcr.io/${{ github.repository_owner }}
+ IMAGE_NAME: ${{ vars.IMAGE_NAME || github.event.repository.name }}
IMAGE_TAG: ${{ inputs.image_tag || 'latest' }}
ISO_FILE_TAG: ${{ github.ref_name == 'testing' && 'testing' || (inputs.image_tag || 'latest') }}
# Keep these aligned with the Fedora major used by the target bazzite-deck base image.
- LIVE_BASE_IMAGE: quay.io/fedora/fedora-kinoite:43
- FEDORA_VERSION: "43"
+ LIVE_BASE_IMAGE: quay.io/fedora/fedora-kinoite:44
+ FEDORA_VERSION: "44"
OSTREE_IMAGE_REF: ${{ vars.OSTREE_IMAGE_REF }}
steps:
@@ -51,12 +52,9 @@ jobs:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- - name: Install just
- uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4
-
- name: Prepare image references
run: |
- image_name="$(just image_name)"
+ image_name="${IMAGE_NAME,,}"
image_registry="${IMAGE_REGISTRY,,}"
echo "IMAGE_NAME=${image_name,,}" >> "$GITHUB_ENV"
echo "IMAGE_REGISTRY=${image_registry}" >> "$GITHUB_ENV"
@@ -152,18 +150,8 @@ jobs:
)
echo "iso-upload-dir=${iso_upload_dir}" >> "$GITHUB_OUTPUT"
- - name: Upload ISO checksum to Job Artifacts
- if: github.ref_name == 'testing'
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
- with:
- name: aerocore-os-installer-iso-${{ env.ISO_FILE_TAG }}-checksum
- path: ${{ steps.upload-directory.outputs.iso-upload-dir }}/*-CHECKSUM
- if-no-files-found: error
- retention-days: 1
- compression-level: 0
-
- name: Upload ISO and checksum to R2
- if: github.event_name == 'workflow_dispatch' && github.ref_name == 'main'
+ if: github.event_name == 'workflow_dispatch' && (github.ref_name == 'main' || github.ref_name == 'testing')
shell: bash
env:
RCLONE_CONFIG_R2_TYPE: s3
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 78b6dc6..0b6b8df 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -5,6 +5,7 @@ on:
branches:
- main
- testing
+ - testing-candidate
#schedule:
# - cron: '05 10 * * *' # 10:05am UTC everyday
workflow_dispatch:
@@ -16,6 +17,18 @@ on:
env:
IMAGE_REGISTRY: "ghcr.io/${{ github.repository_owner }}" # do not edit
+ IMAGE_NAME: ${{ vars.IMAGE_NAME || github.event.repository.name }}
+ REPO_ORGANIZATION: ${{ vars.REPO_ORGANIZATION || github.repository_owner }}
+ IMAGE_DESC: ${{ vars.IMAGE_DESC || 'AeroCore customized Bazzite Deck image' }}
+ IMAGE_KEYWORDS: ${{ vars.IMAGE_KEYWORDS || 'bootc,oci,linux,bazzite,steam-deck,aerocore' }}
+ IMAGE_LOGO_URL: ${{ vars.IMAGE_LOGO_URL || 'https://avatars.githubusercontent.com/u/120078124?s=200&v=4' }}
+ DEFAULT_TAG: ${{ vars.DEFAULT_TAG || 'stable' }}
+ BIB_IMAGE: ${{ vars.BIB_IMAGE || 'quay.io/centos-bootc/bootc-image-builder:latest' }}
+ BASE_IMAGE: ${{ vars.BASE_IMAGE || 'ghcr.io/ublue-os/bazzite-deck:stable-44.20260908' }}
+ FLATPAK_REMOTE_URL: ${{ vars.FLATPAK_REMOTE_URL }}
+ HOMEBREW_BOTTLE_DOMAIN: ${{ vars.HOMEBREW_BOTTLE_DOMAIN }}
+ HOMEBREW_API_DOMAIN: ${{ vars.HOMEBREW_API_DOMAIN }}
+ OSTREE_IMAGE_REF: ${{ vars.OSTREE_IMAGE_REF }}
concurrency:
group: ${{ github.workflow }}-${{ github.ref || github.run_id }}
@@ -82,25 +95,49 @@ jobs:
- name: Generate Version and Tags
id: gen-version
env:
- BASE_IMAGE: ${{ vars.BASE_IMAGE || 'ghcr.io/ublue-os/bazzite-deck:stable' }}
+ BASE_IMAGE: ${{ vars.BASE_IMAGE || 'ghcr.io/ublue-os/bazzite-deck:stable-44.20260908' }}
run: |
set -euo pipefail
sudo podman pull "${BASE_IMAGE}"
FEDORA_VERSION=$(sudo podman run --rm --entrypoint /usr/bin/rpm "${BASE_IMAGE}" -E %fedora)
- VERSION_BASE="${FEDORA_VERSION}.$(date -u +%Y%m%d)"
-
- if [[ "${GITHUB_REF_NAME}" == "testing" ]]; then
+ VERSION_BASE="${FEDORA_VERSION}.$(date +%Y%m%d)"
+
+ if [[ "${GITHUB_REF_NAME}" == "testing-candidate" ]]; then
+ DEFAULT_TAG="testing-candidate"
+ VERSION_TAG="testing-candidate-${VERSION_BASE}"
+ VERSION_PRETTY_PREFIX="Testing candidate"
+ IMAGE_BRANCH="testing-candidate"
+ elif [[ "${GITHUB_REF_NAME}" == "testing" ]]; then
DEFAULT_TAG="testing"
VERSION_TAG="testing-${VERSION_BASE}"
- VERSION_PRETTY="Testing (F${VERSION_BASE})"
+ VERSION_PRETTY_PREFIX="Testing"
IMAGE_BRANCH="testing"
- ALIAS_TAGS="${VERSION_TAG} testing testing-${FEDORA_VERSION}"
else
DEFAULT_TAG="stable"
VERSION_TAG="${VERSION_BASE}"
- VERSION_PRETTY="Stable (F${VERSION_BASE})"
+ VERSION_PRETTY_PREFIX="Stable"
IMAGE_BRANCH="stable"
+ fi
+
+ mapfile -t existing_tags < <(skopeo list-tags "docker://${IMAGE_REGISTRY,,}/${IMAGE_NAME,,}" 2>/dev/null | jq -r '.Tags[]' || true)
+ if printf '%s\n' "${existing_tags[@]}" | grep -Fxq "${VERSION_TAG}"; then
+ build=1
+ while printf '%s\n' "${existing_tags[@]}" | grep -Fxq "${VERSION_TAG}.${build}"; do
+ ((build++))
+ done
+ VERSION_TAG="${VERSION_TAG}.${build}"
+ fi
+
+ VERSION_BASE_PRETTY="${VERSION_TAG#testing-candidate-}"
+ VERSION_BASE_PRETTY="${VERSION_BASE_PRETTY#testing-}"
+ VERSION_PRETTY="${VERSION_PRETTY_PREFIX} (F${VERSION_BASE_PRETTY})"
+
+ if [[ "${GITHUB_REF_NAME}" == "testing-candidate" ]]; then
+ ALIAS_TAGS="${VERSION_TAG} testing-candidate testing-candidate-${FEDORA_VERSION}"
+ elif [[ "${GITHUB_REF_NAME}" == "testing" ]]; then
+ ALIAS_TAGS="${VERSION_TAG} testing testing-${FEDORA_VERSION}"
+ else
ALIAS_TAGS="${VERSION_TAG} stable-${VERSION_TAG} latest stable stable-${FEDORA_VERSION}"
fi
@@ -124,7 +161,8 @@ jobs:
- name: Build Image
id: build-image
env:
- BASE_IMAGE: ${{ vars.BASE_IMAGE || 'ghcr.io/ublue-os/bazzite-deck:stable' }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ BASE_IMAGE: ${{ vars.BASE_IMAGE || 'ghcr.io/ublue-os/bazzite-deck:stable-44.20260908' }}
FLATPAK_REMOTE_URL: ${{ vars.FLATPAK_REMOTE_URL }}
HOMEBREW_BOTTLE_DOMAIN: ${{ vars.HOMEBREW_BOTTLE_DOMAIN }}
HOMEBREW_API_DOMAIN: ${{ vars.HOMEBREW_API_DOMAIN }}
@@ -134,7 +172,11 @@ jobs:
INSTRUMENTS_VERSION: ${{ vars.INSTRUMENTS_VERSION || 'latest' }}
INSTRUMENTS_RELEASE_API_BASE: ${{ vars.INSTRUMENTS_RELEASE_API_BASE || 'https://api.github.com' }}
run: |
- sudo -E "$(command -v just)" build \
+ GITHUB_TOKEN_FILE="${RUNNER_TEMP}/aerocore-build-github-token"
+ umask 077
+ printf '%s' "${GITHUB_TOKEN}" > "${GITHUB_TOKEN_FILE}"
+ trap 'rm -f "${GITHUB_TOKEN_FILE}"' EXIT
+ sudo -E GITHUB_TOKEN_FILE="${GITHUB_TOKEN_FILE}" "$(command -v just)" build \
"${IMAGE_NAME}" \
"${DEFAULT_TAG}"
@@ -173,18 +215,42 @@ jobs:
"${DEFAULT_TAG}" \
"${ALIAS_TAGS}"
+ - name: Install Syft
+ id: setup-syft
+ if: github.event_name != 'pull_request'
+ uses: anchore/sbom-action/download-syft@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2
+ with:
+ syft-version: v1.44.0
+
+ - name: Generate RPM SBOM
+ id: generate-sbom
+ if: github.event_name != 'pull_request'
+ env:
+ SYFT_CMD: ${{ steps.setup-syft.outputs.cmd }}
+ run: |
+ set -euo pipefail
+ sbom="${RUNNER_TEMP}/aerocore-sbom.json"
+ mountpoint="$(sudo podman image mount "${IMAGE_NAME}:${DEFAULT_TAG}")"
+ trap 'sudo podman image unmount "${IMAGE_NAME}:${DEFAULT_TAG}"' EXIT
+ sudo "${SYFT_CMD}" "dir:${mountpoint}" \
+ --select-catalogers rpm \
+ --source-name "${IMAGE_NAME}-${VERSION_TAG}" \
+ -o "syft-json=${sbom}"
+ sudo chown "$(id -u):$(id -g)" "${sbom}"
+ echo "sbom=${sbom}" >> "$GITHUB_OUTPUT"
+
# These `if` statements keep pull requests from publishing packages.
# They publish default-branch builds and manual testing branch builds.
- name: Login to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
- if: github.event_name != 'pull_request' && (github.ref_name == github.event.repository.default_branch || github.ref_name == 'testing')
+ if: github.event_name != 'pull_request' && (github.ref_name == github.event.repository.default_branch || github.ref_name == 'testing' || github.ref_name == 'testing-candidate')
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Push To GHCR
- if: github.event_name != 'pull_request' && (github.ref_name == github.event.repository.default_branch || github.ref_name == 'testing')
+ if: github.event_name != 'pull_request' && (github.ref_name == github.event.repository.default_branch || github.ref_name == 'testing' || github.ref_name == 'testing-candidate')
id: push-image
env:
ALIAS_TAGS: ${{ steps.gen-build-tags.outputs.alias_tags }}
@@ -199,20 +265,40 @@ jobs:
echo "digest=${digest}" >> "$GITHUB_OUTPUT"
+ - name: Install ORAS
+ if: github.event_name != 'pull_request'
+ uses: oras-project/setup-oras@1d808f7d7f6995cc68b7bf507bfe5c5446e1dc9d # v2.0.1
+
+ - name: Upload RPM SBOM
+ if: github.event_name != 'pull_request'
+ env:
+ DIGEST: ${{ steps.push-image.outputs.digest }}
+ GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ SBOM: ${{ steps.generate-sbom.outputs.sbom }}
+ run: |
+ set -euo pipefail
+ echo "${GHCR_TOKEN}" | oras login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin
+ cd "$(dirname "${SBOM}")"
+ oras attach \
+ --artifact-type application/vnd.spdx+json \
+ --annotation "filename=$(basename "${SBOM}")" \
+ "${IMAGE_REGISTRY}/${IMAGE_NAME}@${DIGEST}" \
+ "$(basename "${SBOM}")"
+
# This section is optional and only needs to be enabled if you plan on distributing
# your project for others to consume. You will need to create a public and private key
# using Cosign and save the private key as a repository secret in Github for this workflow
# to consume. For more details, review the image signing section of the README.
- name: Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- if: github.event_name != 'pull_request' && (github.ref_name == github.event.repository.default_branch || github.ref_name == 'testing')
+ if: github.event_name != 'pull_request' && (github.ref_name == github.event.repository.default_branch || github.ref_name == 'testing' || github.ref_name == 'testing-candidate')
# https://github.com/containers/container-libs/issues/388
with:
cosign-release: 'v2.6.3'
- name: Sign container image
id: sign-image
- if: github.event_name != 'pull_request' && (github.ref_name == github.event.repository.default_branch || github.ref_name == 'testing')
+ if: github.event_name != 'pull_request' && (github.ref_name == github.event.repository.default_branch || github.ref_name == 'testing' || github.ref_name == 'testing-candidate')
env:
COSIGN_EXPERIMENTAL: false
COSIGN_PRIVATE_KEY: ${{ secrets.SIGNING_SECRET }}
@@ -226,9 +312,11 @@ jobs:
needs: [build_push]
if: github.event_name != 'pull_request' &&
(github.ref_name == github.event.repository.default_branch ||
- github.ref_name == 'testing')
+ github.ref_name == 'testing' ||
+ github.ref_name == 'testing-candidate')
permissions:
contents: write
+ packages: read
secrets: inherit
uses: ./.github/workflows/generate_release.yml
with:
diff --git a/.github/workflows/generate_release.yml b/.github/workflows/generate_release.yml
index f741c83..bcdd948 100644
--- a/.github/workflows/generate_release.yml
+++ b/.github/workflows/generate_release.yml
@@ -26,12 +26,38 @@ jobs:
runs-on: ubuntu-24.04
permissions:
contents: write
+ packages: read
steps:
- uses: actions/checkout@v7.0.1
with:
- fetch-depth: 50
+ fetch-depth: 0
+
+ - name: Install ORAS
+ uses: oras-project/setup-oras@1d808f7d7f6995cc68b7bf507bfe5c5446e1dc9d # v2.0.1
+
+ - name: Log in to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Log in to GHCR with ORAS
+ env:
+ GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: echo "${GHCR_TOKEN}" | oras login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin
+
+ - name: Align release tag with build commit
+ env:
+ RELEASE_TAG: ${{ inputs.release_tag }}
+ run: |
+ set -euo pipefail
+
+ git tag --force "${RELEASE_TAG}" "${GITHUB_SHA}"
+ git push --force origin "refs/tags/${RELEASE_TAG}"
+ echo "Release tag ${RELEASE_TAG} now points to ${GITHUB_SHA}."
- name: Determine release channel
id: target
@@ -40,12 +66,25 @@ jobs:
[[ "${target}" == "main" ]] && target="stable"
echo "target=${target}" >> "$GITHUB_OUTPUT"
+ - name: Generate package and commit changelog
+ env:
+ IMAGE: ghcr.io/${{ github.repository }}
+ RELEASE_TAG: ${{ inputs.release_tag }}
+ HANDWRITTEN: ${{ inputs.handwritten }}
+ run: |
+ python3 .github/scripts/generate_changelog.py \
+ --image "${IMAGE,,}" \
+ --repository "${GITHUB_REPOSITORY}" \
+ --current "${RELEASE_TAG}" \
+ --channel "${{ steps.target.outputs.target }}" \
+ --handwritten "${HANDWRITTEN}" \
+ --output release-notes.md
+
- name: Generate release
- uses: softprops/action-gh-release@v3.0.2
+ uses: softprops/action-gh-release@v3.0.3
with:
tag_name: "${{ inputs.release_tag }}"
name: "${{ inputs.release_tag }}"
- body: |
- ${{ inputs.handwritten }}
+ body_path: release-notes.md
prerelease: ${{ steps.target.outputs.target != 'stable' }}
make_latest: ${{ steps.target.outputs.target == 'stable' }}
diff --git a/.github/workflows/promote-acr.yml b/.github/workflows/promote-acr.yml
index 6b00e3b..ceb5c0f 100644
--- a/.github/workflows/promote-acr.yml
+++ b/.github/workflows/promote-acr.yml
@@ -13,12 +13,13 @@ on:
required: false
type: string
image_name:
- description: "Image name override; defaults to IMAGE_NAME var, then image-template.env"
+ description: "Image name override; defaults to the runtime IMAGE_NAME variable"
required: false
type: string
env:
IMAGE_REGISTRY: "ghcr.io/${{ github.repository_owner }}"
+ IMAGE_NAME: ${{ vars.IMAGE_NAME || github.event.repository.name }}
concurrency:
group: ${{ github.workflow }}-${{ inputs.image_name || 'default' }}-${{ inputs.source_tag }}
@@ -37,9 +38,6 @@ jobs:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- - name: Install just
- uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 #v4.0.0
-
- name: Install skopeo
run: |
sudo apt-get update
@@ -54,7 +52,7 @@ jobs:
run: |
set -euo pipefail
- image_name="${IMAGE_NAME_INPUT:-${IMAGE_NAME_VAR:-$(just image_name)}}"
+ image_name="${IMAGE_NAME_INPUT:-${IMAGE_NAME_VAR:-${IMAGE_NAME}}}"
target_tags="${TARGET_TAGS_INPUT:-$SOURCE_TAG}"
{
diff --git a/.github/workflows/validate-installer-iso.yml b/.github/workflows/validate-installer-iso.yml
index a93e9cd..3f02a76 100644
--- a/.github/workflows/validate-installer-iso.yml
+++ b/.github/workflows/validate-installer-iso.yml
@@ -13,6 +13,18 @@ jobs:
validate:
name: Run lightweight checks
runs-on: ubuntu-24.04
+ # `just check` parses the whole Justfile, so provide the non-secret
+ # variables required by its top-level metadata declarations. This job
+ # does not build or publish an image; production builds use repository
+ # runtime variables in their respective workflows.
+ env:
+ IMAGE_NAME: aerocore-os
+ REPO_ORGANIZATION: aerocore-io
+ IMAGE_DESC: AeroCore customized Bazzite Deck image
+ IMAGE_KEYWORDS: bootc,oci,linux,bazzite,steam-deck,aerocore
+ IMAGE_LOGO_URL: https://avatars.githubusercontent.com/u/120078124?s=200&v=4
+ DEFAULT_TAG: testing
+ BIB_IMAGE: quay.io/centos-bootc/bootc-image-builder:latest
steps:
- name: Checkout
@@ -32,12 +44,29 @@ jobs:
run: |
just --unstable --fmt --check -f Justfile
+ - name: Test dotenv precedence
+ run: tests/test-dotenv-precedence.sh
+
- name: Validate build scripts
run: |
- for script in build_files/*.sh installer/*.sh just_scripts/*.sh; do
+ for script in build_files/*.sh installer/*.sh just_scripts/*.sh tests/*.sh; do
bash -n "$script"
done
bash -n system_files/usr/libexec/aerocore-instruments-setup
+ bash -n system_files/usr/libexec/aerocore-desktop-branding
+ bash -n system_files/usr/libexec/aerocore-desktop-branding-privileged
+
+ - name: Test Bazzite Deck identity patch
+ run: tests/test-patch-bazzite-deck-identity.sh
+
+ - name: Test desktop branding migration
+ run: tests/test-desktop-branding-migration.sh
+
+ - name: Test GPT root configuration
+ run: tests/test-configure-target-gpt-root.sh
+
+ - name: Validate AeroCore Anaconda profile
+ run: tests/test-aerocore-anaconda-profile.sh
- name: Validate Anaconda WebUI patch
run: |
diff --git a/.gitignore b/.gitignore
index d4fa7cf..71fa748 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@ _build_*
output
_build-*/**
.DS_Store
+.env
diff --git a/Containerfile b/Containerfile
index 16e85a2..11d6ebf 100644
--- a/Containerfile
+++ b/Containerfile
@@ -1,5 +1,5 @@
# Allow build scripts to be referenced without being copied into the final image
-ARG BASE_IMAGE="ghcr.io/ublue-os/bazzite-deck:stable"
+ARG BASE_IMAGE="ghcr.io/ublue-os/bazzite-deck:stable-44.20260908"
FROM scratch AS ctx
COPY build_files /
@@ -9,14 +9,14 @@ COPY system_files /system_files
FROM ${BASE_IMAGE}
ARG BASE_IMAGE
ARG IMAGE_NAME="aerocore-os"
-ARG IMAGE_VENDOR="AeroCore-IO"
-ARG IMAGE_BRANCH="stable"
+ARG IMAGE_VENDOR="aerocore-io"
+ARG IMAGE_BRANCH="testing-candidate"
ARG VERSION_TAG="latest"
ARG VERSION_PRETTY="latest"
ARG OSTREE_IMAGE_REF=""
## Other possible base images include:
-# ghcr.io/ublue-os/bazzite-deck:testing
-# ghcr.io/ublue-os/bazzite-deck:stable-43.20260420
+# ghcr.io/ublue-os/bazzite-deck:stable-44.20260908
+# ghcr.io/ublue-os/bazzite-deck:testing-44.20260908.3
### [IM]MUTABLE /opt
## Some bootable images, like Fedora, have /opt symlinked to /var/opt, in order to
@@ -44,6 +44,7 @@ ARG INSTRUMENTS_RELEASE_API_BASE="https://api.github.com"
RUN --mount=type=bind,from=ctx,source=/,target=/ctx \
--mount=type=cache,dst=/var/cache \
--mount=type=cache,dst=/var/log \
+ --mount=type=secret,id=GITHUB_TOKEN \
--mount=type=tmpfs,dst=/tmp \
/ctx/build.sh && \
/ctx/image-info && \
diff --git a/Justfile b/Justfile
index b8d0be9..a83ba0a 100644
--- a/Justfile
+++ b/Justfile
@@ -1,4 +1,4 @@
-set dotenv-filename := "image-template.env"
+set dotenv-filename := ".env"
set dotenv-load
export image_name := env_var("IMAGE_NAME")
@@ -8,12 +8,12 @@ export image_keywords := env_var("IMAGE_KEYWORDS")
export image_logo_url := env_var("IMAGE_LOGO_URL")
export default_tag := env_var("DEFAULT_TAG")
export bib_image := env_var("BIB_IMAGE")
-export IMAGE_BRANCH := env_var_or_default("IMAGE_BRANCH", "stable")
+export IMAGE_BRANCH := env_var_or_default("IMAGE_BRANCH", "testing-candidate")
export VERSION_TAG := env_var_or_default("VERSION_TAG", default_tag)
export VERSION_PRETTY := env_var_or_default("VERSION_PRETTY", VERSION_TAG)
-export BASE_IMAGE := env_var_or_default("BASE_IMAGE", "ghcr.io/ublue-os/bazzite-deck:stable")
+export BASE_IMAGE := env_var_or_default("BASE_IMAGE", "ghcr.io/ublue-os/bazzite-deck:stable-44.20260908")
export OSTREE_IMAGE_REF := env_var_or_default("OSTREE_IMAGE_REF", "ostree-image-signed:docker://ghcr.io/" + repo_organization + "/" + image_name)
-export LIVE_BASE_IMAGE := env_var_or_default("LIVE_BASE_IMAGE", "quay.io/fedora/fedora-kinoite:43")
+export LIVE_BASE_IMAGE := env_var_or_default("LIVE_BASE_IMAGE", "quay.io/fedora/fedora-kinoite:44")
export TITANOBOA_REPOSITORY := env_var_or_default("TITANOBOA_REPOSITORY", "https://github.com/Zeglius/titanoboa.git")
export TITANOBOA_REVISION := env_var_or_default("TITANOBOA_REVISION", "7737f4748458252ac827dca14b3d6dd09298472a")
export FLATPAK_REMOTE_URL := env_var_or_default("FLATPAK_REMOTE_URL", "")
@@ -111,8 +111,8 @@ sudoif command *args:
# $target_image - The tag you want to apply to the image (default: $image_name).
# $tag - The tag for the image (default: $default_tag).
#
-# If the git working directory is clean, it appends the short SHA of the current HEAD
-# to the version tag supplied by the build environment.
+# If the git working directory is clean, it labels the image with the current HEAD
+# revision while keeping the image version on the build environment's VERSION_TAG.
#
# just build $target_image $tag
#
@@ -130,7 +130,17 @@ build $target_image=image_name $tag=default_tag:
BUILD_ARGS=()
LABELS=()
- for arg in BASE_IMAGE IMAGE_BRANCH VERSION_TAG VERSION_PRETTY FLATPAK_REMOTE_URL HOMEBREW_BOTTLE_DOMAIN HOMEBREW_API_DOMAIN OSTREE_IMAGE_REF INSTRUMENTS_ENABLED INSTRUMENTS_RELEASE_REPOSITORY INSTRUMENTS_VERSION INSTRUMENTS_RELEASE_API_BASE; do
+ # The recipe tag is the channel selected by commands such as
+ # `just build localhost/aerocore-os testing`. VERSION_TAG and
+ # VERSION_PRETTY may be supplied by CI and should remain the release
+ # version stamped inside the image.
+ BUILD_CHANNEL_ARGS=(
+ "--build-arg" "IMAGE_BRANCH={{ tag }}"
+ "--build-arg" "VERSION_TAG=${VERSION_TAG:-{{ tag }}}"
+ "--build-arg" "VERSION_PRETTY=${VERSION_PRETTY:-${VERSION_TAG:-{{ tag }}}}"
+ )
+
+ for arg in BASE_IMAGE FLATPAK_REMOTE_URL HOMEBREW_BOTTLE_DOMAIN HOMEBREW_API_DOMAIN OSTREE_IMAGE_REF INSTRUMENTS_ENABLED INSTRUMENTS_RELEASE_REPOSITORY INSTRUMENTS_VERSION INSTRUMENTS_RELEASE_API_BASE; do
if [[ -n "${!arg:-}" ]]; then
BUILD_ARGS+=("--build-arg" "${arg}=${!arg}")
fi
@@ -140,9 +150,10 @@ build $target_image=image_name $tag=default_tag:
GIT_SHA=$(git rev-parse --short HEAD)
LABELS+=("--label" "io.artifacthub.package.readme-url=https://raw.githubusercontent.com/{{ repo_organization }}/{{ image_name }}/${GIT_SHA}/README.md")
LABELS+=("--label" "org.opencontainers.image.documentation=https://raw.githubusercontent.com/{{ repo_organization }}/{{ image_name }}/${GIT_SHA}/README.md")
+ LABELS+=("--label" "org.opencontainers.image.revision=${GIT_SHA}")
LABELS+=("--label" "org.opencontainers.image.source=https://github.com/{{ repo_organization }}/{{ image_name }}/blob/${GIT_SHA}/Containerfile")
LABELS+=("--label" "org.opencontainers.image.url=https://github.com/{{ repo_organization }}/{{ image_name }}/tree/${GIT_SHA}")
- LABELS+=("--label" "org.opencontainers.image.version={{ VERSION_TAG }}-${GIT_SHA}")
+ LABELS+=("--label" "org.opencontainers.image.version=${VERSION_TAG:-{{ tag }}}")
fi
# Image metadata for https://artifacthub.io/ - This is optional but is highly recommended so we all can get a index of all the custom images
@@ -158,7 +169,9 @@ build $target_image=image_name $tag=default_tag:
LABELS+=("--label" "org.opencontainers.image.vendor={{ repo_organization }}")
# This actually builds the image!
- PODMAN_BUILD_ARGS=("${BUILD_ARGS[@]}" "${LABELS[@]}" --pull=newer --tag "${target_image}:${tag}" --file Containerfile)
+ # The build always exposes a secret mount. Local builds use /dev/null;
+ # CI supplies a short-lived GitHub token file for private release assets.
+ PODMAN_BUILD_ARGS=("${BUILD_CHANNEL_ARGS[@]}" "${BUILD_ARGS[@]}" "${LABELS[@]}" --secret "id=GITHUB_TOKEN,src=${GITHUB_TOKEN_FILE:-/dev/null}" --pull=newer --tag "${target_image}:${tag}" --file Containerfile)
podman build "${PODMAN_BUILD_ARGS[@]}" .
diff --git a/README.md b/README.md
index 213e029..ac56ad9 100644
--- a/README.md
+++ b/README.md
@@ -41,13 +41,19 @@ with `INSTRUMENTS_RELEASE_REPOSITORY` and `INSTRUMENTS_RELEASE_API_BASE`.
## Local Build
-To build AeroCore OS locally, edit `image-template.env` to match your target configurations and then run:
+To build AeroCore OS locally, you may create a private `.env` from the
+committed template and edit it to match your target configuration:
```bash
-sudo just build
-
+cp image-template.env .env
+# edit .env
```
+`.env` is ignored by Git and optional. When it is absent, Just reads the
+required values from the runtime environment. Variables supplied by the shell
+or CI environment override matching values in `.env`, so you can test a
+different upstream base without changing files.
+
To test a different upstream base image directly without modifying the environment files:
```bash
diff --git a/build_files/build.sh b/build_files/build.sh
index 5618d20..40b9efd 100755
--- a/build_files/build.sh
+++ b/build_files/build.sh
@@ -14,6 +14,104 @@ esac
# Copy the contents of system_files/ of the git repo to /
cp -avf "/ctx/system_files"/. /
+# Bazzite's Kinoite/Plasma desktop shortcut used to start a privileged
+# return-to-gamemode.service. Current Bazzite invokes the user-session entry
+# point directly; keep the shortcut correct when the base image still ships
+# the legacy Exec= line.
+RETURN_TO_GAMING_DESKTOP="/etc/skel/Desktop/Return.desktop"
+if [[ -f "${RETURN_TO_GAMING_DESKTOP}" ]]; then
+ sed -i 's|^Exec=.*|Exec=/usr/bin/return-to-gamemode|' "${RETURN_TO_GAMING_DESKTOP}"
+fi
+
+# ujust loads explicit imports, not every fragment in the just/ directory.
+# Preserve upstream recipes and register our overlay after copying its files.
+AEROCORE_DECKY_IMPORT='import "/usr/share/ublue-os/just/92-aerocore-decky-mirror.just"'
+if ! grep -Fxq "${AEROCORE_DECKY_IMPORT}" /usr/share/ublue-os/justfile; then
+ printf '\n%s\n' "${AEROCORE_DECKY_IMPORT}" >> /usr/share/ublue-os/justfile
+fi
+# Fail the image build if the public entry point cannot resolve the recipe.
+ujust --show setup-decky-mirror >/dev/null
+
+install_regular_file() {
+ local source_file="$1"
+ local destination_file="$2"
+
+ # Several Bazzite branding paths are symlinks. cp follows an existing
+ # destination symlink, which leaves the name Plasma resolves untouched.
+ # Replace the link itself so the final immutable image owns this exact path.
+ rm -f -- "${destination_file}"
+ install -Dm0644 -- "${source_file}" "${destination_file}"
+}
+
+# KDE's Deck presets use distributor-logo-steamdeck.svg for the Kickoff
+# launcher. In the Bazzite base image that name (and the related aliases)
+# may already exist as links to the upstream distributor-logo.svg. Install
+# the AeroCore asset explicitly after the general overlay so the final image
+# cannot silently retain the upstream Bazzite logo.
+AEROCORE_DISTRIBUTOR_LOGO="/ctx/system_files/usr/share/icons/hicolor/scalable/places/distributor-logo.svg"
+AEROCORE_ICON_DIR="/usr/share/icons/hicolor/scalable/places"
+mkdir -p "${AEROCORE_ICON_DIR}"
+for icon_name in \
+ distributor-logo.svg \
+ distributor-logo-steamdeck.svg \
+ bazzite-logo.svg \
+ start-here.svg; do
+ install_regular_file "${AEROCORE_DISTRIBUTOR_LOGO}" "${AEROCORE_ICON_DIR}/${icon_name}"
+done
+
+# Use a downstream-specific, non-upstream icon name for Plasma's launcher.
+install_regular_file \
+ "/ctx/system_files/usr/share/pixmaps/bazzite-logo-le.svg" \
+ "/usr/share/icons/hicolor/scalable/apps/aerocore-logo.svg"
+
+# KDE's About System KCM loads this path from kcm-about-distrorc. The base
+# image may provide it as a symlink, so replace the directory entry itself or
+# the KCM can continue rendering the upstream Bazzite logo.
+install_regular_file \
+ "/ctx/system_files/usr/share/pixmaps/system-logo-white.png" \
+ "/usr/share/pixmaps/system-logo-white.png"
+
+# Game Mode's Vapor splash asset is an upstream symlink on current Bazzite
+# images, while VGUI is a regular file. Replace both exact paths explicitly.
+for look_and_feel in com.valve.vapor.desktop com.valve.vgui.desktop; do
+ install_regular_file \
+ "/ctx/system_files/usr/share/plasma/look-and-feel/${look_and_feel}/contents/splash/images/bazzite_logo.svgz" \
+ "/usr/share/plasma/look-and-feel/${look_and_feel}/contents/splash/images/bazzite_logo.svgz"
+done
+
+# Steam's Game Mode transitions load these four assets directly. They are
+# symlinks in some upstream layers, so replace their directory entries just
+# like the Plasma splash assets above.
+for animation in \
+ bazzite.webm \
+ bazzite-oled.webm \
+ bazzite-suspend.webm \
+ bazzite-suspend-oled.webm; do
+ install_regular_file \
+ "/ctx/system_files/usr/share/ublue-os/bazzite/${animation}" \
+ "/usr/share/ublue-os/bazzite/${animation}"
+done
+
+gtk-update-icon-cache -f /usr/share/icons/hicolor || true
+
+# The Plasma launcher resolves the standard start-here name from both scalable
+# and per-size places directories. Populate every existing Bazzite icon size
+# in the installed payload, not only in the live installer image.
+for icon_dir in /usr/share/icons/hicolor/*; do
+ [[ -d "${icon_dir}" ]] || continue
+ source_icon="${icon_dir}/bazzite-logo-icon.png"
+ if [[ -f "${source_icon}" ]]; then
+ mkdir -p "${icon_dir}/apps" "${icon_dir}/places"
+ cp -f "${source_icon}" "${icon_dir}/places/start-here.png"
+ cp -f "${source_icon}" "${icon_dir}/apps/fedora-logo-icon.png"
+ fi
+done
+
+if ! cmp -s "${AEROCORE_DISTRIBUTOR_LOGO}" "${AEROCORE_ICON_DIR}/distributor-logo.svg"; then
+ echo "AeroCore distributor logo was not installed into the image" >&2
+ exit 1
+fi
+
# Default HDMI-CEC to putting the TV into standby when the system sleeps.
# `ujust toggle-cec-sleep enable` changes this same setting at runtime. Keep
# the default in the base image's config so users do not need to run the
@@ -58,3 +156,4 @@ find /etc/environment.d /etc/skel/.config/environment.d -name 99-aerocore-mirror
/ctx/install-instruments.sh
/ctx/patch-bazzite-deck-identity.sh
+/ctx/patch-bazzite-rollback-helper.sh
diff --git a/build_files/image-info b/build_files/image-info
index bd66383..71ab60f 100755
--- a/build_files/image-info
+++ b/build_files/image-info
@@ -31,7 +31,7 @@ cat > "$IMAGE_INFO" <<EOF
"image-name": "$IMAGE_NAME",
"image-vendor": "$IMAGE_VENDOR",
"image-ref": "$IMAGE_REF",
- "image-tag": "stable",
+ "image-tag": "$IMAGE_BRANCH_NORMALIZED",
"image-branch": "$IMAGE_BRANCH_NORMALIZED",
"base-image-name": "$BASE_IMAGE_NAME",
"fedora-version": "$FEDORA_VERSION",
@@ -40,6 +40,20 @@ cat > "$IMAGE_INFO" <<EOF
}
EOF
+# Fail the image build if the metadata file was not rewritten with the
+# downstream identity. A base Bazzite image can otherwise leave a plausible
+# looking build with image-info.json still claiming bazzite-deck.
+if [[ "$IMAGE_NAME" != "aerocore-os" || "$IMAGE_VENDOR" != "aerocore-io" ]]; then
+ echo "Unexpected AeroCore image identity: ${IMAGE_VENDOR}/${IMAGE_NAME}" >&2
+ exit 1
+fi
+
+jq -e \
+ --arg image_name "$IMAGE_NAME" \
+ --arg image_vendor "$IMAGE_VENDOR" \
+ '.["image-name"] == $image_name and .["image-vendor"] == $image_vendor and .["image-ref"] != null' \
+ "$IMAGE_INFO" >/dev/null
+
# Keep the display name consistent across desktop settings and os-release.
sed -i "s/^VARIANT_ID=.*/VARIANT_ID=$IMAGE_ID/" /usr/lib/os-release
sed -i "s/^PRETTY_NAME=.*/PRETTY_NAME=\"$IMAGE_PRETTY_NAME\"/" /usr/lib/os-release
@@ -56,7 +70,22 @@ sed -i "s/^ANSI_COLOR=.*/ANSI_COLOR=\"$LOGO_COLOR\"/" /usr/lib/os-release
sed -i "/^REDHAT_BUGZILLA_PRODUCT=/d; /^REDHAT_BUGZILLA_PRODUCT_VERSION=/d; /^REDHAT_SUPPORT_PRODUCT=/d; /^REDHAT_SUPPORT_PRODUCT_VERSION=/d" /usr/lib/os-release
sed -i "s|^VERSION_CODENAME=.*|VERSION_CODENAME=\"Deck\"|" /usr/lib/os-release
-echo "$IMAGE_PRETTY_NAME release $FEDORA_VERSION (Deck)" > /etc/system-release
+# /etc/system-release is normally a symlink to the RPM-owned fedora-release.
+# Keep that relationship, but replace the target inode explicitly. This
+# avoids relying on an in-place write through an RPM-owned symlink, which can
+# be lost while the bootc/ostree image is assembled, leaving Anaconda to read
+# the upstream file (which may contain a trailing NUL).
+SYSTEM_RELEASE="/etc/system-release"
+SYSTEM_RELEASE_TARGET="$(readlink -f "$SYSTEM_RELEASE")"
+EXPECTED_SYSTEM_RELEASE="$IMAGE_PRETTY_NAME release $FEDORA_VERSION (Deck)"
+rm -f -- "$SYSTEM_RELEASE_TARGET"
+printf '%s\n' "$EXPECTED_SYSTEM_RELEASE" > "$SYSTEM_RELEASE_TARGET"
+
+if ! cmp -s <(printf '%s\n' "$EXPECTED_SYSTEM_RELEASE") "$SYSTEM_RELEASE_TARGET"; then
+ echo "Failed to write a clean AeroCore system-release to ${SYSTEM_RELEASE_TARGET}" >&2
+ od -An -v -tx1 "$SYSTEM_RELEASE_TARGET" >&2
+ exit 1
+fi
if grep -q '^BUILD_ID=' /usr/lib/os-release; then
sed -i "s/^BUILD_ID=.*/BUILD_ID=\"$VERSION_PRETTY\"/" /usr/lib/os-release
diff --git a/build_files/patch-bazzite-deck-identity.sh b/build_files/patch-bazzite-deck-identity.sh
index ea561e4..d3ee860 100755
--- a/build_files/patch-bazzite-deck-identity.sh
+++ b/build_files/patch-bazzite-deck-identity.sh
@@ -110,6 +110,54 @@ patch_steam_state_migration() {
fi
}
+patch_desktop_branding_migration() {
+ local file="$1"
+ local marker='AeroCore desktop branding migration'
+ local condition='pkexec /usr/libexec/bazzite-privileged-user-setup "$USER"'
+ local patched_file
+
+ if [[ ! -f "$file" ]]; then
+ echo "Unable to locate expected Bazzite user setup file: $file" >&2
+ exit 1
+ fi
+
+ if grep -Fq "$marker" "$file"; then
+ return 0
+ fi
+
+ if [[ "$(grep -Foc "$condition" "$file" || true)" -ne 1 ]]; then
+ echo "Unable to locate the Bazzite privileged user setup call in $file" >&2
+ exit 1
+ fi
+
+ patched_file="$(mktemp)"
+ awk -v condition="$condition" '
+ !inserted && $0 == condition {
+ print
+ print ""
+ print " # AeroCore desktop branding migration"
+ print " # The image contains the correct defaults. This only repairs values"
+ print " # persisted by an older Bazzite/AeroCore deployment."
+ print " if [[ -f /etc/xdg/kcm-about-distrorc ]] && grep -Fxq \"Name=Bazzite\" /etc/xdg/kcm-about-distrorc; then"
+ print " pkexec /usr/libexec/aerocore-desktop-branding-privileged"
+ print " fi"
+ print " /usr/libexec/aerocore-desktop-branding"
+ inserted=1
+ next
+ }
+ { print }
+ END { if (!inserted) exit 1 }
+ ' "$file" > "$patched_file"
+
+ cp "$patched_file" "$file"
+ rm -f "$patched_file"
+
+ if ! grep -Fq "$marker" "$file"; then
+ echo "Failed to add the AeroCore desktop branding migration to $file" >&2
+ exit 1
+ fi
+}
+
# Keep AeroCore's public image-name, while restoring the Deck capability checks
# used by Bazzite's runtime scripts. These are literal source lines; variable
# expansion must happen later, when the scripts run on the installed system.
@@ -125,8 +173,8 @@ patch_condition \
# shellcheck disable=SC2016
patch_condition \
"${1:-$(target_path /usr/libexec/bazzite-hardware-setup)}" \
- 'if [[ $IMAGE_NAME =~ "deck" || $IMAGE_NAME =~ "ally" ]]; then' \
- 'if [[ $IMAGE_NAME =~ "deck" || $IMAGE_NAME =~ "ally" || $IMAGE_NAME == aerocore-os ]]; then' \
+ 'if [[ $IMAGE_NAME =~ "deck" ]]; then' \
+ 'if [[ $IMAGE_NAME =~ "deck" || $IMAGE_NAME == aerocore-os ]]; then' \
1
# bazzite-steam gates the Steam bootstrap archive and -steamdeck flag on the
@@ -144,6 +192,8 @@ patch_steam_state_migration "$(target_path /usr/bin/bazzite-steam)"
# shellcheck disable=SC2016
patch_condition \
"$(target_path /usr/libexec/bazzite-user-setup)" \
- 'if [[ $IMAGE_NAME =~ "deck" || $IMAGE_NAME =~ "ally" ]]; then' \
- 'if [[ $IMAGE_NAME =~ "deck" || $IMAGE_NAME =~ "ally" || $IMAGE_NAME == aerocore-os ]]; then' \
+ 'if [[ $IMAGE_NAME =~ "deck" ]]; then' \
+ 'if [[ $IMAGE_NAME =~ "deck" || $IMAGE_NAME == aerocore-os ]]; then' \
3
+
+patch_desktop_branding_migration "$(target_path /usr/libexec/bazzite-user-setup)"
diff --git a/build_files/patch-bazzite-rollback-helper.sh b/build_files/patch-bazzite-rollback-helper.sh
new file mode 100755
index 0000000..6d565b0
--- /dev/null
+++ b/build_files/patch-bazzite-rollback-helper.sh
@@ -0,0 +1,52 @@
+#!/usr/bin/bash
+
+set -euo pipefail
+
+file=/usr/bin/bazzite-rollback-helper
+
+if [[ ! -f "$file" ]]; then
+ echo "Unable to locate Bazzite rollback helper: $file" >&2
+ exit 1
+fi
+
+if grep -Fq 'current_image_prefix()' "$file"; then
+ exit 0
+fi
+
+patched_file=$(mktemp)
+awk '
+ /^signing_scheme\(\) \{/ {
+ print "signing_scheme() {"
+ print " local current=$(rpm-ostree status -b --json | jq -r '\''.deployments | map(select(.booted == true)) | first | .[\"container-image-reference\"] // empty'\'')"
+ print " local scheme=\"ostree-unverified-registry\""
+ print " [[ \"$current\" == *\"ostree-image-signed\"* ]] && scheme=\"ostree-image-signed\""
+ print " echo \"$scheme:docker://\""
+ print "}"
+ print ""
+ print "current_image_prefix() {"
+ print " local current"
+ print " current=$(rpm-ostree status -b --json | jq -r '\''.deployments | map(select(.booted == true)) | first | .[\"container-image-reference\"] // empty'\'')"
+ print " if [[ -z \"$current\" ]]; then"
+ print " echo \"Unable to determine the current booted image reference.\" >&2"
+ print " return 1"
+ print " fi"
+ print " current=\"${current#ostree-image-signed:}\""
+ print " current=\"${current#ostree-unverified-registry:}\""
+ print " current=\"${current#docker://}\""
+ print " echo \"${current%/*}\""
+ print "}"
+ skip=1
+ next
+ }
+ skip && /^warn_if_de_mismatch\(\) \{/ { skip=0; print; next }
+ skip { next }
+ {
+ gsub("ostree-image-signed:docker://[^\"]+/", "$(signing_scheme)$(current_image_prefix)/")
+ gsub("\\$\\(signing_scheme\\)/\\$\\(current_image_prefix\\)/", "$(signing_scheme)$(current_image_prefix)/")
+ gsub("\\$\\(signing_scheme\\)/", "$(signing_scheme)")
+ print
+ }
+' "$file" > "$patched_file"
+
+install -m 0755 "$patched_file" "$file"
+rm -f "$patched_file"
diff --git a/image-template.env b/image-template.env
index 22a387b..3a5a03f 100644
--- a/image-template.env
+++ b/image-template.env
@@ -4,7 +4,7 @@
IMAGE_NAME=aerocore-os
# Make this the same as your github username/organisation
-REPO_ORGANIZATION="AeroCore-IO"
+REPO_ORGANIZATION="aerocore-io"
IMAGE_DESC="AeroCore customized Bazzite Deck image"
IMAGE_KEYWORDS="bootc,oci,linux,bazzite,steam-deck,aerocore"
@@ -16,7 +16,7 @@ BIB_IMAGE="quay.io/centos-bootc/bootc-image-builder:latest"
# Keep this on bazzite-deck for Steam Deck, game mode, and handheld customizations.
# Use a concrete tag during transition windows if stable/testing moves underneath you.
-BASE_IMAGE="ghcr.io/ublue-os/bazzite-deck:stable"
+BASE_IMAGE="ghcr.io/ublue-os/bazzite-deck:stable-44.20260908"
# Optional mirror settings.
FLATPAK_REMOTE_URL=""
diff --git a/installer/Containerfile b/installer/Containerfile
index 32b327e..a7f1d3b 100644
--- a/installer/Containerfile
+++ b/installer/Containerfile
@@ -2,7 +2,7 @@
# The image installed to disk is supplied separately as INSTALL_IMAGE_PAYLOAD.
ARG BASE_IMAGE=ghcr.io/ublue-os/bazzite:stable
-ARG IMAGE_VENDOR=AeroCore-IO
+ARG IMAGE_VENDOR=aerocore-io
ARG IMAGE_NAME=aerocore-os
ARG INSTALL_IMAGE_PAYLOAD=ghcr.io/${IMAGE_VENDOR}/${IMAGE_NAME}:stable
ARG INSTALL_IMAGE_PAYLOAD_ARCHIVE=/install-payload.oci
diff --git a/installer/build.sh b/installer/build.sh
index 4535113..34c55b7 100755
--- a/installer/build.sh
+++ b/installer/build.sh
@@ -9,6 +9,11 @@ INSTALL_IMAGE_PAYLOAD_ARCHIVE=${INSTALL_IMAGE_PAYLOAD_ARCHIVE:-}
INSTALL_IMAGE_TAG=${INSTALL_IMAGE_TAG:?}
OSTREE_IMAGE_REF=${OSTREE_IMAGE_REF:?}
ostree_origin_ref="${OSTREE_IMAGE_REF}:${INSTALL_IMAGE_TAG}"
+live_version_id="$(awk -F= '$1 == "VERSION_ID" { gsub(/\"/, "", $2); print $2; exit }' /usr/lib/os-release)"
+
+# The live image may ship /root as a symlink. Ensure its target exists before
+# systemd-tmpfiles and other early-boot services process root-owned paths.
+mkdir -p "$(realpath /root)"
# The installer is a live KDE environment. Keep the installed system payload
# separate: it is the AeroCore image, while BASE_IMAGE provides the live UI.
@@ -22,6 +27,7 @@ dnf --setopt=excludepkgs= install -y \
libblockdev-lvm \
dracut-live \
livesys-scripts \
+ gdisk \
grub2-efi-x64 \
grub2-efi-x64-cdboot
@@ -32,15 +38,26 @@ dnf --setopt=excludepkgs= reinstall -y \
grub2-efi-x64-cdboot
# Fedora 43's anaconda-webui bundle embeds a Cockpit password helper from
-# before cockpit-project/cockpit@900c13f. Restore that upstream fix so a weak
-# pwscore result is returned as score 0 instead of rejecting the WebUI promise.
-"${SCRIPT_DIR}/patch-anaconda-webui.sh"
+# before cockpit-project/cockpit@900c13f. Fedora 44 already carries the fix;
+# retain the workaround only for explicitly requested Fedora 43 live images.
+if [[ "${live_version_id}" == "43" ]]; then
+ "${SCRIPT_DIR}/patch-anaconda-webui.sh"
+fi
# Apply the same AeroCore overlay to the live session where possible.
if [[ -d /src/system_files ]]; then
cp -a /src/system_files/. /
fi
+# The live base can already contain this About System asset as a symlink.
+# Replace the link itself so the installer UI also renders AeroCore branding.
+if [[ -f /src/system_files/usr/share/pixmaps/system-logo-white.png ]]; then
+ rm -f /usr/share/pixmaps/system-logo-white.png
+ install -Dm0644 \
+ /src/system_files/usr/share/pixmaps/system-logo-white.png \
+ /usr/share/pixmaps/system-logo-white.png
+fi
+
BRANDING_DIR="${SCRIPT_DIR}/branding"
if [[ -d "${BRANDING_DIR}" ]]; then
mkdir -p /usr/share/anaconda/pixmaps
@@ -59,6 +76,10 @@ install -Dm0755 \
"${SCRIPT_DIR}/configure-target-grub.sh" \
/usr/libexec/aerocore-configure-target-grub
+install -Dm0755 \
+ "${SCRIPT_DIR}/configure-target-gpt-root.sh" \
+ /usr/libexec/aerocore-configure-target-gpt-root
+
# Plasma's live-session launcher defaults to start-here, and Fedora keeps
# LOGO=fedora-logo-icon in the base live environment.
for size in 16x16 22x22 24x24 32x32 36x36 48x48 64x64 96x96 128x128 256x256; do
@@ -78,10 +99,10 @@ if [[ -f /usr/share/icons/hicolor/scalable/places/distributor-logo.svg ]]; then
/usr/share/icons/hicolor/scalable/places/start-here.svg
fi
-live_version_id="$(awk -F= '$1 == "VERSION_ID" { gsub(/\"/, "", $2); print $2; exit }' /usr/lib/os-release)"
live_product_name="AeroCore OS"
sed -i "s/^NAME=.*/NAME=\"${live_product_name}\"/" /usr/lib/os-release
sed -i "s/^PRETTY_NAME=.*/PRETTY_NAME=\"${live_product_name} ${live_version_id} (Kinoite)\"/" /usr/lib/os-release
+sed -i 's/^ID=.*/ID=aerocore/' /usr/lib/os-release
sed -i "s/^LOGO=.*/LOGO=distributor-logo/" /usr/lib/os-release
printf '%s release %s (Kinoite)\n' "${live_product_name}" "${live_version_id}" > /etc/system-release
@@ -130,6 +151,44 @@ mount --bind \${target_tmp} /var/tmp
df -h /var/tmp \${target_tmp}
%end
+# bootc relies on systemd's GPT auto-root discovery on the first boot. Anaconda
+# can leave the installed root partition with the generic Linux filesystem
+# type, which makes /dev/gpt-auto-root unavailable even though the filesystem
+# itself is healthy. Normalize the type before ostreecontainer deployment.
+%pre-install --erroronfail --log=/tmp/aerocore-gpt-root.log
+set -eux
+target_root=
+for root in /mnt/sysroot /mnt/sysimage /var/mnt/sysroot /var/mnt/sysimage; do
+ if mountpoint -q \${root}; then
+ target_root=\${root}
+ break
+ fi
+done
+if [ x\${target_root} = x ]; then
+ echo No mounted target system found for GPT root configuration >&2
+ exit 1
+fi
+/usr/libexec/aerocore-configure-target-gpt-root \${target_root}
+%end
+
+# Do not let stale Fedora boot files from an earlier installation collide with
+# the bootc payload being installed. This matches the upstream Bazzite
+# installer behavior; other vendor EFI directories are left untouched.
+%pre-install --erroronfail --log=/tmp/aerocore-efi-cleanup.log
+set -eux
+efi_root=
+for root in /mnt/sysroot /mnt/sysimage /var/mnt/sysroot /var/mnt/sysimage; do
+ if mountpoint -q \${root}/boot/efi; then
+ efi_root=\${root}/boot/efi
+ break
+ fi
+done
+if [ x\${efi_root} = x ]; then
+ echo No mounted EFI system partition found for cleanup >&2
+ exit 1
+fi
+rm -rf \${efi_root}/EFI/fedora
+%end
ostreecontainer --url=${INSTALL_IMAGE_PAYLOAD} --transport=containers-storage --no-signature-verification
# The local containers-storage deployment is intentionally unverified because
diff --git a/installer/configure-target-gpt-root.sh b/installer/configure-target-gpt-root.sh
new file mode 100755
index 0000000..6c1a2d1
--- /dev/null
+++ b/installer/configure-target-gpt-root.sh
@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+target_root=${1:?usage: configure-target-gpt-root.sh TARGET_ROOT}
+
+source_device="$(findmnt --nofsroot -no SOURCE --target "${target_root}")"
+# For a Btrfs subvolume, findmnt decorates the backing device with the
+# subvolume path (for example, /dev/vda3[/root]). lsblk and the partition
+# validation below require the undecorated block-device path.
+source_device="${source_device%%\[*}"
+# Anaconda can bind-mount the target at /mnt/sysroot. In that case findmnt's
+# human-readable SOURCE is "root", even though the mounted filesystem still
+# has the real target partition's device number. Resolve that number through
+# lsblk before requiring a partition-backed source.
+if [[ ! "${source_device}" =~ ^/dev/ ]]; then
+ source_maj_min="$(findmnt -no MAJ:MIN --target "${target_root}")"
+ source_device="$(lsblk -nrpo NAME,MAJ:MIN | awk -v wanted="${source_maj_min}" '$2 == wanted { print $1; exit }')"
+fi
+case "${source_device}" in
+ /dev/*[0-9]) ;;
+ *)
+ echo "Could not resolve a partition-backed target root: ${source_device}" >&2
+ exit 1
+ ;;
+esac
+
+parent_device="$(lsblk -no PKNAME "${source_device}")"
+partition_number="$(lsblk -no PARTN "${source_device}")"
+if [[ -z "${parent_device}" || -z "${partition_number}" ]]; then
+ echo "Could not resolve parent disk and partition number for ${source_device}" >&2
+ exit 1
+fi
+
+disk="/dev/${parent_device}"
+root_type_guid="4f68bce3-e8cd-4db1-96e7-fbcaf984b709"
+echo "Setting ${source_device} (partition ${partition_number} on ${disk}) to GPT root-x86-64 type ${root_type_guid}"
+sgdisk --typecode="${partition_number}:${root_type_guid}" "${disk}"
+partprobe "${disk}" || true
diff --git a/installer/system_files/overrides/etc/anaconda/profile.d/aerocore.conf b/installer/system_files/overrides/etc/anaconda/profile.d/aerocore.conf
new file mode 100644
index 0000000..0dd8f41
--- /dev/null
+++ b/installer/system_files/overrides/etc/anaconda/profile.d/aerocore.conf
@@ -0,0 +1,36 @@
+# Anaconda configuration for the AeroCore live installer.
+#
+# This deliberately does not inherit the Fedora KDE profile: that profile
+# hides the accounts screen, which would leave an interactive bootc install
+# without a user account. Keep the account page available so the installer
+# creates the user's requested login during installation.
+
+[Profile]
+profile_id = aerocore
+
+[Profile Detection]
+os_id = aerocore
+
+[Network]
+default_on_boot = FIRST_WIRED_WITH_LINK
+
+[Bootloader]
+efi_dir = fedora
+menu_auto_hide = True
+
+[Storage]
+default_scheme = BTRFS
+btrfs_compression = zstd:1
+default_partitioning =
+ / (min 1 GiB, max 70 GiB)
+ /home (min 500 MiB, free 50 GiB)
+ /var (btrfs)
+
+[User Interface]
+# Leave anaconda-screen-accounts visible. The user account is configured in
+# the WebUI rather than via an unused Lorax template.
+hidden_webui_pages =
+ network
+
+[Localization]
+use_geolocation = False
diff --git a/just_scripts/build-installer-iso.sh b/just_scripts/build-installer-iso.sh
index 3353651..5d40a05 100755
--- a/just_scripts/build-installer-iso.sh
+++ b/just_scripts/build-installer-iso.sh
@@ -7,7 +7,7 @@ cd "${project_root}"
image_name="${IMAGE_NAME:-localhost/$(just image_name)}"
image_tag="${IMAGE_TAG:-$(just generate-default-tag)}"
-live_base_image="${LIVE_BASE_IMAGE:-quay.io/fedora/fedora-kinoite:43}"
+live_base_image="${LIVE_BASE_IMAGE:-quay.io/fedora/fedora-kinoite:44}"
titanoboa_repository="${TITANOBOA_REPOSITORY:-https://github.com/Zeglius/titanoboa.git}"
titanoboa_revision="${TITANOBOA_REVISION:-7737f4748458252ac827dca14b3d6dd09298472a}"
source_image="${image_name}:${image_tag}"
@@ -16,7 +16,10 @@ source_image="${image_name}:${image_tag}"
# origin instead of persisting the temporary localhost name. OSTREE_IMAGE_REF
# is intentionally tagless, matching the image-info contract.
image_basename="${image_name##*/}"
-ostree_image_ref="${OSTREE_IMAGE_REF:-ostree-image-signed:docker://ghcr.io/${REPO_ORGANIZATION:-AeroCore-IO}/${image_basename}}"
+registry_organization="${REPO_ORGANIZATION:-aerocore-io}"
+registry_organization="${registry_organization,,}"
+ostree_image_ref="${OSTREE_IMAGE_REF:-ostree-image-signed:docker://ghcr.io/${registry_organization}/${image_basename}}"
+ostree_image_ref="${ostree_image_ref,,}"
install_image_ref="${ostree_image_ref#ostree-image-signed:docker://}"
install_image_ref="${install_image_ref#ostree-unverified-registry:docker://}"
install_image_ref="${install_image_ref#docker://}"
@@ -79,8 +82,32 @@ ensure_rootful_image() {
rm -rf "${copy_tmp}"
}
+validate_install_payload() {
+ # Run on the build host rather than from installer/Containerfile. Nested
+ # Podman has no /dev/fuse in the build sandbox, while this check needs to
+ # mount the payload rootfs to inspect the bootupd label source.
+ "${podman_cmd[@]}" run --rm --network none --entrypoint /usr/bin/bash \
+ "${source_image}" -ceu '
+ system_release_target=$(readlink -f /etc/system-release)
+ if od -An -v -tx1 "$system_release_target" | awk '\''{ for (i = 1; i <= NF; i++) if ($i == "00") found = 1 } END { exit(found ? 0 : 1) }'\''; then
+ echo "Install payload has a NUL byte in ${system_release_target}" >&2
+ exit 1
+ fi
+ case "$(<"$system_release_target")" in
+ "AeroCore OS release $(rpm -E %fedora) ("*) ;;
+ *)
+ echo "Unexpected system-release: $(<"$system_release_target")" >&2
+ exit 1
+ ;;
+ esac
+ jq -e '\''.["image-name"] == "aerocore-os" and .["image-vendor"] == "aerocore-io"'\'' \
+ /usr/share/ublue-os/image-info.json >/dev/null
+ '
+}
+
mkdir -p "${output_dir}"
ensure_rootful_image
+validate_install_payload
"${podman_cmd[@]}" tag "${source_image}" "${payload}"
"${podman_cmd[@]}" save --format oci-archive --output "${payload_archive}" "${payload}"
diff --git a/system_files/usr/libexec/aerocore-desktop-branding b/system_files/usr/libexec/aerocore-desktop-branding
new file mode 100755
index 0000000..84902eb
--- /dev/null
+++ b/system_files/usr/libexec/aerocore-desktop-branding
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+# Called from Bazzite's user-level setup. The setup's version files provide
+# the once-per-image-version guard; this helper only touches the current user.
+plasma_config="${HOME}/.config/plasma-org.kde.plasma.desktop-appletsrc"
+
+if [[ -f "${plasma_config}" ]] && grep -Fxq 'icon=distributor-logo-steamdeck' "${plasma_config}"; then
+ updated_config="$(mktemp "${plasma_config}.XXXXXX")"
+ trap 'rm -f -- "${updated_config}"' EXIT
+ sed 's/^icon=distributor-logo-steamdeck$/icon=aerocore-logo/' \
+ "${plasma_config}" > "${updated_config}"
+ mv -- "${updated_config}" "${plasma_config}"
+ trap - EXIT
+fi
diff --git a/system_files/usr/libexec/aerocore-desktop-branding-privileged b/system_files/usr/libexec/aerocore-desktop-branding-privileged
new file mode 100755
index 0000000..318a64a
--- /dev/null
+++ b/system_files/usr/libexec/aerocore-desktop-branding-privileged
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+about_source=/usr/share/aerocore/desktop-branding/kcm-about-distrorc
+about_target=/etc/xdg/kcm-about-distrorc
+
+# Only replace the known upstream default. Administrator changes win.
+if [[ -f "${about_source}" ]] &&
+ { [[ ! -f "${about_target}" ]] || grep -Fxq 'Name=Bazzite' "${about_target}"; }; then
+ install -Dm0644 "${about_source}" "${about_target}"
+fi
diff --git a/system_files/usr/share/aerocore/desktop-branding/kcm-about-distrorc b/system_files/usr/share/aerocore/desktop-branding/kcm-about-distrorc
new file mode 100644
index 0000000..ed2c831
--- /dev/null
+++ b/system_files/usr/share/aerocore/desktop-branding/kcm-about-distrorc
@@ -0,0 +1,5 @@
+[General]
+LogoPath=/usr/share/pixmaps/system-logo-white.png
+Name=AeroCore OS
+Website=https://os.aerocore.com.cn
+Variant=Handheld & HTPC Edition
diff --git a/system_files/usr/share/plasma/look-and-feel/com.valve.vapor.desktop/contents/plasmoidsetupscripts/org.kde.plasma.kickoff.js b/system_files/usr/share/plasma/look-and-feel/com.valve.vapor.desktop/contents/plasmoidsetupscripts/org.kde.plasma.kickoff.js
new file mode 100644
index 0000000..76ec66d
--- /dev/null
+++ b/system_files/usr/share/plasma/look-and-feel/com.valve.vapor.desktop/contents/plasmoidsetupscripts/org.kde.plasma.kickoff.js
@@ -0,0 +1,5 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+applet.currentConfigGroup = ["General"]
+applet.writeConfig("icon", "aerocore-logo")
+applet.reloadConfig()
diff --git a/system_files/usr/share/plasma/look-and-feel/com.valve.vgui.desktop/contents/plasmoidsetupscripts/org.kde.plasma.kickoff.js b/system_files/usr/share/plasma/look-and-feel/com.valve.vgui.desktop/contents/plasmoidsetupscripts/org.kde.plasma.kickoff.js
new file mode 100644
index 0000000..76ec66d
--- /dev/null
+++ b/system_files/usr/share/plasma/look-and-feel/com.valve.vgui.desktop/contents/plasmoidsetupscripts/org.kde.plasma.kickoff.js
@@ -0,0 +1,5 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+applet.currentConfigGroup = ["General"]
+applet.writeConfig("icon", "aerocore-logo")
+applet.reloadConfig()
diff --git a/system_files/usr/share/ublue-os/just/92-aerocore-decky-mirror.just b/system_files/usr/share/ublue-os/just/92-aerocore-decky-mirror.just
new file mode 100644
index 0000000..c8212eb
--- /dev/null
+++ b/system_files/usr/share/ublue-os/just/92-aerocore-decky-mirror.just
@@ -0,0 +1,172 @@
+# vim: set ft=make :
+
+# Install and configure Decky Loader from the AeroCore mirror. Options: status | install | install-prerelease | uninstall
+[group("gaming")]
+setup-decky-mirror ACTION="":
+ #!/usr/bin/bash
+ set -euo pipefail
+
+ if [[ $(id -u) -eq 0 ]]; then
+ echo >&2 "ERROR: Do not run this with sudo"
+ exit 1
+ fi
+
+ source /usr/lib/ujust/ujust.sh
+
+ if [[ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]]; then
+ SUDO_PREFIX=(sudo -A)
+ HAS_GRAPHICAL_SESSION=1
+ else
+ SUDO_PREFIX=(sudo)
+ HAS_GRAPHICAL_SESSION=0
+ fi
+
+ run_as_root() {
+ "${SUDO_PREFIX[@]}" "$@"
+ }
+
+ DECKY_MIRROR_HOST="${AEROCORE_DECKY_MIRROR_HOST:-decky.mirror.aerocore.com.cn}"
+ DECKY_INSTALLER_BASE="https://${DECKY_MIRROR_HOST}/SteamDeckHomebrew/decky-installer/releases/latest/download"
+ DECKY_PLUGIN_MIRROR_HOST="${AEROCORE_DECKY_PLUGIN_MIRROR_HOST:-plugins.decky.mirror.aerocore.com.cn}"
+ DECKY_PLUGIN_STORE_URL="https://${DECKY_PLUGIN_MIRROR_HOST}/plugins"
+
+ get_status_token() {
+ local decky_service="/etc/systemd/system/plugin_loader.service"
+ if [[ ! -f /etc/systemd/system/multi-user.target.wants/plugin_loader.service ]]; then
+ echo "uninstall"
+ elif [[ -r "$decky_service" ]] && grep -q 'Environment=LOG_LEVEL=DEBUG' "$decky_service"; then
+ echo "install-prerelease"
+ elif [[ -r "$decky_service" ]]; then
+ echo "install"
+ else
+ return 1
+ fi
+ }
+
+ DECKY_STATE="${b}${red}Not Installed${n}"
+ case "$(get_status_token)" in
+ install)
+ DECKY_STATE="${b}${green}Installed${n}"
+ ;;
+ install-prerelease)
+ DECKY_STATE="${b}${yellow}Installed (Prerelease)${n}"
+ ;;
+ uninstall)
+ DECKY_STATE="${b}${red}Not Installed${n}"
+ ;;
+ esac
+
+ setup_home_deck_compat() {
+ export HOME
+ HOME="$(getent passwd "${SUDO_USER:-$USER}" | cut -d: -f6)"
+ if [[ ! -L "/home/deck" && ! -e "/home/deck" && "$HOME" != "/home/deck" ]]; then
+ echo "Making a /home/deck symlink to fix plugins that do not use environment variables."
+ run_as_root ln -sf "$HOME" /home/deck
+ fi
+ }
+
+ configure_plugin_store() {
+ echo "Configuring Decky plugin store: ${DECKY_PLUGIN_STORE_URL}"
+ local settings_file="$HOME/homebrew/settings/loader.json"
+ local settings_dir="$(dirname "$settings_file")"
+ local temporary_file
+ mkdir -p "$settings_dir"
+ temporary_file="$(mktemp "${settings_dir}/.loader.XXXXXX")"
+ if [[ -f "$settings_file" ]]; then
+ jq --arg store_url "$DECKY_PLUGIN_STORE_URL" '.store = 2 | ."store-url" = $store_url' "$settings_file" > "$temporary_file"
+ else
+ jq -n --arg store_url "$DECKY_PLUGIN_STORE_URL" '{store: 2, "store-url": $store_url}' > "$temporary_file"
+ fi
+ chmod --reference="$settings_file" "$temporary_file" 2>/dev/null || chmod 0644 "$temporary_file"
+ mv -f "$temporary_file" "$settings_file"
+ }
+
+ install_release() {
+ setup_home_deck_compat
+ if [[ "$HAS_GRAPHICAL_SESSION" -eq 1 ]]; then
+ curl -L "${DECKY_INSTALLER_BASE}/install_release.sh" |
+ sed 's|sudo|sudo -A |g' | sh
+ else
+ curl -L "${DECKY_INSTALLER_BASE}/install_release.sh" | sh
+ fi
+ run_as_root chcon -R -t bin_t "$HOME/homebrew/services/PluginLoader"
+ run_as_root systemctl stop plugin_loader.service 2>/dev/null || true
+ configure_plugin_store
+ run_as_root systemctl start plugin_loader.service 2>/dev/null || true
+ }
+
+ install_prerelease() {
+ setup_home_deck_compat
+ if [[ "$HAS_GRAPHICAL_SESSION" -eq 1 ]]; then
+ curl -L "${DECKY_INSTALLER_BASE}/install_prerelease.sh" |
+ sed 's|sudo|sudo -A |g' | sh
+ else
+ curl -L "${DECKY_INSTALLER_BASE}/install_prerelease.sh" | sh
+ fi
+ run_as_root chcon -R -t bin_t "$HOME/homebrew/services/PluginLoader"
+ run_as_root systemctl stop plugin_loader.service 2>/dev/null || true
+ configure_plugin_store
+ run_as_root systemctl start plugin_loader.service 2>/dev/null || true
+ }
+
+ uninstall() {
+ echo "Uninstalling Decky Loader..."
+ if [[ "$HAS_GRAPHICAL_SESSION" -eq 1 ]]; then
+ curl -L "${DECKY_INSTALLER_BASE}/uninstall.sh" |
+ sed -e 's|sudo|sudo -A |g' \
+ -e 's|sudo -A rm "${HOMEBREW_FOLDER}/services/PluginLoader"|sudo -A rm -rf "${HOMEBREW_FOLDER}/services/PluginLoader"|' \
+ -e 's|sudo -A rm "${USER_DIR}/.steam/steam/.cef-enable-remote-debugging"|sudo -A rm -f "${USER_DIR}/.steam/steam/.cef-enable-remote-debugging"|' \
+ -e 's|sudo -A rm "${USER_DIR}/.var/app/com.valvesoftware.Steam/data/Steam/.cef-enable-remote-debugging"|sudo -A rm -f "${USER_DIR}/.var/app/com.valvesoftware.Steam/data/Steam/.cef-enable-remote-debugging"|' | sh
+ else
+ curl -L "${DECKY_INSTALLER_BASE}/uninstall.sh" |
+ sed -e 's|sudo rm "${HOMEBREW_FOLDER}/services/PluginLoader"|sudo rm -rf "${HOMEBREW_FOLDER}/services/PluginLoader"|' \
+ -e 's|sudo rm "${USER_DIR}/.steam/steam/.cef-enable-remote-debugging"|sudo rm -f "${USER_DIR}/.steam/steam/.cef-enable-remote-debugging"|' \
+ -e 's|sudo rm "${USER_DIR}/.var/app/com.valvesoftware.Steam/data/Steam/.cef-enable-remote-debugging"|sudo rm -f "${USER_DIR}/.var/app/com.valvesoftware.Steam/data/Steam/.cef-enable-remote-debugging"|' | sh
+ fi
+ echo "Decky Loader has been uninstalled."
+ }
+
+ OPTION="{{ ACTION }}"
+ if [[ "$OPTION" == "help" ]]; then
+ echo "Usage: ujust setup-decky-mirror <option>"
+ echo " <option>: Specify the quick option to skip the prompt"
+ echo " Use 'status' to print the current portal token"
+ echo " Use 'install' to select Install Decky from the AeroCore mirror"
+ echo " Use 'install-prerelease' to select Install Decky Prerelease from the AeroCore mirror"
+ echo " Use 'uninstall' to uninstall Decky Loader using the AeroCore mirror"
+ exit 0
+ elif [[ "$OPTION" == "status" ]]; then
+ get_status_token
+ exit $?
+ elif [[ "$OPTION" == "" ]]; then
+ if [[ "$HAS_GRAPHICAL_SESSION" -eq 0 ]]; then
+ echo "No graphical session detected. Use 'ujust setup-decky-mirror install' from a TTY, or run this command in KDE/Steam Gaming Mode."
+ exit 2
+ fi
+ echo "${bold}Decky Loader setup and configuration${normal}"
+ echo "Decky is $DECKY_STATE"
+ OPTION="$(ugum choose "Install Decky from AeroCore Mirror" "Install Decky Prerelease from AeroCore Mirror" "Uninstall Decky" "Exit")"
+ case "$OPTION" in
+ "Install Decky from AeroCore Mirror")
+ install_release
+ ;;
+ "Install Decky Prerelease from AeroCore Mirror")
+ install_prerelease
+ ;;
+ "Uninstall Decky")
+ uninstall
+ ;;
+ *)
+ echo "No changes were made."
+ ;;
+ esac
+ elif [[ "$OPTION" == "install" ]]; then
+ install_release
+ exit 0
+ elif [[ "$OPTION" == "install-prerelease" || "$OPTION" == "prerelease" ]]; then
+ install_prerelease
+ exit 0
+ elif [[ "$OPTION" == "uninstall" ]]; then
+ uninstall
+ exit 0
+ fi
diff --git a/tests/fixtures/bazzite-44/usr/bin/bazzite-steam b/tests/fixtures/bazzite-44/usr/bin/bazzite-steam
new file mode 100644
index 0000000..9249ce1
--- /dev/null
+++ b/tests/fixtures/bazzite-44/usr/bin/bazzite-steam
@@ -0,0 +1,10 @@
+#!/usr/bin/bash
+
+# Minimal structural fixture from Bazzite 44.20260820.
+if [[ $IMAGE_NAME =~ "deck" ]]; then
+ if [[ ! -d $HOME/.local/share/Steam ]]; then
+ mkdir -p "$HOME/.local/share"
+ fi
+
+ DECK_OPTION="-steamdeck"
+fi
diff --git a/tests/fixtures/bazzite-44/usr/libexec/bazzite-hardware-setup b/tests/fixtures/bazzite-44/usr/libexec/bazzite-hardware-setup
new file mode 100644
index 0000000..54aa246
--- /dev/null
+++ b/tests/fixtures/bazzite-44/usr/libexec/bazzite-hardware-setup
@@ -0,0 +1,10 @@
+#!/usr/bin/bash
+
+# Minimal structural fixture from Bazzite 44.20260820.
+if [[ "$IMAGE_NAME" != *deck* && "$IMAGE_NAME" != *dx* ]]; then
+ rm -f /etc/sddm.conf.d/steamos.conf
+fi
+
+if [[ $IMAGE_NAME =~ "deck" ]]; then
+ systemctl mask tuned.service
+fi
diff --git a/tests/fixtures/bazzite-44/usr/libexec/bazzite-user-setup b/tests/fixtures/bazzite-44/usr/libexec/bazzite-user-setup
new file mode 100644
index 0000000..9466a0b
--- /dev/null
+++ b/tests/fixtures/bazzite-44/usr/libexec/bazzite-user-setup
@@ -0,0 +1,16 @@
+#!/usr/bin/bash
+
+pkexec /usr/libexec/bazzite-privileged-user-setup "$USER"
+
+# Minimal structural fixture from Bazzite 44.20260820.
+if [[ $IMAGE_NAME =~ "deck" ]]; then
+ touch "$HOME/Desktop/Return.desktop"
+fi
+
+if [[ $IMAGE_NAME =~ "deck" ]]; then
+ touch "$HOME/.config/bazzite/desktop-configured"
+fi
+
+if [[ $IMAGE_NAME =~ "deck" ]]; then
+ systemctl --user enable --now sdgyrodsu.service
+fi
diff --git a/tests/test-aerocore-anaconda-profile.sh b/tests/test-aerocore-anaconda-profile.sh
new file mode 100755
index 0000000..3b3bb61
--- /dev/null
+++ b/tests/test-aerocore-anaconda-profile.sh
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+profile="${project_root}/installer/system_files/overrides/etc/anaconda/profile.d/aerocore.conf"
+build_script="${project_root}/installer/build.sh"
+
+grep -qx 'profile_id = aerocore' "${profile}"
+grep -qx 'os_id = aerocore' "${profile}"
+grep -Fqx "sed -i 's/^ID=.*/ID=aerocore/' /usr/lib/os-release" "${build_script}"
+
+if grep -Eq '^[[:space:]]*(UserSpoke|anaconda-screen-accounts)[[:space:]]*$' "${profile}"; then
+ echo "AeroCore's Anaconda profile must leave account creation visible" >&2
+ exit 1
+fi
diff --git a/tests/test-build-channel-metadata.sh b/tests/test-build-channel-metadata.sh
new file mode 100644
index 0000000..75a2827
--- /dev/null
+++ b/tests/test-build-channel-metadata.sh
@@ -0,0 +1,20 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+# The build recipe's positional tag must be authoritative for channel
+# metadata. This protects `just build ... testing` from stale dotenv values.
+grep -Fqx ' "--build-arg" "IMAGE_BRANCH={{ tag }}"' "${repo_root}/Justfile"
+grep -Fqx ' "--build-arg" "VERSION_TAG={{ tag }}"' "${repo_root}/Justfile"
+grep -Fqx ' "--build-arg" "VERSION_PRETTY={{ tag }}"' "${repo_root}/Justfile"
+grep -Fqx ' LABELS+=("--label" "org.opencontainers.image.version={{ tag }}-${GIT_SHA}")' "${repo_root}/Justfile"
+grep -Fqx ' "image-tag": "$IMAGE_BRANCH_NORMALIZED",' "${repo_root}/build_files/image-info"
+
+if grep -Fq ' "image-tag": "stable",' "${repo_root}/build_files/image-info"; then
+ echo 'image-info must not hard-code stable as the image tag' >&2
+ exit 1
+fi
+
+echo 'Build channel metadata checks passed'
diff --git a/tests/test-configure-target-gpt-root.sh b/tests/test-configure-target-gpt-root.sh
new file mode 100755
index 0000000..ed5e33e
--- /dev/null
+++ b/tests/test-configure-target-gpt-root.sh
@@ -0,0 +1,51 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+project_root="$(git rev-parse --show-toplevel)"
+tmp_dir="$(mktemp -d)"
+trap 'rm -rf "${tmp_dir}"' EXIT
+mkdir -p "${tmp_dir}/bin"
+
+# shellcheck disable=SC2016 # The generated fixture expands this in its own process.
+printf '%s\n' \
+ '#!/usr/bin/env bash' \
+ 'if [[ " $* " == *" -no SOURCE "* ]]; then' \
+ ' [[ " $* " == *" --nofsroot "* ]] || exit 97' \
+ ' printf "%s\\n" "${TEST_FINDMNT_SOURCE:?}"' \
+ 'else' \
+ " printf '252:3\\n'" \
+ 'fi' > "${tmp_dir}/bin/findmnt"
+
+printf '%s\n' \
+ '#!/usr/bin/env bash' \
+ 'case " $* " in' \
+ " *\" PKNAME \"*) printf 'vda\\n' ;;" \
+ " *\" PARTN \"*) printf '3\\n' ;;" \
+ " *\" PARTNUM \"*) exit 98 ;;" \
+ " *) printf '/dev/vda3 252:3\\n' ;;" \
+ 'esac' > "${tmp_dir}/bin/lsblk"
+
+# shellcheck disable=SC2016 # The generated fixture expands TEST_OUTPUT itself.
+printf '%s\n' \
+ '#!/usr/bin/env bash' \
+ 'printf "%s\\n" "$*" > "${TEST_OUTPUT:?}"' > "${tmp_dir}/bin/sgdisk"
+
+printf '%s\n' '#!/usr/bin/env bash' 'exit 0' > "${tmp_dir}/bin/partprobe"
+
+chmod +x "${tmp_dir}/bin/"*
+
+run_fixture() {
+ local findmnt_source=$1
+
+ TEST_FINDMNT_SOURCE="${findmnt_source}" TEST_OUTPUT="${tmp_dir}/sgdisk.args" \
+ PATH="${tmp_dir}/bin:${PATH}" \
+ bash "${project_root}/installer/configure-target-gpt-root.sh" /mnt/sysroot
+
+ grep -qx -- '--typecode=3:4f68bce3-e8cd-4db1-96e7-fbcaf984b709 /dev/vda' \
+ "${tmp_dir}/sgdisk.args"
+}
+
+run_fixture root
+run_fixture '/dev/vda3[/root]'
+printf 'GPT root bind-mount and Btrfs subvolume fixtures passed\n'
diff --git a/tests/test-decky-mirror-ujust.sh b/tests/test-decky-mirror-ujust.sh
new file mode 100755
index 0000000..6950c31
--- /dev/null
+++ b/tests/test-decky-mirror-ujust.sh
@@ -0,0 +1,51 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+recipe="${project_root}/system_files/usr/share/ublue-os/just/92-aerocore-decky-mirror.just"
+
+if [[ ! -f "${recipe}" ]]; then
+ echo "Missing Decky mirror ujust recipe: ${recipe}" >&2
+ exit 1
+fi
+
+if ! rg -q '^setup-decky-mirror ACTION=""' "${recipe}"; then
+ echo "Decky mirror recipe must expose ujust setup-decky-mirror" >&2
+ exit 1
+fi
+
+if ! rg -q 'decky\.mirror\.aerocore\.com\.cn' "${recipe}"; then
+ echo "Decky mirror recipe must default to the AeroCore Decky mirror host" >&2
+ exit 1
+fi
+
+if rg -q 'https://github\.com/SteamDeckHomebrew/decky-installer' "${recipe}"; then
+ echo "Decky mirror recipe must not install from GitHub" >&2
+ exit 1
+fi
+
+if ! rg -q 'configure_plugin_store\(\)' "${recipe}" || \
+ ! rg -q 'plugins\.decky\.mirror\.aerocore\.com\.cn' "${recipe}" || \
+ ! rg -q 'DECKY_PLUGIN_STORE_URL=.*\/plugins' "${recipe}"; then
+ echo "Decky mirror recipe must configure the AeroCore plugin store URL" >&2
+ exit 1
+fi
+
+if ! rg -q 'loader\.json' "${recipe}" || ! rg -q '\.store = 2' "${recipe}" || ! rg -q 'store-url' "${recipe}"; then
+ echo "Decky mirror recipe must persist the custom plugin store in loader.json" >&2
+ exit 1
+fi
+
+if ! rg -q 'rm -rf.*HOMEBREW_FOLDER.*/services/PluginLoader' "${recipe}" ||
+ ! rg -q 'rm -f.*USER_DIR.*/\.steam/steam/\.cef-enable-remote-debugging' "${recipe}" ||
+ ! rg -q 'rm -f.*USER_DIR.*/\.var/app/com\.valvesoftware\.Steam/data/Steam/\.cef-enable-remote-debugging' "${recipe}"; then
+ echo "Decky mirror recipe must make uninstall safe for missing paths" >&2
+ exit 1
+fi
+
+for script_name in install_release.sh install_prerelease.sh uninstall.sh; do
+ if ! rg -q "\\$\\{DECKY_INSTALLER_BASE\\}/${script_name}" "${recipe}"; then
+ echo "Decky mirror recipe does not route ${script_name} through the mirror base" >&2
+ exit 1
+ fi
+done
diff --git a/tests/test-desktop-branding-migration.sh b/tests/test-desktop-branding-migration.sh
new file mode 100755
index 0000000..4715010
--- /dev/null
+++ b/tests/test-desktop-branding-migration.sh
@@ -0,0 +1,59 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+test_root="$(mktemp -d -t aerocore-desktop-branding.XXXXXXXXXX)"
+trap 'rm -rf "${test_root}"' EXIT
+
+mkdir -p \
+ "${test_root}/var/home/stock/.config" \
+ "${test_root}/var/home/custom/.config"
+
+printf '%s\n' 'icon=distributor-logo-steamdeck' \
+ > "${test_root}/var/home/stock/.config/plasma-org.kde.plasma.desktop-appletsrc"
+printf '%s\n' 'icon=user-selected-logo' \
+ > "${test_root}/var/home/custom/.config/plasma-org.kde.plasma.desktop-appletsrc"
+
+HOME="${test_root}/var/home/stock" \
+ bash "${repo_root}/system_files/usr/libexec/aerocore-desktop-branding"
+
+grep -Fxq 'icon=aerocore-logo' \
+ "${test_root}/var/home/stock/.config/plasma-org.kde.plasma.desktop-appletsrc"
+grep -Fxq 'icon=user-selected-logo' \
+ "${test_root}/var/home/custom/.config/plasma-org.kde.plasma.desktop-appletsrc"
+
+# The migration must also be idempotent.
+first_digest="$(sha256sum \
+ "${test_root}/var/home/stock/.config/plasma-org.kde.plasma.desktop-appletsrc")"
+HOME="${test_root}/var/home/stock" \
+ bash "${repo_root}/system_files/usr/libexec/aerocore-desktop-branding"
+second_digest="$(sha256sum \
+ "${test_root}/var/home/stock/.config/plasma-org.kde.plasma.desktop-appletsrc")"
+test "${first_digest}" = "${second_digest}"
+
+for path in \
+ bazzite.webm \
+ bazzite-oled.webm \
+ bazzite-suspend.webm \
+ bazzite-suspend-oled.webm; do
+ grep -Fq "${path}" "${repo_root}/build_files/build.sh"
+done
+
+# The About System logo may be a symlink in the Bazzite base image. Both image
+# build paths must explicitly replace that directory entry with AeroCore's PNG.
+grep -Fq '"/usr/share/pixmaps/system-logo-white.png"' \
+ "${repo_root}/build_files/build.sh"
+grep -Fq 'rm -f /usr/share/pixmaps/system-logo-white.png' \
+ "${repo_root}/installer/build.sh"
+
+# Branding migration is user setup, not a system boot service.
+if rg -n 'aerocore-desktop-branding\.service|systemctl enable aerocore-desktop-branding' \
+ "${repo_root}/build_files" "${repo_root}/system_files"; then
+ echo "Desktop branding must not be enabled as a system service" >&2
+ exit 1
+fi
+test ! -e "${repo_root}/system_files/usr/lib/systemd/system/aerocore-desktop-branding.service"
+test -x "${repo_root}/system_files/usr/libexec/aerocore-desktop-branding-privileged"
+
+echo "Desktop branding migration fixture passed"
diff --git a/tests/test-dotenv-precedence.sh b/tests/test-dotenv-precedence.sh
new file mode 100755
index 0000000..f2cc760
--- /dev/null
+++ b/tests/test-dotenv-precedence.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+tmp_dir="$(mktemp -d)"
+trap 'rm -rf "${tmp_dir}"' EXIT
+
+grep -Fqx 'set dotenv-filename := ".env"' "${project_root}/Justfile"
+grep -Fqx '.env' "${project_root}/.gitignore"
+if rg -F -- '--dotenv-path image-template.env' "${project_root}/.github/workflows"; then
+ echo 'CI must not load image-template.env as a dotenv file' >&2
+ exit 1
+fi
+
+printf '%s\n' \
+ 'set dotenv-load' \
+ 'value := env_var("VALUE")' \
+ 'show:' \
+ ' @echo "{{value}}"' > "${tmp_dir}/justfile"
+printf '%s\n' 'VALUE=from-dotenv' > "${tmp_dir}/.env"
+
+[[ "$(cd "${tmp_dir}" && just show)" == 'from-dotenv' ]]
+[[ "$(cd "${tmp_dir}" && VALUE=from-runtime just show)" == 'from-runtime' ]]
+
+mkdir "${tmp_dir}/without-dotenv"
+cp "${tmp_dir}/justfile" "${tmp_dir}/without-dotenv/justfile"
+[[ "$(cd "${tmp_dir}/without-dotenv" && VALUE=from-runtime just show)" == 'from-runtime' ]]
diff --git a/tests/test-patch-bazzite-deck-identity.sh b/tests/test-patch-bazzite-deck-identity.sh
new file mode 100755
index 0000000..fa420ce
--- /dev/null
+++ b/tests/test-patch-bazzite-deck-identity.sh
@@ -0,0 +1,58 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+fixture_root="${repo_root}/tests/fixtures/bazzite-44"
+test_root="$(mktemp -d -t aerocore-deck-identity.XXXXXXXXXX)"
+trap 'rm -rf "${test_root}"' EXIT
+
+cp -a "${fixture_root}/." "${test_root}/"
+
+run_patch() {
+ AEROCORE_DECK_COMPAT_ROOT="${test_root}" \
+ bash "${repo_root}/build_files/patch-bazzite-deck-identity.sh"
+}
+
+assert_count() {
+ local expected="$1"
+ local pattern="$2"
+ local file="$3"
+ local actual
+
+ actual="$(grep -Foc "${pattern}" "${file}" || true)"
+ if [[ "${actual}" != "${expected}" ]]; then
+ echo "Expected ${expected} occurrence(s) of '${pattern}' in ${file}, found ${actual}" >&2
+ exit 1
+ fi
+}
+
+hardware_setup="${test_root}/usr/libexec/bazzite-hardware-setup"
+steam="${test_root}/usr/bin/bazzite-steam"
+user_setup="${test_root}/usr/libexec/bazzite-user-setup"
+# shellcheck disable=SC2016
+deck_condition='if [[ $IMAGE_NAME =~ "deck" || $IMAGE_NAME == aerocore-os ]]; then'
+# shellcheck disable=SC2016
+non_deck_condition='if [[ "$IMAGE_NAME" != aerocore-os && "$IMAGE_NAME" != *deck* && "$IMAGE_NAME" != *dx* ]]; then'
+
+run_patch
+
+assert_count 1 "${non_deck_condition}" "${hardware_setup}"
+assert_count 1 "${deck_condition}" "${hardware_setup}"
+assert_count 1 "${deck_condition}" "${steam}"
+assert_count 3 "${deck_condition}" "${user_setup}"
+assert_count 1 'AeroCore Steam state migration' "${steam}"
+
+# A second run must recognize the patched fixture and make no further changes.
+first_digest="$(sha256sum "${hardware_setup}" "${steam}" "${user_setup}")"
+run_patch
+second_digest="$(sha256sum "${hardware_setup}" "${steam}" "${user_setup}")"
+
+if [[ "${first_digest}" != "${second_digest}" ]]; then
+ echo "Deck identity patch is not idempotent" >&2
+ exit 1
+fi
+
+assert_count 1 'AeroCore Steam state migration' "${steam}"
+
+echo "Bazzite 44 Deck identity fixture passed"