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
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:
@@ -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 }}"
|
||||
@@ -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
|
||||
@@ -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
|
||||
)"
|
||||
@@ -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
|
||||
@@ -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 }}"
|
||||
@@ -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/
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }}"
|
||||
Reference in New Issue
Block a user