feat(flatpak): publish a signed self-hosted OSTree remote on releases (#875)

* feat(flatpak): publish a signed self-hosted OSTree remote on releases (#785)

release.yml gains publish-flatpak-remote: it imports the per-arch
.flatpak bundles into an OSTree repo, signs commits and the summary
with a provisioned GPG key, regenerates static deltas and the
vocahq.flatpakrepo/index page, and pushes to VocaHQ/vocalinux-flatpak
served by GitHub Pages — the Homebrew-tap analogue, so installs
auto-update via flatpak update instead of per-release downloads.

The job is secret-gated like publish-aur (FLATPAK_GPG_PRIVATE_KEY +
FLATPAK_REPO_TOKEN) and skips itself while unconfigured. Prior repo
objects are pulled back in from gh-pages so clients get incremental
deltas. The manifest now declares branch: stable and the flatpakrepo
declares DefaultBranch=stable + RuntimeRepo for the Flathub GNOME
runtime.

Docs: new docs/FLATPAK_REMOTE.md (one-time setup, rotation,
verification); README, docs/INSTALL.md, packaging/flatpak/README.md
and the release notes now lead with the remote and keep release
bundles as the no-auto-update fallback. tests/test_release_integrity.py
pins the job's shape.

Closes #785

* fix(flatpak-remote): publish a .flatpakref and gate the remote to stable tags

- needs build-and-release so the remote cannot lead the GitHub Release
- stable tags only: prereleases must not reach `flatpak update`
- probe gh-pages before the empty-repo fallback: a transient checkout
  failure no longer discards remote history
- publish com.vocalinux.Vocalinux.flatpakref (RuntimeRepo lives there, not
  on .flatpakrepo) plus vocahq.gpg, so clean installs resolve the GNOME
  runtime and key rotation works via remote-modify --gpg-import
- gate the release-notes remote instructions on the publish actually
  running; the bundle path stays primary otherwise
- document rotation via remote-modify --gpg-import / remote-delete
  --force and the explicit Flathub setup step

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
This commit is contained in:
devin-ai-integration[bot]
2026-09-29 22:45:49 -07:00
committed by GitHub
co-authored by Jatin K Malik
parent d29c884782
commit c9ea6c7de7
9 changed files with 606 additions and 33 deletions
+286 -14
View File
@@ -14,6 +14,12 @@ permissions:
env:
# Build against the supported floor (requires-python in pyproject.toml).
PYTHON_VERSION: '3.11'
# The dedicated "tap" repo and the URL Pages serves it at. A custom
# domain (e.g. flatpak.vocalinux.com) overrides both via the vars.
FLATPAK_REMOTE_REPO: ${{ vars.FLATPAK_REMOTE_REPO || 'VocaHQ/vocalinux-flatpak' }}
FLATPAK_REMOTE_URL: ${{ vars.FLATPAK_REMOTE_URL || 'https://vocahq.github.io/vocalinux-flatpak/repo' }}
FLATPAKREPO_URL: ${{ vars.FLATPAKREPO_URL || 'https://vocahq.github.io/vocalinux-flatpak/vocahq.flatpakrepo' }}
FLATPAKREF_URL: ${{ vars.FLATPAKREF_URL || 'https://vocahq.github.io/vocalinux-flatpak/com.vocalinux.Vocalinux.flatpakref' }}
jobs:
build-and-release:
@@ -107,6 +113,88 @@ jobs:
if-no-files-found: error
retention-days: 7
# The remote install commands only belong in the notes when this tag
# will actually publish the remote — the same two gates
# publish-flatpak-remote applies: a stable tag and both secrets
# configured. Otherwise the notes would send users to a remote that
# never carries this release, so they get the bundle path alone.
- name: Compose the Flatpak install notes
id: flatpak_notes
env:
FLATPAK_GPG_PRIVATE_KEY: ${{ secrets.FLATPAK_GPG_PRIVATE_KEY }}
FLATPAK_REPO_TOKEN: ${{ secrets.FLATPAK_REPO_TOKEN }}
VERSION: ${{ steps.get_version.outputs.VERSION }}
run: |
set -euo pipefail
notes="$RUNNER_TEMP/flatpak-notes.md"
case "$GITHUB_REF_NAME" in
*alpha*|*beta*|*rc*) prerelease=true ;;
*) prerelease=false ;;
esac
if [ "$prerelease" = false ] && [ -n "$FLATPAK_GPG_PRIVATE_KEY" ] && [ -n "$FLATPAK_REPO_TOKEN" ]; then
cat >"$notes" <<'EOF'
### Flatpak
Install and auto-update via the self-hosted VocaHQ remote (`flatpak update`
picks up each release):
```bash
flatpak install __FLATPAKREF_URL__
```
The `.flatpakref` adds the `vocahq` remote and resolves `org.gnome.Platform//50`
through Flathub itself — no separate Flathub setup needed. To add the
pieces by hand instead:
```bash
flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
flatpak remote-add --if-not-exists vocahq __FLATPAKREPO_URL__
flatpak install vocahq com.vocalinux.Vocalinux
```
Or download the `.flatpak` matching your CPU (`x86_64` or `aarch64`, attached
shortly after this release is published) — a bundle with **no auto-update**:
```bash
# once: Flathub remote + GNOME runtime (the app itself is not on Flathub)
flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
flatpak install flathub org.gnome.Platform//50
flatpak install --user ./Vocalinux-__VERSION__-x86_64.flatpak
flatpak run com.vocalinux.Vocalinux
```
EOF
else
cat >"$notes" <<'EOF'
### Flatpak
Download the `.flatpak` matching your CPU (`x86_64` or `aarch64`, attached
shortly after this release is published) — a bundle with **no auto-update**:
```bash
# once: Flathub remote + GNOME runtime (the app itself is not on Flathub)
flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
flatpak install flathub org.gnome.Platform//50
flatpak install --user ./Vocalinux-__VERSION__-x86_64.flatpak
flatpak run com.vocalinux.Vocalinux
```
EOF
if [ "$prerelease" = true ]; then
cat >>"$notes" <<'EOF'
Prereleases are not pushed to the `vocahq` auto-update remote — `flatpak update`
there stays on the latest stable release.
EOF
fi
fi
cat >>"$notes" <<'EOF'
Whisper.cpp engine only. Hotkeys need device access as documented in
packaging/flatpak/README.md. Not on Flathub (https://github.com/flathub/flathub/pull/9368).
EOF
sed -i \
-e "s|__VERSION__|$VERSION|g" \
-e "s|__FLATPAKREPO_URL__|$FLATPAKREPO_URL|g" \
-e "s|__FLATPAKREF_URL__|$FLATPAKREF_URL|g" \
"$notes"
{
echo 'flatpak<<VOCA_NOTES_EOF'
cat "$notes"
echo 'VOCA_NOTES_EOF'
} >> "$GITHUB_OUTPUT"
- name: Create Release
id: create_release
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
@@ -161,20 +249,7 @@ jobs:
```
Still requires `xdotool`/`wtype`/`ydotool` on the host for text injection, same as the PyPI path.
### Flatpak
Download the `.flatpak` matching your CPU (`x86_64` or `aarch64`, attached shortly
after this release is published). The app is **not on Flathub**; these are GitHub
Release bundles with **no auto-update**.
```bash
# once: Flathub remote + GNOME runtime (the app itself is not on Flathub)
flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
flatpak install flathub org.gnome.Platform//50
flatpak install --user ./Vocalinux-${{ steps.get_version.outputs.VERSION }}-x86_64.flatpak
flatpak run com.vocalinux.Vocalinux
```
Whisper.cpp engine only. Hotkeys need device access as documented in
packaging/flatpak/README.md. Not on Flathub (https://github.com/flathub/flathub/pull/9368).
${{ steps.flatpak_notes.outputs.flatpak }}
### Snap
Tagged releases also attach `vocalinux_${{ steps.get_version.outputs.VERSION }}_amd64.snap`
@@ -337,6 +412,203 @@ jobs:
if-no-files-found: error
retention-days: 7
# Self-hosted OSTree remote (#785), the Homebrew-tap analogue: the release
# bundles are imported into a signed repo and pushed to
# VocaHQ/vocalinux-flatpak, which GitHub Pages serves at FLATPAK_REMOTE_URL.
# Installs then auto-update via `flatpak update`. One-time setup — repo,
# Pages, signing key, PAT — is documented in docs/FLATPAK_REMOTE.md.
# build-and-release is a hard requirement: the remote is the update channel
# for the GitHub Release, so it must not publish when release creation
# failed. Stable tags only — a prerelease on `stable` would land in every
# user's `flatpak update`.
publish-flatpak-remote:
name: Publish Flatpak remote
needs: [build-and-release, build-flatpak-amd64, build-flatpak-arm64]
if: ${{ !contains(github.ref, 'alpha') && !contains(github.ref, 'beta') && !contains(github.ref, 'rc') }}
runs-on: ubuntu-latest
permissions:
contents: read
concurrency:
# Two tags publishing at once would race each other's gh-pages push.
group: flatpak-remote
steps:
# Same pattern as publish-aur: an unconfigured remote skips the publish
# instead of failing the release.
- name: Check remote credentials
id: creds
env:
FLATPAK_GPG_PRIVATE_KEY: ${{ secrets.FLATPAK_GPG_PRIVATE_KEY }}
FLATPAK_REPO_TOKEN: ${{ secrets.FLATPAK_REPO_TOKEN }}
run: |
if [ -z "$FLATPAK_GPG_PRIVATE_KEY" ] || [ -z "$FLATPAK_REPO_TOKEN" ]; then
echo "configured=false" >> "$GITHUB_OUTPUT"
echo "FLATPAK_GPG_PRIVATE_KEY and/or FLATPAK_REPO_TOKEN not set; skipping remote publish (docs/FLATPAK_REMOTE.md)"
else
echo "configured=true" >> "$GITHUB_OUTPUT"
fi
- name: Checkout
if: steps.creds.outputs.configured == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install repo tooling
if: steps.creds.outputs.configured == 'true'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends flatpak ostree
- name: Download the Flatpak bundles
if: steps.creds.outputs.configured == 'true'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: flatpak-*
merge-multiple: true
path: dist
# The import below falls back to `ostree init` when site/repo is absent,
# which is only safe on a confirmed first publish: a transient checkout
# failure taken as "no history" would publish an empty repo over the
# remote and drop every object and delta clients still need. Probe
# gh-pages directly — exit 2 means confirmed absent; anything else
# aborts instead of guessing.
- name: Check whether the remote was published before
id: published
if: steps.creds.outputs.configured == 'true'
env:
FLATPAK_REPO_TOKEN: ${{ secrets.FLATPAK_REPO_TOKEN }}
run: |
set -u
rc=0
git ls-remote --exit-code --heads \
"https://x-access-token:${FLATPAK_REPO_TOKEN}@github.com/${FLATPAK_REMOTE_REPO}.git" \
gh-pages >/dev/null 2>&1 || rc=$?
case $rc in
0) echo "published=true" >> "$GITHUB_OUTPUT" ;;
2) echo "published=false" >> "$GITHUB_OUTPUT" ;;
*)
echo "::error::Could not check ${FLATPAK_REMOTE_REPO} for gh-pages (git ls-remote exited $rc). Does the repo exist (docs/FLATPAK_REMOTE.md)?"
exit "$rc"
;;
esac
- name: Fetch the previously published repo
if: steps.creds.outputs.configured == 'true' && steps.published.outputs.published == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: ${{ env.FLATPAK_REMOTE_REPO }}
ref: gh-pages
token: ${{ secrets.FLATPAK_REPO_TOKEN }}
path: site
- name: Import the signing key
if: steps.creds.outputs.configured == 'true'
env:
FLATPAK_GPG_PRIVATE_KEY: ${{ secrets.FLATPAK_GPG_PRIVATE_KEY }}
FLATPAK_GPG_PASSPHRASE: ${{ secrets.FLATPAK_GPG_PASSPHRASE }}
run: |
set -euo pipefail
export GNUPGHOME="$RUNNER_TEMP/gnupg"
mkdir -m 700 "$GNUPGHOME"
gpg --batch --import <<<"$FLATPAK_GPG_PRIVATE_KEY"
KEYID=$(gpg --batch --with-colons --list-secret-keys | awk -F: '$1 == "sec" {print $5; exit}')
KEYGRIP=$(gpg --batch --with-colons --with-keygrip --list-secret-keys | awk -F: '$1 == "grp" {print $10; exit}')
if [ -n "${FLATPAK_GPG_PASSPHRASE:-}" ]; then
# libostree signs through gpg-agent, so the passphrase has to be
# preset on the keygrip — there is no --passphrase-fd to reach for.
PRESET=$(command -v gpg-preset-passphrase || find /usr/lib* /usr/libexec -name gpg-preset-passphrase 2>/dev/null | head -1 || true)
if [ -z "$PRESET" ]; then
echo "::error::FLATPAK_GPG_PASSPHRASE is set but gpg-preset-passphrase is unavailable; import a passphrase-less key instead"
exit 1
fi
echo "allow-preset-passphrase" >> "$GNUPGHOME/gpg-agent.conf"
gpgconf --kill gpg-agent || true
gpg-agent --daemon
printf '%s' "$FLATPAK_GPG_PASSPHRASE" | "$PRESET" --preset "$KEYGRIP"
fi
gpg --batch --export "$KEYID" > "$RUNNER_TEMP/pubkey.gpg"
echo "GNUPGHOME=$GNUPGHOME" >> "$GITHUB_ENV"
echo "KEYID=$KEYID" >> "$GITHUB_ENV"
# site/repo carries the prior objects back in, so clients pull deltas
# per release rather than re-downloading the app.
- name: Import bundles and sign the repo
if: steps.creds.outputs.configured == 'true'
run: |
set -euo pipefail
repo="$PWD/publish/repo"
if [ -d site/repo/objects ]; then
mkdir -p publish
cp -a site/repo "$repo"
else
mkdir -p "$repo"
ostree --repo="$repo" init --mode=archive-z2
fi
ls -la dist/*.flatpak
for bundle in dist/*.flatpak; do
flatpak build-import-bundle \
--gpg-sign="$KEYID" --gpg-homedir="$GNUPGHOME" \
--update-appstream --no-update-summary \
"$repo" "$bundle"
done
flatpak build-update-repo \
--gpg-sign="$KEYID" --gpg-homedir="$GNUPGHOME" \
--gpg-import="$RUNNER_TEMP/pubkey.gpg" \
--default-branch=stable \
--generate-static-deltas \
"$repo"
# RuntimeRepo lives on the .flatpakref — it is a "Flatpak Ref" key, not
# a "Flatpak Repo" key, so on the .flatpakrepo it was a silent no-op and
# clean installs without Flathub could not resolve the GNOME runtime.
# Installing the .flatpakref adds the remote and pulls the runtime in
# one command. The bare public key ships for remote-modify key rotation.
- name: Write the .flatpakrepo, .flatpakref, and index page
if: steps.creds.outputs.configured == 'true'
run: |
set -euo pipefail
touch publish/.nojekyll
{
echo '[Flatpak Repo]'
echo 'Title=VocaHQ'
echo "Url=${FLATPAK_REMOTE_URL}"
echo 'Homepage=https://vocalinux.com'
echo 'Comment=Official Vocalinux Flatpak remote'
echo 'Description=Self-hosted Vocalinux Flatpak repository'
echo 'Icon=https://vocalinux.com/icon-512x512.png'
echo 'DefaultBranch=stable'
echo "GPGKey=$(base64 -w0 "$RUNNER_TEMP/pubkey.gpg")"
} > publish/vocahq.flatpakrepo
{
echo '[Flatpak Ref]'
echo 'Version=1'
echo 'Title=Vocalinux'
echo 'Name=com.vocalinux.Vocalinux'
echo 'Branch=stable'
echo "Url=${FLATPAK_REMOTE_URL}"
echo 'SuggestRemoteName=vocahq'
echo 'RuntimeRepo=https://dl.flathub.org/repo/flathub.flatpakrepo'
echo 'IsRuntime=false'
echo 'Homepage=https://vocalinux.com'
echo 'Icon=https://vocalinux.com/icon-512x512.png'
echo "GPGKey=$(base64 -w0 "$RUNNER_TEMP/pubkey.gpg")"
} > publish/com.vocalinux.Vocalinux.flatpakref
cp "$RUNNER_TEMP/pubkey.gpg" publish/vocahq.gpg
grep -v GPGKey publish/vocahq.flatpakrepo
sed -e "s|__FLATPAKREPO_URL__|${FLATPAKREPO_URL}|g" \
-e "s|__FLATPAKREF_URL__|${FLATPAKREF_URL}|g" \
-e "s|__REMOTE_URL__|${FLATPAK_REMOTE_URL}|g" \
packaging/flatpak/index.html > publish/index.html
- name: Publish the remote
if: steps.creds.outputs.configured == 'true'
uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4
with:
personal_token: ${{ secrets.FLATPAK_REPO_TOKEN }}
external_repository: ${{ env.FLATPAK_REMOTE_REPO }}
publish_dir: ./publish
publish_branch: gh-pages
commit_message: 'Vocalinux ${{ github.ref_name }}'
attach-flatpak:
name: Attach Flatpak bundles
needs: [build-and-release, build-flatpak-amd64, build-flatpak-arm64]