diff options
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: | @@ -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 && \ @@ -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[@]}" . @@ -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" |
