chore: import upstream snapshot with attribution
CI / lint (push) Failing after 13m4s
CI / test (3.11, ubuntu-latest) (push) Failing after 2m4s
CI / test (3.13, ubuntu-latest) (push) Successful in 13m30s
CI / test (3.14, ubuntu-latest) (push) Successful in 17m21s
CI / test (3.12, ubuntu-latest) (push) Successful in 17m55s
CI / discover-apps-ps (push) Successful in 1m56s
CI / test (3.9, ubuntu-latest) (push) Successful in 13m17s
CI / test (3.10, ubuntu-latest) (push) Successful in 26m21s
CI / audit (push) Successful in 13m38s
Deploy site / deploy (push) Has been cancelled
CI / test (3.14, ubuntu-24.04-arm) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:32:37 +08:00
commit 3e779be6f3
474 changed files with 118156 additions and 0 deletions
+283
View File
@@ -0,0 +1,283 @@
name: AppImage
on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
inputs:
tag:
description: "Optional release tag to build from (e.g. v0.5.8). Leave empty to build from the selected ref (usually main) -- useful for verifying the recipe before cutting a tag."
required: false
default: ""
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
# workflow_dispatch input: explicit tag; tag push: ref already there.
ref: ${{ github.event.inputs.tag || github.ref }}
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install build prerequisites
run: |
python -m pip install --upgrade pip
python -m pip install --upgrade build
- name: Pre-install appimagetool (extract-and-wrap, FUSE-free)
# GitHub Actions ubuntu-24.04 runners cannot mount AppImages
# via FUSE in the sandboxed environment. Download appimagetool
# ourselves, extract its squashfs payload, and put a thin
# wrapper on PATH at /usr/local/bin/appimagetool that exec's
# into the extracted AppRun.
run: |
cd /tmp
wget -q https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage -O appimagetool.AppImage
chmod +x appimagetool.AppImage
./appimagetool.AppImage --appimage-extract >/dev/null
sudo mv squashfs-root /opt/appimagetool
sudo tee /usr/local/bin/appimagetool >/dev/null <<'WRAPPER'
#!/bin/sh
exec /opt/appimagetool/AppRun "$@"
WRAPPER
sudo chmod +x /usr/local/bin/appimagetool
appimagetool --version || true
- name: Build winpodx wheel
run: python -m build --wheel --outdir dist
- name: Stage AppDir with portable Python + winpodx
# Bypass python-appimage entirely -- it does its own
# appimagetool download that ignores our PATH wrapper and
# silently fails on CI. Instead: drop a python-build-standalone
# portable Python 3.11 into AppDir, install winpodx into its
# site-packages, write AppRun + desktop + icon, then hand off
# to bundle-system-bins.sh for the FreeRDP overlay.
#
# Thin AppImage (0.6.0 item A): we no longer pip-install
# podman-compose into the bundled interpreter, and the dnf step
# below no longer installs the podman stack. Rootless podman
# cannot be bundled (host systemd / subuid integration); the
# AppImage now requires the user to install podman / docker /
# libvirt from the host package manager. See #357 / #363 for the
# root cause this redesign removes.
run: |
set -euo pipefail
mkdir -p AppDir/opt AppDir/usr/bin AppDir/usr/lib
# Portable Python 3.11 with linked OpenSSL, gnu-linux x86_64.
# Pinned to a known-good astral-sh python-build-standalone release.
# Bump the tag + version pair together when refreshing.
PYBUILD_URL="https://github.com/astral-sh/python-build-standalone/releases/download/20240909/cpython-3.11.10+20240909-x86_64-unknown-linux-gnu-install_only.tar.gz"
curl -fsSL "${PYBUILD_URL}" -o /tmp/python-standalone.tar.gz
# Supply-chain: verify against the upstream .sha256 sidecar
# (astral-sh publishes one per asset). The portable interpreter
# executes winpodx's code -- a tampered asset would run with the
# user's RDP password + agent token, so fail closed on mismatch.
curl -fsSL "${PYBUILD_URL}.sha256" -o /tmp/python-standalone.sha256
echo "$(cat /tmp/python-standalone.sha256) /tmp/python-standalone.tar.gz" | sha256sum -c -
tar -xzf /tmp/python-standalone.tar.gz -C AppDir/opt/
# Tarball extracts to AppDir/opt/python/ on the install_only build.
AppDir/opt/python/bin/python3 -m pip install --upgrade pip
WHEEL="$(ls -t dist/winpodx-*.whl | head -n1)"
AppDir/opt/python/bin/python3 -m pip install --no-cache-dir "${WHEEL}[gui,reverse-open]"
# AppRun
cat > AppDir/AppRun <<'APPRUN'
#!/bin/sh
APPDIR="$(dirname "$(readlink -f "$0")")"
export PATH="$APPDIR/usr/bin:$APPDIR/opt/python/bin:$PATH"
export LD_LIBRARY_PATH="$APPDIR/usr/lib:$APPDIR/opt/python/lib:$LD_LIBRARY_PATH"
export PYTHONPATH="$APPDIR/opt/python/lib/python3.11/site-packages:$PYTHONPATH"
# Invoke via `python3 -m winpodx`, NOT the pip-generated
# bin/winpodx console script. pip bakes an absolute build-time
# shebang (#!/home/runner/work/.../AppDir/opt/python/bin/python3)
# into bin/winpodx that doesn't exist at AppImage-extract time
# -> "bad interpreter". The -m form runs the module through the
# bundled interpreter directly and ignores the broken shebang.
exec "$APPDIR/opt/python/bin/python3" -m winpodx "$@"
APPRUN
chmod +x AppDir/AppRun
# Desktop + icon -- appimagetool expects them in AppDir root.
cp packaging/appimage/recipe/winpodx.desktop AppDir/
cp packaging/appimage/recipe/winpodx.png AppDir/
# Symlink common icon location too (some launchers look here)
mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps
cp packaging/appimage/recipe/winpodx.png AppDir/usr/share/icons/hicolor/256x256/apps/winpodx.png
# License texts (LGPL/GPL/Apache/PSF redistribution obligation --
# the fat AppImage redistributes third-party binaries, so their
# license + NOTICE texts must travel with it). winpodx's own MIT
# LICENSE + the aggregate THIRD_PARTY_LICENSES.md go at the doc
# root; the Fedora-bundled binaries' texts are added by the
# docker step below under third-party/. PyPI deps (PySide6/Qt,
# Pillow, cairosvg, pyxdg) already carry their license in their
# site-packages *.dist-info inside AppDir/opt/python.
mkdir -p AppDir/usr/share/doc/winpodx/third-party
cp LICENSE THIRD_PARTY_LICENSES.md AppDir/usr/share/doc/winpodx/
# python-build-standalone ships its license files; surface them.
find AppDir/opt/python -maxdepth 3 -iname "LICENSE*" -path "*python*" \
-exec cp {} AppDir/usr/share/doc/winpodx/third-party/python-LICENSE.txt \; 2>/dev/null || true
# LGPL compliance (legal gate): the fat AppImage REDISTRIBUTES the
# PySide6/Qt6 binaries (LGPL-3.0), so their license text must travel
# with it. The PySide6 wheels do NOT ship the LGPL/GPL texts (verified:
# neither the dist-info nor the PySide6/ tree carries them), so we
# vendor the verbatim texts + a NOTICE in-repo under
# packaging/appimage/licenses/pyside6/ and copy those in. HARD-FAIL if
# the vendored texts are missing, so the compliance artifact can't be
# dropped silently.
mkdir -p AppDir/usr/share/doc/winpodx/third-party/pyside6
cp packaging/appimage/licenses/pyside6/*.txt \
AppDir/usr/share/doc/winpodx/third-party/pyside6/
if ! ls AppDir/usr/share/doc/winpodx/third-party/pyside6/ 2>/dev/null \
| grep -qiE "lgpl"; then
echo "::error::LGPL compliance: no PySide6/Qt LGPL text bundled in the AppImage; refusing to publish a non-compliant build." >&2
exit 1
fi
du -sh AppDir
- name: Slim PySide6 — drop unused Qt6 modules
# PySide6 wheels bundle the entire Qt6 stack (QtWebEngine ~100 MB+,
# QtQuick/QtQml, Qt3D, QtCharts, QtMultimedia + FFmpeg, QtPdf, …).
# winpodx links only QtCore/QtGui/QtWidgets/QtSvg/QtDBus, so the rest
# is dead weight — this was the real reason the "Thin" AppImage was
# still ~270 MB (dropping the podman stack only saved ~20 MB). The
# prune removes ~500 MB uncompressed (the bulk of the AppImage).
run: |
bash packaging/appimage/slim-pyside6.sh \
AppDir/opt/python/lib/python3.11/site-packages/PySide6
du -sh AppDir
- name: Bundle FreeRDP via Fedora 41
# Thin-bundle stage: pulls only the FreeRDP 3 client binaries +
# libwinpr out of a Fedora 41 container and traverses ldd to bring
# in transitive lib deps. Host-critical libs (glibc, libX11,
# libwayland, libGL, ...) stay on the host -- they cannot be
# bundled without breaking desktop integration or hard-crashing
# on glibc mismatch.
#
# The container stack (podman / podman-compose / conmon / crun /
# netavark / aardvark-dns / slirp4netns / passt / pasta) is
# intentionally NOT installed here -- 0.6.0 item A drops it.
# Rootless podman needs host systemd / subuid integration that
# an AppImage can't carry; bundling it caused #357 (shadowed
# host podman-compose) and #363 (LD_LIBRARY_PATH-poisoned
# systemd-run / aardvark-dns). The AppImage now requires the
# user to install a container runtime from the host package
# manager (same model as install.sh).
run: |
docker run --rm \
-v "$PWD/AppDir:/AppDir" \
-v "$PWD/packaging/appimage:/scripts" \
fedora:41 bash -c '
set -euo pipefail
dnf install -y --setopt=install_weak_deps=False \
freerdp \
freerdp-libs \
libwinpr \
file
echo "[provenance] freerdp binaries provided:"
rpm -ql freerdp | grep -E "^(/usr/bin|/usr/libexec)/.*freerdp.*" || true
bash /scripts/bundle-system-bins.sh /AppDir
# License + NOTICE texts for the bundled FreeRDP stack
# (Apache-2.0). VENDORED in-repo at
# packaging/appimage/licenses/ (mounted at /scripts/licenses),
# harvested verbatim from the Fedora rpm payloads -- see
# that dir`s README for provenance + refresh steps. We do
# NOT read them off the installed filesystem: the fedora
# image installs with `tsflags=nodocs`, which strips
# /usr/share/licenses, and every attempt to defeat that on
# the CI runner silently produced nothing.
#
# Thin AppImage: only the FreeRDP stack`s license texts
# ride along now; the container-stack license dirs (podman
# / podman-compose / conmon / crun / netavark / passt /
# slirp4netns) stay vendored in-repo for provenance but are
# no longer copied into the AppImage because their binaries
# are no longer bundled.
dst=/AppDir/usr/share/doc/winpodx/third-party
mkdir -p "$dst"
src=/scripts/licenses
# REQUIRED FreeRDP-stack license texts -- fail the build if
# missing. Apache-2.0 redistribution obligation: freerdp-libs
# + libwinpr ship the combined FreeRDP license.
for pkg in freerdp-libs libwinpr; do
if [ ! -d "$src/$pkg" ]; then
echo "FATAL: vendored license dir $src/$pkg missing -- cannot ship a" >&2
echo "license-compliant AppImage. Re-run packaging/appimage/licenses refresh." >&2
exit 1
fi
cp -rL "$src/$pkg" "$dst/$pkg"
done
echo "[provenance] bundled license dirs:"
ls -1 "$dst" || true
'
- name: Repack as thin AppImage
env:
ARCH: x86_64
run: |
OUT="winpodx-x86_64.AppImage"
appimagetool AppDir "${OUT}"
chmod +x "${OUT}"
ls -lh "${OUT}"
- name: Locate built AppImage
id: locate
run: |
APPIMAGE="$(ls -t winpodx-x86_64.AppImage | head -n1)"
if [ -z "${APPIMAGE}" ]; then
echo "No AppImage produced." >&2
exit 1
fi
echo "appimage_path=${APPIMAGE}" >> "$GITHUB_OUTPUT"
ls -lh "${APPIMAGE}"
- name: Upload as workflow artefact (always)
uses: actions/upload-artifact@v4
with:
name: winpodx-appimage-x86_64
path: ${{ steps.locate.outputs.appimage_path }}
if-no-files-found: error
# Single-writer rule: release.yml (REL- marker tag) is the SOLE creator
# of the GitHub Release and the only writer of its title/notes. This
# workflow only attaches the AppImage to an already-created release —
# it never creates or edits the notes. (softprops/action-gh-release
# would create-or-update the release even with only `files:` set, which
# contributed to the "edited N times" churn; `gh release upload` to an
# existing release does not.) Wait up to 12 min for release.yml — which
# runs the full test suite first — to create the release; skip
# gracefully if it never appears (the AppImage stays a build artifact).
- name: Wait for release (release.yml owns creation)
if: github.event_name == 'push'
id: wait
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tag="${{ github.ref_name }}"
for _ in $(seq 1 48); do
if gh release view "$tag" --repo "${{ github.repository }}" >/dev/null 2>&1; then
echo "Release $tag exists — attaching AppImage."
echo "exists=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Release $tag not created yet (release.yml owns creation) — waiting…"
sleep 15
done
echo "::warning::Release $tag never appeared (REL-$tag marker tag not pushed?). Skipping AppImage upload; it remains as a build artifact."
echo "exists=false" >> "$GITHUB_OUTPUT"
- name: Attach AppImage to GitHub Release (tag push only)
if: github.event_name == 'push' && steps.wait.outputs.exists == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload "${{ github.ref_name }}" \
"${{ steps.locate.outputs.appimage_path }}" --clobber \
--repo "${{ github.repository }}"
+118
View File
@@ -0,0 +1,118 @@
name: AUR Publish
on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
inputs:
tag:
description: "Release tag (e.g. v0.1.0)"
required: true
permissions:
contents: read
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
# Secret-gated so tag pushes made before AUR_SSH_PRIVATE_KEY is set up
# (see packaging/aur/README.md for the one-time onboarding flow) do not
# red-X the Release. Once the secret is configured, subsequent tag
# pushes publish to AUR automatically with no other changes.
- name: Check AUR secret presence
id: check
env:
AUR_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
run: |
if [ -z "$AUR_KEY" ]; then
echo "::notice::AUR_SSH_PRIVATE_KEY is not set; skipping AUR publish. See packaging/aur/README.md for one-time setup."
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Resolve tag
if: steps.check.outputs.skip == 'false'
id: tag
run: |
if [ "${{ github.event_name }}" = "push" ]; then
tag="${{ github.ref_name }}"
else
tag="${{ inputs.tag }}"
fi
# AUR pkgver disallows '-' (reserved for the pkgrel delimiter),
# so strip the RTM gate suffix before stamping PKGBUILD. Tag
# name itself is preserved for the source URL.
version="${tag#v}"
version="${version%%-RTM*}"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Render PKGBUILD (stamp version + sha256)
if: steps.check.outputs.skip == 'false'
id: render
run: |
set -e
version="${{ steps.tag.outputs.version }}"
tag="${{ steps.tag.outputs.tag }}"
url="https://github.com/${{ github.repository }}/archive/${tag}.tar.gz"
# GitHub generates the archive tarball on first request; give it
# a few attempts in case the tag-push → archive materialization
# has a small race window.
for i in 1 2 3 4 5; do
sha=$(curl -sSL "$url" | sha256sum | awk '{print $1}')
# sha256 of empty string → don't accept it.
if [ "$sha" != "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" ] \
&& [ -n "$sha" ]; then
break
fi
echo "archive not ready yet, retrying ($i/5)..."
sleep 5
done
if [ -z "$sha" ]; then
echo "failed to compute sha256 for $url" >&2
exit 1
fi
echo "sha256: $sha"
# Anchor on the assignment lines so the template comment at the
# top (which mentions __PKGVER__ / __SHA256__ literally) is not
# also rewritten — otherwise the published PKGBUILD ships with
# the stamped values inlined into the explanatory text.
sed -e "s|^pkgver=__PKGVER__$|pkgver=${version}|" \
-e "s|^sha256sums=('__SHA256__')$|sha256sums=('${sha}')|" \
packaging/aur/PKGBUILD > PKGBUILD.rendered
mv PKGBUILD.rendered packaging/aur/PKGBUILD
echo "---- rendered PKGBUILD ----"
cat packaging/aur/PKGBUILD
# KSXGitHub/github-actions-deploy-aur handles: makepkg-style SRCINFO
# regeneration, SSH key material setup, ssh-keyscan of aur.archlinux.org,
# and the git clone/commit/push to ssh://aur@aur.archlinux.org/<pkg>.git.
# Pinned to v4.1.3 (specific patch) — v2 floating tag has an upstream
# regression that fails with `bash: --command: invalid option`
# (upstream issue #50, fixed 2026-04-18). v3+ uses the same input
# shape, so the pin is safe.
- name: Publish to AUR
if: steps.check.outputs.skip == 'false'
uses: KSXGitHub/github-actions-deploy-aur@v4.1.3
with:
pkgname: winpodx
pkgbuild: packaging/aur/PKGBUILD
# #255 PR 4: ship the install scriptlet alongside PKGBUILD so
# pacman's install= directive resolves at install time. Without
# this, the post_install / post_remove banners + cleanup hook
# don't fire (the PKGBUILD references winpodx.install but the
# AUR repo wouldn't have the file).
assets: |
packaging/aur/winpodx.install
commit_username: kernalix7
commit_email: kernalix7@kodenet.io
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
commit_message: "Update to ${{ steps.tag.outputs.version }}"
ssh_keyscan_types: rsa,ecdsa,ed25519
+280
View File
@@ -0,0 +1,280 @@
name: Check Windows Build Updates
on:
schedule:
- cron: "0 9 * * 3" # Every Wednesday 09:00 UTC
workflow_dispatch:
permissions:
contents: read
issues: write
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Check latest dockur Windows images
id: check
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# x86_64 Windows image (dockur/windows)
DOCKUR_LATEST=$(gh api repos/dockur/windows/releases/latest --jq '.tag_name' 2>/dev/null || echo "unknown")
echo "dockur_latest=$DOCKUR_LATEST" >> "$GITHUB_OUTPUT"
# ARM Windows image (dockur/windows-arm) — pulled in by aarch64
# hosts via core/config.py:_default_pod_image. See #141.
DOCKUR_ARM_LATEST=$(gh api repos/dockur/windows-arm/releases/latest --jq '.tag_name' 2>/dev/null || echo "unknown")
echo "dockur_arm_latest=$DOCKUR_ARM_LATEST" >> "$GITHUB_OUTPUT"
VERSION_FILE="config/oem/VERSIONS.txt"
DOCKUR_CURRENT=""
DOCKUR_ARM_CURRENT=""
if [ -f "$VERSION_FILE" ]; then
DOCKUR_CURRENT=$(grep "^dockur=" "$VERSION_FILE" | cut -d= -f2 || echo "")
DOCKUR_ARM_CURRENT=$(grep "^dockur-arm=" "$VERSION_FILE" | cut -d= -f2 || echo "")
fi
NEEDS_UPDATE_X86=false
if [ "$DOCKUR_LATEST" != "$DOCKUR_CURRENT" ] && [ "$DOCKUR_LATEST" != "unknown" ]; then
NEEDS_UPDATE_X86=true
echo "dockur/windows: ${DOCKUR_CURRENT:-none} -> $DOCKUR_LATEST"
fi
NEEDS_UPDATE_ARM=false
if [ "$DOCKUR_ARM_LATEST" != "$DOCKUR_ARM_CURRENT" ] && [ "$DOCKUR_ARM_LATEST" != "unknown" ]; then
NEEDS_UPDATE_ARM=true
echo "dockur/windows-arm: ${DOCKUR_ARM_CURRENT:-none} -> $DOCKUR_ARM_LATEST"
fi
echo "needs_update_x86=$NEEDS_UPDATE_X86" >> "$GITHUB_OUTPUT"
echo "needs_update_arm=$NEEDS_UPDATE_ARM" >> "$GITHUB_OUTPUT"
echo "dockur_current=$DOCKUR_CURRENT" >> "$GITHUB_OUTPUT"
echo "dockur_arm_current=$DOCKUR_ARM_CURRENT" >> "$GITHUB_OUTPUT"
- name: Check :latest digest drift on Docker Hub
id: digest_check
run: |
# Anonymous Docker Hub Registry API. We fetch the manifest
# headers for ``:latest`` and read ``Docker-Content-Digest``
# to detect silent rebuilds (digest changed but the GitHub
# release tag did not). dockur sometimes ships security
# patches as same-tag rebuilds, which the release-tag check
# alone misses.
fetch_digest() {
local repo="$1"
local out_dir
out_dir="$(mktemp -d)"
curl -fsSL -o "$out_dir/token.json" \
"https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repo}:pull"
local token
token=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['token'])" "$out_dir/token.json")
curl -fsSL -D "$out_dir/headers.txt" -o /dev/null \
-H "Authorization: Bearer ${token}" \
-H "Accept: application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json" \
"https://registry-1.docker.io/v2/${repo}/manifests/latest"
grep -i '^docker-content-digest:' "$out_dir/headers.txt" \
| awk '{print $2}' | tr -d '\r\n'
}
DIGEST_X86_LATEST=$(fetch_digest "dockurr/windows" || echo "")
DIGEST_ARM_LATEST=$(fetch_digest "dockurr/windows-arm" || echo "")
VERSION_FILE="config/oem/VERSIONS.txt"
DIGEST_X86_CURRENT=""
DIGEST_ARM_CURRENT=""
if [ -f "$VERSION_FILE" ]; then
DIGEST_X86_CURRENT=$(grep "^dockur-digest=" "$VERSION_FILE" | cut -d= -f2- || echo "")
DIGEST_ARM_CURRENT=$(grep "^dockur-arm-digest=" "$VERSION_FILE" | cut -d= -f2- || echo "")
fi
DIGEST_DRIFT_X86=false
if [ -n "$DIGEST_X86_LATEST" ] && [ "$DIGEST_X86_LATEST" != "$DIGEST_X86_CURRENT" ]; then
DIGEST_DRIFT_X86=true
echo "dockur/windows:latest digest drift: ${DIGEST_X86_CURRENT:-none} -> $DIGEST_X86_LATEST"
fi
DIGEST_DRIFT_ARM=false
if [ -n "$DIGEST_ARM_LATEST" ] && [ "$DIGEST_ARM_LATEST" != "$DIGEST_ARM_CURRENT" ]; then
DIGEST_DRIFT_ARM=true
echo "dockur/windows-arm:latest digest drift: ${DIGEST_ARM_CURRENT:-none} -> $DIGEST_ARM_LATEST"
fi
echo "digest_drift_x86=$DIGEST_DRIFT_X86" >> "$GITHUB_OUTPUT"
echo "digest_drift_arm=$DIGEST_DRIFT_ARM" >> "$GITHUB_OUTPUT"
echo "digest_x86_latest=$DIGEST_X86_LATEST" >> "$GITHUB_OUTPUT"
echo "digest_arm_latest=$DIGEST_ARM_LATEST" >> "$GITHUB_OUTPUT"
echo "digest_x86_current=$DIGEST_X86_CURRENT" >> "$GITHUB_OUTPUT"
echo "digest_arm_current=$DIGEST_ARM_CURRENT" >> "$GITHUB_OUTPUT"
- name: Open x86_64 tracking issue
if: steps.check.outputs.needs_update_x86 == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DOCKUR_LATEST: ${{ steps.check.outputs.dockur_latest }}
DOCKUR_CURRENT: ${{ steps.check.outputs.dockur_current }}
DIGEST_X86_LATEST: ${{ steps.digest_check.outputs.digest_x86_latest }}
run: |
TITLE="chore: dockur/windows update available ($DOCKUR_LATEST)"
EXISTING=$(gh issue list \
--search "$TITLE in:title is:open" \
--json number --jq '.[0].number' 2>/dev/null || echo "")
if [ -n "$EXISTING" ]; then
echo "Issue #$EXISTING already tracks this update, skipping"
exit 0
fi
gh issue create \
--title "$TITLE" \
--body "$(cat <<EOF
## Windows Build Update (x86_64)
**dockur/windows**: ${DOCKUR_CURRENT:-unknown} → $DOCKUR_LATEST
**Current :latest digest**: \`${DIGEST_X86_LATEST:-unknown}\`
### Action items
1. Update \`config/oem/VERSIONS.txt\`:
- \`dockur=$DOCKUR_LATEST\`
- \`dockur-digest=${DIGEST_X86_LATEST:-<paste new digest>}\`
2. Refresh \`DOCKUR_IMAGE_PIN\` in \`src/winpodx/core/config.py\` if rolling forward:
\`\`\`
podman pull docker.io/dockurr/windows:latest
podman image inspect docker.io/dockurr/windows:latest -f '{{json .RepoDigests}}'
\`\`\`
3. Recreate container to pick up the new Windows build
4. Check if \`TargetReleaseVersionInfo\` in \`config/oem/install.bat\` needs updating
Release notes: https://github.com/dockur/windows/releases/tag/$DOCKUR_LATEST
EOF
)"
- name: Open aarch64 tracking issue
if: steps.check.outputs.needs_update_arm == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DOCKUR_ARM_LATEST: ${{ steps.check.outputs.dockur_arm_latest }}
DOCKUR_ARM_CURRENT: ${{ steps.check.outputs.dockur_arm_current }}
DIGEST_ARM_LATEST: ${{ steps.digest_check.outputs.digest_arm_latest }}
run: |
TITLE="chore: dockur/windows-arm update available ($DOCKUR_ARM_LATEST)"
EXISTING=$(gh issue list \
--search "$TITLE in:title is:open" \
--json number --jq '.[0].number' 2>/dev/null || echo "")
if [ -n "$EXISTING" ]; then
echo "Issue #$EXISTING already tracks this update, skipping"
exit 0
fi
gh issue create \
--title "$TITLE" \
--body "$(cat <<EOF
## Windows Build Update (aarch64)
**dockur/windows-arm**: ${DOCKUR_ARM_CURRENT:-unknown} → $DOCKUR_ARM_LATEST
**Current :latest digest**: \`${DIGEST_ARM_LATEST:-unknown}\`
### Action items
1. Update \`config/oem/VERSIONS.txt\`:
- \`dockur-arm=$DOCKUR_ARM_LATEST\`
- \`dockur-arm-digest=${DIGEST_ARM_LATEST:-<paste new digest>}\`
2. Refresh \`DOCKUR_IMAGE_ARM_PIN\` in \`src/winpodx/core/config.py\` if rolling forward:
\`\`\`
podman pull docker.io/dockurr/windows-arm:latest
podman image inspect docker.io/dockurr/windows-arm:latest -f '{{json .RepoDigests}}'
\`\`\`
3. Recreate container on an aarch64 host to pick up the new Windows-on-ARM build
4. Check if \`TargetReleaseVersionInfo\` in \`config/oem/install.bat\` needs updating
Release notes: https://github.com/dockur/windows-arm/releases/tag/$DOCKUR_ARM_LATEST
EOF
)"
- name: Open x86_64 silent-rebuild issue
# Only fire when digest drifted AND no release-tag bump (the
# tag-bump issue above already covers the drift in that case).
if: steps.digest_check.outputs.digest_drift_x86 == 'true' && steps.check.outputs.needs_update_x86 != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DOCKUR_LATEST: ${{ steps.check.outputs.dockur_latest }}
DIGEST_X86_LATEST: ${{ steps.digest_check.outputs.digest_x86_latest }}
DIGEST_X86_CURRENT: ${{ steps.digest_check.outputs.digest_x86_current }}
run: |
TITLE="chore: dockur/windows :latest digest drift (silent rebuild)"
EXISTING=$(gh issue list \
--search "$TITLE in:title is:open" \
--json number --jq '.[0].number' 2>/dev/null || echo "")
if [ -n "$EXISTING" ]; then
echo "Issue #$EXISTING already tracks this drift, skipping"
exit 0
fi
gh issue create \
--title "$TITLE" \
--body "$(cat <<EOF
## Silent rebuild of dockur/windows:latest (x86_64)
The \`:latest\` tag points at a new image digest, but no new
release tag was published on GitHub. This usually means an
upstream security patch was baked into the same release tag.
**Tag**: \`${DOCKUR_LATEST:-unknown}\` (unchanged)
**Previous digest**: \`${DIGEST_X86_CURRENT:-unknown}\`
**New digest**: \`$DIGEST_X86_LATEST\`
### Action items
1. Investigate dockur/windows recent commits for the rebuild trigger:
https://github.com/dockur/windows/commits/master
2. If the rebuild is desirable, update \`config/oem/VERSIONS.txt\`:
- \`dockur-digest=$DIGEST_X86_LATEST\`
3. Refresh \`DOCKUR_IMAGE_PIN\` in \`src/winpodx/core/config.py\` if rolling forward.
4. If the rebuild looks unintentional or breaks something, leave
the pin alone and update only the baseline once stable.
EOF
)"
- name: Open aarch64 silent-rebuild issue
if: steps.digest_check.outputs.digest_drift_arm == 'true' && steps.check.outputs.needs_update_arm != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DOCKUR_ARM_LATEST: ${{ steps.check.outputs.dockur_arm_latest }}
DIGEST_ARM_LATEST: ${{ steps.digest_check.outputs.digest_arm_latest }}
DIGEST_ARM_CURRENT: ${{ steps.digest_check.outputs.digest_arm_current }}
run: |
TITLE="chore: dockur/windows-arm :latest digest drift (silent rebuild)"
EXISTING=$(gh issue list \
--search "$TITLE in:title is:open" \
--json number --jq '.[0].number' 2>/dev/null || echo "")
if [ -n "$EXISTING" ]; then
echo "Issue #$EXISTING already tracks this drift, skipping"
exit 0
fi
gh issue create \
--title "$TITLE" \
--body "$(cat <<EOF
## Silent rebuild of dockur/windows-arm:latest (aarch64)
The \`:latest\` tag points at a new image digest, but no new
release tag was published on GitHub. This usually means an
upstream security patch was baked into the same release tag.
**Tag**: \`${DOCKUR_ARM_LATEST:-unknown}\` (unchanged)
**Previous digest**: \`${DIGEST_ARM_CURRENT:-unknown}\`
**New digest**: \`$DIGEST_ARM_LATEST\`
### Action items
1. Investigate dockur/windows-arm recent commits for the rebuild trigger:
https://github.com/dockur/windows-arm/commits/master
2. If the rebuild is desirable, update \`config/oem/VERSIONS.txt\`:
- \`dockur-arm-digest=$DIGEST_ARM_LATEST\`
3. Refresh \`DOCKUR_IMAGE_ARM_PIN\` in \`src/winpodx/core/config.py\` if rolling forward.
4. If the rebuild looks unintentional or breaks something, leave
the pin alone and update only the baseline once stable.
EOF
)"
+99
View File
@@ -0,0 +1,99 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- run: pip install ruff
- run: ruff check src/ tests/
- run: ruff format --check src/ tests/
- name: Verify version stamps agree across files
# Catches the v0.5.2 release-prep mistake (debian/changelog stayed at
# 0.5.1 while pyproject.toml / __init__.py moved to 0.5.2) before the
# tag push. See scripts/ci/verify_versions.py for details.
run: python scripts/ci/verify_versions.py
test:
runs-on: ${{ matrix.runner }}
strategy:
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
runner: ["ubuntu-latest"]
include:
# aarch64 regression catch — winpodx ships arch-aware code paths
# (image picker in core/config.py, compose ARGUMENTS in
# core/pod/compose.py, install.sh arch detection) for ARM64
# Linux hosts per #141. The x86_64 matrix covers all 5 Python
# versions; we keep aarch64 minimal at the latest Python only
# so the regression check is fast + cheap. GitHub Actions
# provides free aarch64 runners for public repos since 2024.
- python-version: "3.14"
runner: "ubuntu-24.04-arm"
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: pip install -e ".[dev]"
- run: pytest tests/ -v
discover-apps-ps:
# Smoke test for scripts/windows/discover_apps.ps1 — runs the script
# under -DryRun (no Registry/AppX access) and asserts stdout parses as
# a non-empty JSON array with the shape core.discovery expects. Catches
# PowerShell syntax regressions before they hit the Windows guest.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
# Defense in depth: the DryRun path is read-only, but we still
# do not want the job's GITHUB_TOKEN left persisted in
# .git/config where any subsequent step (including the pwsh
# smoke test) could observe it.
persist-credentials: false
- name: Ensure PowerShell Core is available
run: |
if command -v pwsh >/dev/null 2>&1; then
echo "pwsh already installed: $(pwsh --version)"
else
sudo apt-get update
sudo apt-get install -y --no-install-recommends wget apt-transport-https ca-certificates
# Official Microsoft repository for PowerShell on Ubuntu.
source /etc/os-release
wget -q "https://packages.microsoft.com/config/ubuntu/${VERSION_ID}/packages-microsoft-prod.deb" -O /tmp/packages-microsoft-prod.deb
sudo dpkg -i /tmp/packages-microsoft-prod.deb
sudo apt-get update
sudo apt-get install -y powershell
pwsh --version
fi
- name: Run discover_apps.ps1 -DryRun and validate JSON shape
run: |
pwsh -NoProfile -File scripts/windows/discover_apps.ps1 -DryRun > /tmp/discover.json
python3 scripts/ci/validate_discover_dryrun.py /tmp/discover.json
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- run: pip install pip-audit
# `[all]` is libvirt-free as of 0.6.0 (the libvirt backend + its
# libvirt-python extra were dropped — #286), so it no longer needs the
# libvirt-dev native headers and is safe to install on ubuntu-latest.
- run: pip install -e ".[all]" || pip install -e ".[dev]"
- run: pip-audit --desc --skip-editable
continue-on-error: true
+154
View File
@@ -0,0 +1,154 @@
name: Debian/Ubuntu Packages
on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
inputs:
tag:
description: "Release tag (e.g. v0.1.0)"
required: true
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- image: debian:12
suffix: debian12
backports: true
- image: debian:13
suffix: debian13
- image: ubuntu:24.04
suffix: ubuntu2404
- image: ubuntu:25.04
suffix: ubuntu2504
- image: ubuntu:25.10
suffix: ubuntu2510
- image: ubuntu:26.04
suffix: ubuntu2604
container: ${{ matrix.image }}
steps:
- name: Bootstrap container (git + apt)
shell: bash
run: |
set -e
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y --no-install-recommends ca-certificates git
- uses: actions/checkout@v5
- name: Enable backports if needed
if: matrix.backports
shell: bash
run: |
set -e
export DEBIAN_FRONTEND=noninteractive
codename=$(. /etc/os-release && echo "$VERSION_CODENAME")
echo "deb http://deb.debian.org/debian ${codename}-backports main" \
> /etc/apt/sources.list.d/backports.list
apt-get update
- name: Install build deps
shell: bash
run: |
set -e
export DEBIAN_FRONTEND=noninteractive
pkgs="build-essential devscripts debhelper dh-python \
python3-all python3-setuptools python3-wheel \
python3-hatchling python3-pip python3-build"
apt-get install -y --no-install-recommends $pkgs
if [ "${{ matrix.backports }}" = "true" ]; then
codename=$(. /etc/os-release && echo "$VERSION_CODENAME")
apt-get install -y -t ${codename}-backports pybuild-plugin-pyproject
else
apt-get install -y --no-install-recommends pybuild-plugin-pyproject
fi
- name: Build .deb
shell: bash
run: |
set -e
dpkg-buildpackage -us -uc -b
mkdir -p dist
for f in ../winpodx_*.deb; do
base=$(basename "$f" .deb)
cp "$f" "dist/${base}_${{ matrix.suffix }}.deb"
done
ls -la dist/
- uses: actions/upload-artifact@v4
with:
name: deb-${{ matrix.suffix }}
path: dist/*.deb
publish:
needs: build
runs-on: ubuntu-latest
steps:
- name: Resolve tag
id: tag
shell: bash
run: |
if [ "${{ github.event_name }}" = "push" ]; then
echo "tag=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
else
echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
fi
- uses: actions/download-artifact@v4
with:
path: all-debs
pattern: deb-*
merge-multiple: true
- name: List collected debs
run: ls -la all-debs/
# Single-writer rule: release.yml (REL- marker tag) is the SOLE creator
# of the GitHub Release and the only writer of its title/notes. This
# packaging workflow never creates or edits the release — it waits for
# release.yml to create it, then attaches its assets. (The old per-
# workflow `gh release create --notes "Release artifacts…"` stubs both
# raced to produce duplicate releases — kernalix7 hit this on v0.4.1
# push 2026-05-05 — and churned the notes with placeholder text that
# release.yml then overwrote, so the release showed as "edited" many
# times. Waiting instead of creating eliminates both.)
#
# release.yml runs the full test suite before creating the release, so
# it typically appears ~5-6 min after the push; poll up to 12 min. If it
# never appears (e.g. only the v-tag was pushed, no REL- marker), skip
# the upload gracefully — the .debs remain available as build-job
# artifacts — rather than failing the run.
- name: Wait for release (release.yml owns creation)
id: wait
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tag="${{ steps.tag.outputs.tag }}"
for _ in $(seq 1 48); do
if gh release view "$tag" --repo "${{ github.repository }}" >/dev/null 2>&1; then
echo "Release $tag exists — attaching .debs."
echo "exists=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Release $tag not created yet (release.yml owns creation) — waiting…"
sleep 15
done
echo "::warning::Release $tag never appeared (REL-$tag marker tag not pushed?). Skipping .deb upload; .debs remain as build-job artifacts."
echo "exists=false" >> "$GITHUB_OUTPUT"
- name: Upload .deb to release
if: steps.wait.outputs.exists == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload "${{ steps.tag.outputs.tag }}" all-debs/*.deb --clobber \
--repo "${{ github.repository }}"
+264
View File
@@ -0,0 +1,264 @@
name: OBS Publish
# OBS builds RPMs asynchronously on shared openSUSE infrastructure
# (~30-60 min per rebuild). Trying to trigger the rebuild AND download the
# freshly-built RPMs in the same tag-push run was structurally broken: the
# download ran minutes after the trigger, long before OBS had published the
# new version, so every release push failed with "No RPMs were downloaded".
#
# This workflow is split to match OBS's async reality:
# * `trigger` (tag push / dispatch) — fire the OBS rebuild, then exit
# green. It never tries to download, so a release push can't go red here.
# * `attach` (hourly schedule / dispatch) — once OBS has published the
# RPMs for the latest release's version, download them and attach them to
# the existing GitHub Release. It is a no-op (green) when the RPMs aren't
# built yet (retries next hour) or are already attached (no churn).
#
# Single-writer rule: this workflow NEVER creates the GitHub Release or edits
# its title/notes — release.yml (REL- marker tag) is the sole creator and the
# only writer of the release body. `attach` only uploads assets to a release
# that already exists, so it can't cause the "edited N times" / placeholder-
# notes churn the old per-workflow `gh release create --notes …` stubs did.
on:
push:
tags:
- "v*.*.*"
schedule:
# Hourly sweep: attach OBS RPMs to the latest release once OBS finishes
# building them. A no-op the rest of the time (already attached / not
# built yet), so it never edits a release it has nothing new for.
- cron: "17 * * * *"
workflow_dispatch:
inputs:
tag:
description: "Release tag to trigger / attach RPMs to (e.g. v0.6.0)"
required: true
force:
description: "Force OBS runservice even if Tumbleweed RPM exists (use when source bumped without version change)"
required: false
default: "false"
env:
OBS_PROJECT: home:Kernalix7
OBS_PACKAGE: winpodx
OBS_API: https://api.opensuse.org
# /public/ is OBS's anonymous read-only API mirror — no auth needed for
# public home: projects. The runservice token can't read /build/.
OBS_PUBLIC_API: https://api.opensuse.org/public
OBS_DOWNLOAD: https://download.opensuse.org/repositories/home:/Kernalix7
permissions:
contents: write
jobs:
# Fire the OBS rebuild and exit. Runs on tag push and manual dispatch only.
# Never downloads — so a release push is always green here; the RPMs land
# asynchronously via the `attach` job once OBS finishes the server-side build.
trigger:
if: github.event_name != 'schedule'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Resolve tag
id: tag
run: |
if [ "${{ github.event_name }}" = "push" ]; then
tag="${{ github.ref_name }}"
else
tag="${{ inputs.tag }}"
fi
# OBS feeds the version into both deb and rpm specs; rpm forbids
# '-' in Version: (Version/Release delimiter). Strip the RTM
# gate suffix so `0.3.0-RTM1` lands as `0.3.0`.
version="${tag#v}"
version="${version%%-RTM*}"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Install curl + jq
run: sudo apt-get update && sudo apt-get install -y curl jq
- name: Probe whether OBS already has target version built
# Avoid triggering an unnecessary OBS rebuild when the target version
# is already published. A single rebuild costs ~30-60min on OBS shared
# infrastructure and is pure waste when the RPMs we'd download are
# byte-identical to what's already on the server. Probe a representative
# shipping repo (Tumbleweed) for a winpodx-<version>-*.rpm filename in
# the public download index; if found, OBS has the build cached.
id: probe
run: |
version="${{ steps.tag.outputs.version }}"
if [ "${{ inputs.force }}" = "true" ]; then
echo "force=true requested — bypassing Tumbleweed RPM probe and triggering runservice."
echo "skip_runservice=false" >> "$GITHUB_OUTPUT"
exit 0
fi
listing_url="${OBS_DOWNLOAD}/openSUSE_Tumbleweed/noarch/"
html=$(curl -sS -f -L "$listing_url" 2>/dev/null || echo "")
if echo "$html" | grep -qE "winpodx-${version}-[0-9]+\.[0-9]+\.noarch\.rpm"; then
echo "OBS already has openSUSE_Tumbleweed RPM for v${version} — skipping runservice."
echo "skip_runservice=true" >> "$GITHUB_OUTPUT"
else
echo "OBS doesn't yet have openSUSE_Tumbleweed RPM for v${version} — will trigger runservice."
echo "skip_runservice=false" >> "$GITHUB_OUTPUT"
fi
- name: Trigger OBS service run (token)
if: steps.probe.outputs.skip_runservice == 'false'
env:
OBS_TOKEN: ${{ secrets.OBS_TOKEN }}
run: |
if [ -z "$OBS_TOKEN" ]; then
echo "OBS_TOKEN secret is not set" >&2
exit 1
fi
curl -sS -f -X POST \
-H "Authorization: Token $OBS_TOKEN" \
"$OBS_API/trigger/runservice?project=$OBS_PROJECT&package=$OBS_PACKAGE"
echo "OBS rebuild triggered for v${{ steps.tag.outputs.version }}. RPMs will be attached by the hourly 'attach' job once OBS finishes (~30-60 min)."
# Download OBS-built RPMs once they're published and attach them to the
# existing GitHub Release. Runs hourly (sweeps the latest release) and on
# manual dispatch (targets inputs.tag). Never creates or edits the release.
attach:
if: github.event_name != 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install curl + jq
run: sudo apt-get update && sudo apt-get install -y curl jq
- name: Resolve target tag
id: tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
tag="${{ inputs.tag }}"
else
# schedule: sweep the most recent published release.
tag=$(gh release list --repo "${{ github.repository }}" --limit 1 \
--json tagName --jq '.[0].tagName' 2>/dev/null || echo "")
fi
if [ -z "$tag" ]; then
echo "No target tag (no releases yet?) — nothing to attach."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
version="${tag#v}"
version="${version%%-RTM*}"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "skip=false" >> "$GITHUB_OUTPUT"
- name: Skip if release already has OBS RPMs attached
id: gate
if: steps.tag.outputs.skip != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tag="${{ steps.tag.outputs.tag }}"
# release.yml owns creation — only attach to a release that exists.
if ! gh release view "$tag" --repo "${{ github.repository }}" >/dev/null 2>&1; then
echo "Release $tag does not exist yet (release.yml owns creation) — nothing to attach."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Tumbleweed is always built and is the canary for "OBS RPMs already
# attached". If its asset is present, the sweep already ran for this
# release — exit green without re-uploading (no edit churn).
assets=$(gh release view "$tag" --repo "${{ github.repository }}" \
--json assets --jq '.assets[].name' 2>/dev/null || echo "")
if echo "$assets" | grep -q "openSUSE_Tumbleweed\.rpm$"; then
echo "OBS RPMs already attached to $tag — no-op."
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Discover repos and download RPMs
id: download
if: steps.gate.outputs.skip == 'false'
run: |
mkdir -p dist-rpms
version="${{ steps.tag.outputs.version }}"
# Parse enabled repos from project _meta (public, no auth)
meta=$(curl -sS -f "$OBS_PUBLIC_API/source/$OBS_PROJECT/_meta")
repos=$(echo "$meta" | grep -oP '(?<=<repository name=")[^"]+')
if [ -z "$repos" ]; then
echo "No repositories configured on $OBS_PROJECT" >&2
exit 1
fi
for repo in $repos; do
# openSUSE_Factory_{ARM,PowerPC,RISCV,zSystems} builds the same
# noarch content as openSUSE_Tumbleweed — publishing all five would
# just list four identical RPMs with misleading architecture-looking
# names. Skip the non-x86 Factory rebuilds; Tumbleweed users on
# aarch64/ppc64/riscv64/s390x install the Tumbleweed noarch RPM.
case "$repo" in
openSUSE_Factory_*) echo "Skipping $repo (noarch duplicate of Tumbleweed)"; continue ;;
esac
# Tumbleweed / Fedora / Slowroll all produce the same filename
# (winpodx-<ver>-<rel>.noarch.rpm) because their release tags lack
# a distro suffix. Without renaming, only the last-iterated repo's
# file survives in dist-rpms/. Append the repo name to every RPM so
# each distro's build gets a stable, unique asset name on the
# Release — matches the RHEL convention (.el9.rpm / .el10.rpm).
# Normalize short Leap repo names ("15.6", "16.0") to the familiar
# openSUSE_Leap_* form so the final filename is self-describing
# instead of ending in a bare version number like ".15.6.rpm".
case "$repo" in
1[0-9].[0-9]|1[0-9].[0-9][0-9]) repo_slug="openSUSE_Leap_$repo" ;;
*) repo_slug=$(echo "$repo" | tr '/:' '__') ;;
esac
for arch in x86_64 noarch; do
base="$OBS_DOWNLOAD/$repo/$arch"
# MirrorCache listing hrefs have a "./" prefix
# (href="./winpodx-<ver>-<rel>.noarch.rpm"); match the filename
# directly instead of anchoring on `href="winpodx-`.
listing=$(curl -sSL "$base/" || true)
[ -z "$listing" ] && continue
# Anchor the filename match to the current tag's version so a
# stale older RPM still in the OBS publish index (Tumbleweed /
# Slowroll rolling-release repos are slow to garbage-collect)
# doesn't get downloaded and re-uploaded as a v${version}
# release asset.
rpms=$(echo "$listing" | grep -oE "winpodx-${version}-[^\"]+\.rpm" | sort -u)
for f in $rpms; do
case "$f" in
*debuginfo*|*debugsource*|*.src.rpm) continue ;;
esac
out="dist-rpms/${f%.rpm}.${repo_slug}.rpm"
echo "Fetching $repo/$arch/$f -> $(basename "$out")"
curl -sS -fL -o "$out" "$base/$f" || true
done
done
done
ls -la dist-rpms/
if ! ls dist-rpms/*.rpm >/dev/null 2>&1; then
echo "::notice::OBS hasn't published RPMs for v${version} yet — they build asynchronously (~30-60 min). The hourly sweep will retry; or re-run this workflow once the build finishes."
echo "have_rpms=false" >> "$GITHUB_OUTPUT"
else
echo "have_rpms=true" >> "$GITHUB_OUTPUT"
fi
- name: Attach RPMs to GitHub release
if: steps.download.outputs.have_rpms == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload "${{ steps.tag.outputs.tag }}" dist-rpms/*.rpm --clobber \
--repo "${{ github.repository }}"
echo "Attached OBS RPMs to ${{ steps.tag.outputs.tag }}."
- uses: actions/upload-artifact@v4
if: steps.download.outputs.have_rpms == 'true'
with:
name: rpms-${{ steps.tag.outputs.version }}
path: dist-rpms/
+40
View File
@@ -0,0 +1,40 @@
# Deploy the winpodx.org landing page (web/) to GitHub Pages.
#
# Requires repo Settings -> Pages -> Source = "GitHub Actions" (one-time, done
# in the UI) and the custom domain winpodx.org configured there. The CNAME
# file in web/ is published with the site so the custom domain sticks.
name: Deploy site
on:
push:
branches: [main]
paths:
- "web/**"
- ".github/workflows/pages.yml"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
# Never run two deploys at once; let an in-flight one finish (don't cancel,
# so a partial publish can't clobber a good one).
concurrency:
group: pages
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v4
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: web
- id: deployment
uses: actions/deploy-pages@v4
+195
View File
@@ -0,0 +1,195 @@
name: Release
on:
push:
# Only REL-prefixed marker tags create a public release. Version
# tags themselves (v0.4.0, v0.4.0rc1) trigger the four packaging
# workflows (.deb / RPM / OBS / AUR) via their `v*.*.*` globs but
# NOT this Release workflow — that's deliberate. kernalix7 found
# that rapid-iteration tags between v0.1.9 and v0.2.2.1 shipped
# unverified releases that broke users' working installs
# (2026-04-29). Releasing now requires pushing a SECOND tag pointed
# at the same commit as the version tag, namespaced with REL- so
# the version scheme itself stays clean (PEP 440 / SemVer):
#
# git tag v0.4.0 # version, triggers packaging
# git tag REL-v0.4.0 # marker, triggers this workflow
# git push origin v0.4.0 REL-v0.4.0
#
# The previous `*RTM*` glob was retired 2026-05-05 alongside the
# PEP 440 versioning move (current versions are `0.4.0` /
# `0.4.0rc1`, no RTM suffix anywhere). Manual workflow_dispatch
# still works for emergency one-offs.
tags:
- "REL-*"
workflow_dispatch:
permissions:
contents: write
# id-token: write # reserved for future PyPI trusted publisher
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- run: pip install ruff
- name: Run ruff check
run: ruff check src/ tests/
- name: Run ruff format check
run: ruff format --check src/ tests/
- name: Install project
run: pip install -e ".[dev]"
- name: Run tests
run: pytest tests/ -v
# Skip tag/pyproject version parity check on manual dispatch
- name: Verify tag matches pyproject version
if: github.event_name == 'push'
run: |
# Tag layout: REL-v<pep440-version>. Strip the REL- marker
# prefix and the leading 'v' to get pyproject.toml's version
# field (e.g., REL-v0.4.0rc1 -> 0.4.0rc1, REL-v0.4.0 -> 0.4.0).
tag="${GITHUB_REF_NAME#REL-}"
tag="${tag#v}"
pyver=$(python -c "import tomllib,pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])")
if [ "$tag" != "$pyver" ]; then
echo "Tag version ($tag) does not match pyproject.toml version ($pyver)" >&2
exit 1
fi
build:
needs: validate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- run: python -m pip install build twine
- name: Build distribution
run: python -m build
- name: Check distribution metadata
run: python -m twine check dist/*
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
release:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Extract version from REL tag
id: version
run: |
# REL-v0.4.0 -> 0.4.0; REL-v0.4.0rc1 -> 0.4.0rc1. Used by all
# subsequent steps so the public Release page reads
# "v0.4.0" / "0.4.0" instead of the internal "REL-v0.4.0"
# marker tag name.
tag="${GITHUB_REF_NAME#REL-}"
tag="${tag#v}"
echo "value=$tag" >> "$GITHUB_OUTPUT"
- name: Extract changelog section
id: changelog
run: |
# REL-v0.4.0 -> 0.4.0; REL-v0.4.0rc1 -> 0.4.0rc1.
tag="${GITHUB_REF_NAME#REL-}"
tag="${tag#v}"
# Determine the previous published version so we can include
# any intermediate unreleased sections in the notes (sections
# the user wrote in CHANGELOG but never tagged & released).
# On 2026-05-05, v0.3.1 was such an unreleased section: it
# collected Mic92's PRs #62-#65 but no v0.3.1 tag was ever
# pushed; v0.4.0rc1's release page would have lost those
# contributor credits if it only matched ## [0.4.0].
# Strategy: find the most recent CHANGELOG section that
# corresponds to a published GitHub Release (i.e., has its own
# tag). Capture from the current section back to (but not
# including) that already-published one.
prev=""
while IFS= read -r section; do
sver="${section#\#\# \[}"
sver="${sver%%\] *}"
# Skip the in-flight version itself and the unreleased header.
[ "$sver" = "$tag" ] && continue
[ "$sver" = "Unreleased" ] && continue
# First section we hit that has a corresponding tag is the
# previous published release. CHANGELOG is in newest-first
# order so this is correct on the first match.
if git rev-parse --verify "v${sver}^{commit}" >/dev/null 2>&1; then
prev="$sver"
break
fi
done < <(grep -E '^## \[' CHANGELOG.md)
notes=$(awk -v ver="$tag" -v prev="$prev" '
$0 ~ "^## \\[" ver "\\]" { flag=1 }
prev != "" && $0 ~ "^## \\[" prev "\\]" { exit }
prev == "" && flag && /^## \[/ && $0 !~ "^## \\[" ver "\\]" { exit }
flag { print }
' CHANGELOG.md)
# PEP 440 prereleases (0.4.0rc1) usually share notes with the
# final (0.4.0) section in CHANGELOG. Fall back to the base
# version section if the prerelease-specific one is missing.
if [ -z "$(echo "$notes" | tr -d '[:space:]')" ]; then
base=$(echo "$tag" | sed -E 's/(rc|alpha|beta)[0-9]*$//')
if [ "$base" != "$tag" ]; then
notes=$(awk -v ver="$base" -v prev="$prev" '
$0 ~ "^## \\[" ver "\\]" { flag=1 }
prev != "" && $0 ~ "^## \\[" prev "\\]" { exit }
prev == "" && flag && /^## \[/ && $0 !~ "^## \\[" ver "\\]" { exit }
flag { print }
' CHANGELOG.md)
fi
fi
if [ -z "$(echo "$notes" | tr -d '[:space:]')" ]; then
notes="See CHANGELOG.md for details."
fi
{
echo "notes<<CHANGELOG_EOF"
echo "$notes"
echo "CHANGELOG_EOF"
} >> "$GITHUB_OUTPUT"
- name: Detect prerelease
id: prerelease
run: |
# Match both PEP 440 ("0.4.0rc1", no separator) and SemVer-
# hyphen ("0.4.0-rc1") prerelease forms after the REL- prefix
# is stripped.
tag="${GITHUB_REF_NAME#REL-}"
tag="${tag#v}"
if echo "$tag" | grep -qE -- '(rc|beta|alpha)[0-9]*'; then
echo "value=true" >> "$GITHUB_OUTPUT"
else
echo "value=false" >> "$GITHUB_OUTPUT"
fi
- name: Create release
uses: softprops/action-gh-release@v2
with:
# tag_name + name override the default github.ref_name
# (REL-v0.4.0 marker) with the clean version tag (v0.4.0) so
# the public release surface is namespace-free. The marker
# tag still exists in git as the trigger record.
tag_name: v${{ steps.version.outputs.value }}
name: v${{ steps.version.outputs.value }}
prerelease: ${{ steps.prerelease.outputs.value }}
# The hand-written CHANGELOG section is the entire release body —
# including its "### Contributors" subsection, which credits the
# issue reporters and external PR authors for the release (the repo's
# house convention; see CONTRIBUTING.md). generate_release_notes is
# deliberately NOT enabled: on this maintainer-authored repo it dumps
# a ~120-line "What's Changed" list that credits the owner on nearly
# every PR and buries the real contributor credits behind it. This
# step is the SOLE writer of the release body: the packaging
# workflows (.deb / RPM / OBS / AppImage) only attach assets, never
# touch the notes, so the release isn't edited/rewritten repeatedly.
body: ${{ steps.changelog.outputs.notes }}
files: |
dist/*.tar.gz
dist/*.whl
+206
View File
@@ -0,0 +1,206 @@
name: RHEL/AlmaLinux Packages
on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
inputs:
tag:
description: "Release tag (e.g. v0.1.0)"
required: true
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
# AlmaLinux 9 ships python3 == 3.9. The spec pulls in python3-tomli
# on rhel<=9 so the stdlib-tomllib-absent case is covered. EPEL
# provides pyproject-rpm-macros + python3-hatchling + python3-tomli.
- image: almalinux:9
suffix: el9
epel: true
# AlmaLinux 10 ships python3 >= 3.12 so tomllib is stdlib; the spec
# conditional skips the tomli Requires on rhel >= 10. EPEL 10 is
# still enabled because python3-hatchling lives there on el10.
- image: almalinux:10
suffix: el10
epel: true
container: ${{ matrix.image }}
steps:
- name: Bootstrap container (git + rpm tools)
shell: bash
run: |
set -e
dnf -y install dnf-plugins-core git rpm-build rpmdevtools tar
- name: Enable EPEL + CRB
if: matrix.epel
shell: bash
run: |
set -e
# CRB (CodeReady Builder) holds the -devel and python- build deps
# that are not in BaseOS/AppStream (needed for python3-pluggy
# providers on el9 and some python3-* build deps on el10).
dnf -y install epel-release
dnf config-manager --set-enabled crb || \
dnf config-manager --set-enabled powertools || true
- uses: actions/checkout@v5
- name: Resolve tag
id: tag
shell: bash
run: |
if [ "${{ github.event_name }}" = "push" ]; then
tag="${{ github.ref_name }}"
else
tag="${{ inputs.tag }}"
fi
# Strip the RTM gate suffix when computing the package version —
# RPM's Version: field forbids '-' (it's the Version/Release
# delimiter), so 'v0.3.0-RTM1' would land as the illegal
# 'Version: 0.3.0-RTM1'. The tag itself is preserved for the
# release upload + asset naming; only the on-disk Version is
# stripped to a plain '0.3.0'.
version="${tag#v}"
version="${version%%-RTM*}"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Install RPM build deps
shell: bash
run: |
set -e
dnf -y install \
python3 python3-pip python3-wheel python3-setuptools \
python3-hatchling python3-installer \
pyproject-rpm-macros python-rpm-macros \
python3-pluggy
# el9 default python3 is 3.9 → tomllib is NOT stdlib, so the runtime
# Requires: python3-tomli must be resolvable at install time too.
# Install here just to keep the build environment symmetric.
if [ "${{ matrix.suffix }}" = "el9" ]; then
dnf -y install python3-tomli || true
fi
- name: Prepare rpmbuild tree
shell: bash
run: |
set -e
version="${{ steps.tag.outputs.version }}"
mkdir -p ~/rpmbuild/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}
# git archive produces a clean tarball matching what OBS's tar_scm
# builds from, including the @PARENT_TAG@-shaped prefix the spec's
# %autosetup expects.
git config --global --add safe.directory "$GITHUB_WORKSPACE"
git archive --format=tar.gz \
--prefix="winpodx-${version}/" \
-o "$HOME/rpmbuild/SOURCES/winpodx-${version}.tar.gz" \
HEAD
# Stamp the real version into the spec (OBS does this via its
# set_version service; here we do it locally before rpmbuild).
sed "s|^Version:.*|Version: ${version}|" \
packaging/rpm/winpodx.spec > "$HOME/rpmbuild/SPECS/winpodx.spec"
- name: Build RPM
shell: bash
run: |
set -e
rpmbuild -bb "$HOME/rpmbuild/SPECS/winpodx.spec"
- name: Collect RPMs
shell: bash
run: |
set -e
mkdir -p dist
version="${{ steps.tag.outputs.version }}"
# BuildArch is noarch in the spec, so everything lands in noarch/.
for f in ~/rpmbuild/RPMS/noarch/winpodx-*.rpm; do
[ -e "$f" ] || continue
base=$(basename "$f" .rpm)
# Drop any existing distro tag (rpmbuild puts .el9 / .el10 here
# via %{dist}) and replace with our matrix suffix for clarity.
# The rpm itself is noarch so it would install on either distro;
# the suffix is just a human hint on the release asset list.
case "$base" in
*debuginfo*|*debugsource*) continue ;;
esac
cp "$f" "dist/${base}.${{ matrix.suffix }}.rpm"
done
ls -la dist/
- uses: actions/upload-artifact@v4
with:
name: rpm-${{ matrix.suffix }}
path: dist/*.rpm
publish:
needs: build
runs-on: ubuntu-latest
steps:
- name: Resolve tag
id: tag
shell: bash
run: |
if [ "${{ github.event_name }}" = "push" ]; then
echo "tag=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
else
echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
fi
- uses: actions/download-artifact@v4
with:
path: all-rpms
pattern: rpm-*
merge-multiple: true
- name: List collected RPMs
run: ls -la all-rpms/
# Single-writer rule: release.yml (REL- marker tag) is the SOLE creator
# of the GitHub Release and the only writer of its title/notes. This
# packaging workflow never creates or edits the release — it waits for
# release.yml to create it, then attaches its assets. (The old per-
# workflow `gh release create --notes "Release artifacts…"` stubs both
# raced to produce duplicate releases — kernalix7 hit this on v0.4.1
# push 2026-05-05 — and churned the notes with placeholder text that
# release.yml then overwrote, so the release showed as "edited" many
# times. Waiting instead of creating eliminates both.)
#
# release.yml runs the full test suite before creating the release, so
# it typically appears ~5-6 min after the push; poll up to 12 min. If it
# never appears (e.g. only the v-tag was pushed, no REL- marker), skip
# the upload gracefully — the RPMs remain available as build-job
# artifacts — rather than failing the run.
- name: Wait for release (release.yml owns creation)
id: wait
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tag="${{ steps.tag.outputs.tag }}"
for _ in $(seq 1 48); do
if gh release view "$tag" --repo "${{ github.repository }}" >/dev/null 2>&1; then
echo "Release $tag exists — attaching RPMs."
echo "exists=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Release $tag not created yet (release.yml owns creation) — waiting…"
sleep 15
done
echo "::warning::Release $tag never appeared (REL-$tag marker tag not pushed?). Skipping RPM upload; RPMs remain as build-job artifacts."
echo "exists=false" >> "$GITHUB_OUTPUT"
- name: Upload RPMs to release
if: steps.wait.outputs.exists == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload "${{ steps.tag.outputs.tag }}" all-rpms/*.rpm --clobber \
--repo "${{ github.repository }}"