From c9ea6c7de7e6b7522f534a5b4f2968304fe8d3f0 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:45:49 -0700 Subject: [PATCH] feat(flatpak): publish a signed self-hosted OSTree remote on releases (#875) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .github/workflows/release.yml | 300 +++++++++++++++++- README.md | 18 +- docs/FLATPAK_REMOTE.md | 140 ++++++++ docs/INSTALL.md | 18 +- docs/RELEASE_PROCESS.md | 12 +- packaging/flatpak/README.md | 39 ++- packaging/flatpak/com.vocalinux.Vocalinux.yml | 1 + packaging/flatpak/index.html | 52 +++ tests/test_release_integrity.py | 59 +++- 9 files changed, 606 insertions(+), 33 deletions(-) create mode 100644 docs/FLATPAK_REMOTE.md create mode 100644 packaging/flatpak/index.html diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7f7fea7..09e49db 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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<> "$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] diff --git a/README.md b/README.md index 18b7ebb..473f72f 100644 --- a/README.md +++ b/README.md @@ -166,13 +166,27 @@ Download the `x86_64` or `aarch64` AppImage from [Releases](https://github.com/V ### Flatpak (any distro) -GitHub Releases attach `Vocalinux--x86_64.flatpak` and `Vocalinux--aarch64.flatpak`. After the Flathub GNOME runtime is present: +Install and auto-update via the self-hosted VocaHQ remote (`flatpak update` picks up each release): + +```bash +flatpak install https://vocahq.github.io/vocalinux-flatpak/com.vocalinux.Vocalinux.flatpakref +``` + +The `.flatpakref` adds the `vocahq` remote and resolves the GNOME runtime through Flathub. Manually instead: + +```bash +flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo +flatpak remote-add --if-not-exists vocahq https://vocahq.github.io/vocalinux-flatpak/vocahq.flatpakrepo +flatpak install vocahq com.vocalinux.Vocalinux +``` + +Or sideload a GitHub Release bundle (`Vocalinux--x86_64.flatpak` or `-aarch64.flatpak`) after the Flathub GNOME runtime is present — bundles do not auto-update: ```bash flatpak install --user ./Vocalinux--x86_64.flatpak ``` -Bundles do not auto-update. Local build: +Local build: ```bash flatpak install flathub org.gnome.Platform//50 org.gnome.Sdk//50 diff --git a/docs/FLATPAK_REMOTE.md b/docs/FLATPAK_REMOTE.md new file mode 100644 index 0000000..6ead646 --- /dev/null +++ b/docs/FLATPAK_REMOTE.md @@ -0,0 +1,140 @@ +# Self-hosted Flatpak remote + +Vocalinux publishes a signed OSTree Flatpak remote on every stable `v*` tag — +the Homebrew-tap analogue to `VocaHQ/homebrew-vocamac` +([issue #785](https://github.com/VocaHQ/vocalinux/issues/785)). The remote +lives in the dedicated repo `VocaHQ/vocalinux-flatpak`, served by GitHub +Pages: + +```bash +flatpak install https://vocahq.github.io/vocalinux-flatpak/com.vocalinux.Vocalinux.flatpakref +flatpak update # picks up each new release +``` + +The `.flatpakref` adds the `vocahq` remote (via `SuggestRemoteName=`) and lets +flatpak resolve `org.gnome.Platform//50` through its `RuntimeRepo=` pointer to +Flathub — one command, no separate Flathub setup. The equivalent manual +steps: + +```bash +flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo +flatpak remote-add --if-not-exists vocahq https://vocahq.github.io/vocalinux-flatpak/vocahq.flatpakrepo +flatpak install vocahq com.vocalinux.Vocalinux +``` + +Only the app is ours; the GNOME runtime still comes from Flathub — +`RuntimeRepo=` is a `.flatpakref` key (it is ignored on `.flatpakrepo`, which +is why the app ref carries it, not the remote descriptor). GitHub Release +`.flatpak` bundles ([#784](https://github.com/VocaHQ/vocalinux/issues/784)) +remain as a no-auto-update fallback, and are the only Flatpak path for +prerelease tags, which never reach the remote. + +## Maintainer setup (one time) + +1. **Repo** — create `VocaHQ/vocalinux-flatpak` (public). +2. **Pages** — Settings → Pages → "Deploy from a branch" → branch `gh-pages`, + folder `/ (root)`. The first publish creates the branch, so Pages can be + pointed at it afterwards; creating an empty `gh-pages` upfront works too. +3. **Signing key** — OSTree remotes sign the summary and commits with GPG: + + ```bash + gpg --quick-gen-key "Vocalinux Flatpak " ed25519 sign never + gpg --list-secret-keys # note the KEYID + gpg --export-secret-keys --armor KEYID # -> FLATPAK_GPG_PRIVATE_KEY + gpg --gen-revoke KEYID > flatpak-remote-revoke.asc # store it somewhere safe + ``` + + A passphrase-less key keeps CI simplest — libostree signs through + gpg-agent, which has no `--passphrase-fd` to reach for. A passphrase works + too: set `FLATPAK_GPG_PASSPHRASE` and the workflow presets it onto the + keygrip via `gpg-preset-passphrase`. +4. **Secrets on `VocaHQ/vocalinux`** (Settings → Secrets and variables → + Actions): + - `FLATPAK_GPG_PRIVATE_KEY` — the armored private key + - `FLATPAK_GPG_PASSPHRASE` — optional + - `FLATPAK_REPO_TOKEN` — fine-grained PAT scoped to + `VocaHQ/vocalinux-flatpak` with Contents: read and write +5. **Variables (optional)** — same settings page, Variables tab: + - `FLATPAK_REMOTE_REPO` — publish target (default + `VocaHQ/vocalinux-flatpak`) + - `FLATPAK_REMOTE_URL` — remote URL baked into the `.flatpakrepo` + (default `https://vocahq.github.io/vocalinux-flatpak/repo`; set + e.g. `https://flatpak.vocalinux.com/repo` for a custom domain) + - `FLATPAKREPO_URL` — where users fetch `vocahq.flatpakrepo` (default + `https://vocahq.github.io/vocalinux-flatpak/vocahq.flatpakrepo`) + - `FLATPAKREF_URL` — where users fetch the app `.flatpakref` (default + `https://vocahq.github.io/vocalinux-flatpak/com.vocalinux.Vocalinux.flatpakref`) +6. **Trigger** — push a stable `v*` tag (alpha/beta/rc tags do not publish to + the remote), or re-run `publish-flatpak-remote` on an existing release run + after configuring the secrets. + +Without the secrets the job skips itself (same pattern as `publish-aur`), so +an unconfigured remote never fails a release. + +## How a publish works + +`release.yml → publish-flatpak-remote` runs on stable tags only, after the +GitHub Release exists and the two arch builds finish: + +1. Downloads `Vocalinux--{x86_64,aarch64}.flatpak` from the build + jobs. +2. Probes the remote repo for an existing `gh-pages` branch; only a confirmed + first publish may skip the history checkout — a transient failure aborts + instead of seeding an empty repo over the live remote. It then checks out + `gh-pages` and copies `repo/` over, so existing objects and deltas + survive — clients pull an incremental delta each release, not the whole + app. +3. `flatpak build-import-bundle --gpg-sign` imports each bundle under the + signing key. +4. `flatpak build-update-repo --generate-static-deltas --gpg-sign + --gpg-import` rebuilds the summary, signs it, and generates static deltas. +5. Regenerates `vocahq.flatpakrepo` (URL + base64 public key), + `com.vocalinux.Vocalinux.flatpakref` (the one-command app ref carrying the + `RuntimeRepo=` pointer to Flathub), `vocahq.gpg` (the bare public key, for + the key-rotation path below), and the index page from + `packaging/flatpak/index.html`. +6. Publishes the directory back to `gh-pages` via + `peaceiris/actions-gh-pages`. + +## Verifying a publish + +```bash +flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo +flatpak remote-add --if-not-exists vocahq https://vocahq.github.io/vocalinux-flatpak/vocahq.flatpakrepo +flatpak remote-info vocahq com.vocalinux.Vocalinux # signed commit, both arches +flatpak install vocahq com.vocalinux.Vocalinux +flatpak run com.vocalinux.Vocalinux +# after the next tag: flatpak update pulls the delta +``` + +## Key rotation and loss + +Generate a fresh key, replace `FLATPAK_GPG_PRIVATE_KEY` (and +`FLATPAK_GPG_PASSPHRASE` if used), then re-run `publish-flatpak-remote`. The +published `.flatpakrepo`/`.flatpakref` then carry the new `GPGKey=`, so new +installs trust it immediately. + +Existing installs must import the new key into the remote's trusted keyring — +re-running `remote-add` does **not** replace it (with `--if-not-exists` it +does nothing at all), and `flatpak update` fails verification until the key +is updated. Point users at the public key published beside the repo: + +```bash +curl -fsSL https://vocahq.github.io/vocalinux-flatpak/vocahq.gpg -o /tmp/vocahq.gpg +flatpak remote-modify --gpg-import=/tmp/vocahq.gpg vocahq +flatpak update +``` + +`remote-modify --gpg-import` adds the key — the old one stays trusted, which +is what makes a routine rotation seamless. To *revoke* a leaked key, drop the +remote's keyring instead: delete and re-add the remote (installed apps keep +their `vocahq` origin and update normally afterwards): + +```bash +flatpak remote-delete --force vocahq # --force: the remote is still in use +flatpak remote-add vocahq https://vocahq.github.io/vocalinux-flatpak/vocahq.flatpakrepo +flatpak update +``` + +Either way is worth a line in that release's notes. The revoke certificate is +for the day the key leaks, not for routine rotation. diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 885ec5e..08902d9 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -101,14 +101,26 @@ See [AUR.md](AUR.md). ## Flatpak -GitHub Releases attach `Vocalinux--x86_64.flatpak` and `Vocalinux--aarch64.flatpak`. After the Flathub GNOME runtime is present: +Install and auto-update via the self-hosted VocaHQ remote (`flatpak update` picks up each release): + +```bash +flatpak install https://vocahq.github.io/vocalinux-flatpak/com.vocalinux.Vocalinux.flatpakref +``` + +The `.flatpakref` adds the `vocahq` remote and resolves the GNOME runtime through Flathub. Manually instead: + +```bash +flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo +flatpak remote-add --if-not-exists vocahq https://vocahq.github.io/vocalinux-flatpak/vocahq.flatpakrepo +flatpak install vocahq com.vocalinux.Vocalinux +``` + +Or sideload a GitHub Release bundle (`Vocalinux--x86_64.flatpak` or `-aarch64.flatpak`) after the Flathub GNOME runtime is present — bundles do not auto-update: ```bash flatpak install --user ./Vocalinux--x86_64.flatpak ``` -Bundles do not auto-update. - Local build (contributors): ```bash diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index 8b32a2d..02d8fcc 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -389,11 +389,15 @@ After pushing the tag, the GitHub Actions workflow will automatically: so a partial manifest never gets published) 5. Publish to PyPI via trusted publishing 6. Publish the AUR package (when the `AUR_SSH_PRIVATE_KEY` secret is configured) -7. Deploy the website to vocalinux.com -8. Build the amd64 snap, attach it to the GitHub Release, and try Snap Store +7. Publish the signed self-hosted Flatpak remote to `VocaHQ/vocalinux-flatpak` + (stable tags only, once the GitHub Release exists, when + `FLATPAK_GPG_PRIVATE_KEY` + `FLATPAK_REPO_TOKEN` are configured — see + docs/FLATPAK_REMOTE.md) +8. Deploy the website to vocalinux.com +9. Build the amd64 snap, attach it to the GitHub Release, and try Snap Store `edge`/`candidate`. Store human review (for example `uinput` allow-installation) must not block the GitHub `.snap`. `stable` is still a manual promote -9. Mark as pre-release if version contains alpha/beta/rc +10. Mark as pre-release if version contains alpha/beta/rc Monitor at: https://github.com/VocaHQ/vocalinux/actions @@ -409,6 +413,8 @@ Monitor at: https://github.com/VocaHQ/vocalinux/actions - [ ] Verify PyPI package was published (if applicable), and that its wheel sha256 matches the line for that wheel in `SHA256SUMS` - [ ] Verify website was deployed (check vocalinux.com) +- [ ] If the Flatpak remote is configured, verify it picked up the tag: + `flatpak remote-info vocahq com.vocalinux.Vocalinux` (docs/FLATPAK_REMOTE.md) - [ ] Announce on social media/communities - [ ] Update any pinned issues or discussions diff --git a/packaging/flatpak/README.md b/packaging/flatpak/README.md index e48b54c..ac64f92 100644 --- a/packaging/flatpak/README.md +++ b/packaging/flatpak/README.md @@ -2,10 +2,28 @@ Manifest and AppStream metadata for building Vocalinux as a Flatpak. +## VocaHQ remote (auto-update) + +The recommended install is the self-hosted VocaHQ OSTree remote — the +Homebrew-tap analogue — which `release.yml` signs and publishes to +`VocaHQ/vocalinux-flatpak` (GitHub Pages) on every stable `v*` tag: + +```bash +flatpak install https://vocahq.github.io/vocalinux-flatpak/com.vocalinux.Vocalinux.flatpakref +flatpak update # each new release arrives here +``` + +The `.flatpakref` adds the `vocahq` remote and resolves the GNOME runtime +through Flathub via `RuntimeRepo=` — the manual equivalent is `remote-add` +for `flathub` then `vocahq`, then `flatpak install vocahq +com.vocalinux.Vocalinux`. Only the app is ours; `org.gnome.Platform//50` +still comes from Flathub. One-time repo/key setup: +[docs/FLATPAK_REMOTE.md](../../docs/FLATPAK_REMOTE.md). + ## GitHub Release bundles Each `v*` GitHub Release attaches `Vocalinux--x86_64.flatpak` and -`Vocalinux--aarch64.flatpak`. That is the easy install path: +`Vocalinux--aarch64.flatpak` as a no-remote fallback: ```bash # once: Flathub remote + GNOME runtime (the app itself is not on Flathub) @@ -16,11 +34,11 @@ flatpak install --user ./Vocalinux--x86_64.flatpak flatpak run com.vocalinux.Vocalinux ``` -Bundles do **not** auto-update. Vocalinux is **not on Flathub** (submission +Bundles do **not** auto-update — the VocaHQ remote above is the path for +that. Vocalinux is **not on Flathub** (submission [flathub/flathub#9368](https://github.com/flathub/flathub/pull/9368) closed on -policy grounds; we are not re-submitting). A self-hosted VocaHQ remote is the -long-term auto-update path. Until then, download a new bundle from the next -release. Local `flatpak-builder` remains for contributors (below). +policy grounds; we are not re-submitting). Local `flatpak-builder` remains for +contributors (below). ## Current Scope @@ -109,15 +127,16 @@ symlinking of whisper.cpp's shared libraries onto the loader path. `python3-build-dependencies.yaml` is a small hand-maintained helper so `--no-build-isolation` builds can import `mesonpy` before NumPy is built. -## Channel: GitHub Releases, not Flathub +## Channel: VocaHQ remote + GitHub Releases, not Flathub Flathub is **not pursued**. The submission, [flathub/flathub#9368](https://github.com/flathub/flathub/pull/9368), was closed on 2026-07-23 on policy grounds. See -[#167](https://github.com/VocaHQ/vocalinux/issues/167) for the channel decision -and [#784](https://github.com/VocaHQ/vocalinux/issues/784) for release bundles. -Do not re-submit. Local builds and GitHub Release `.flatpak` assets are the -supported paths. +[#167](https://github.com/VocaHQ/vocalinux/issues/167) for the channel decision, +[#784](https://github.com/VocaHQ/vocalinux/issues/784) for release bundles, and +[#785](https://github.com/VocaHQ/vocalinux/issues/785) for the self-hosted +remote. Do not re-submit. The `vocahq` remote, local builds, and GitHub +Release `.flatpak` assets are the supported paths. ## Manifest Details diff --git a/packaging/flatpak/com.vocalinux.Vocalinux.yml b/packaging/flatpak/com.vocalinux.Vocalinux.yml index c8b20ec..3c34c85 100644 --- a/packaging/flatpak/com.vocalinux.Vocalinux.yml +++ b/packaging/flatpak/com.vocalinux.Vocalinux.yml @@ -1,4 +1,5 @@ app-id: com.vocalinux.Vocalinux +branch: stable runtime: org.gnome.Platform runtime-version: "50" sdk: org.gnome.Sdk diff --git a/packaging/flatpak/index.html b/packaging/flatpak/index.html new file mode 100644 index 0000000..aab5b3d --- /dev/null +++ b/packaging/flatpak/index.html @@ -0,0 +1,52 @@ + + + + + + VocaHQ Flatpak remote + + + +

VocaHQ Flatpak remote

+

+ Self-hosted, signed OSTree repository for + Vocalinux — published on every stable + tagged release, so installs auto-update via flatpak update. +

+
flatpak install __FLATPAKREF_URL__
+

+ The .flatpakref adds the vocahq remote and + resolves the GNOME runtime through Flathub in one step. To wire it up by + hand instead: +

+
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
+

+ The GNOME runtime still comes from Flathub — the + .flatpakref declares RuntimeRepo so flatpak + finds it there. Vocalinux itself is not on Flathub. +

+

+ Source code · + Repository index +

+ + diff --git a/tests/test_release_integrity.py b/tests/test_release_integrity.py index 8351983..11553b3 100644 --- a/tests/test_release_integrity.py +++ b/tests/test_release_integrity.py @@ -234,4 +234,61 @@ def test_the_release_notes_document_flatpak_bundles(): assert "org.gnome.Platform//50" in body assert "no auto-update" in body assert "not on Flathub" in body - assert "Vocalinux-${{ steps.get_version.outputs.VERSION }}-x86_64.flatpak" in body + assert "Vocalinux-__VERSION__-x86_64.flatpak" in body + + +def test_the_remote_install_guidance_only_runs_when_the_remote_publishes(): + """The notes recommend `flatpak install` of the `.flatpakref` only for a + tag the remote will actually carry: stable, with both publish secrets + configured — the same gates publish-flatpak-remote applies. Otherwise the + prominent command would point at a remote that never gets this release.""" + body = _jobs()["build-and-release"] + assert ( + "steps.flatpak_notes.outputs.flatpak" in body + ), "the Flatpak notes must come from the conditional step, not inline text" + for secret in ("FLATPAK_GPG_PRIVATE_KEY", "FLATPAK_REPO_TOKEN"): + assert f"secrets.{secret}" in body, f"the notes do not gate on {secret}" + assert "*alpha*|*beta*|*rc*" in body, "the notes do not detect prerelease tags" + assert "__FLATPAKREF_URL__" in body, "the notes do not publish the .flatpakref path" + # Clean installs without Flathub cannot resolve the GNOME runtime from the + # remote alone: the manual path has to add flathub first. + assert "remote-add --if-not-exists flathub" in body + + +def test_the_flatpak_remote_publishes_signed_and_secret_gated(): + """#785's self-hosted OSTree remote signs with a provisioned GPG key and + pushes to VocaHQ/vocalinux-flatpak, never to the release — and it skips + itself instead of failing the release while secrets are unconfigured. + """ + block = _jobs()["publish-flatpak-remote"] + assert _needs(block) >= { + "build-and-release", + "build-flatpak-amd64", + "build-flatpak-arm64", + }, "the remote must not publish bundles still building or lead the GitHub Release" + for secret in ("FLATPAK_GPG_PRIVATE_KEY", "FLATPAK_REPO_TOKEN"): + assert f"secrets.{secret}" in block, f"{secret} is not wired into the job" + assert "build-import-bundle" in block + assert "--gpg-sign" in block, "the remote must sign what it publishes" + assert "--generate-static-deltas" in block, "updates would re-download whole apps" + assert "external_repository" in block, "the tap repo gets no push without it" + assert "gh release upload" not in block, "the remote is not a release asset" + assert _permissions(block).get("id-token") != "write" + + +def test_the_remote_publish_is_stable_gated_and_keeps_history(): + """A prerelease tag must not reach `flatpak update` users, and a transient + checkout failure must not be mistaken for a first publish — the empty-repo + path would drop the objects clients still delta from.""" + block = _jobs()["publish-flatpak-remote"] + job_if = re.search(r"^ if: (.+)$", block, re.M) + assert job_if, "the job is not gated to stable tags" + for marker in ("alpha", "beta", "rc"): + assert f"contains(github.ref, '{marker}')" in job_if.group(1) + assert ( + "ls-remote --exit-code" in block + ), "no explicit check decides whether the remote was published before" + assert ( + "continue-on-error" not in block + ), "a tolerated checkout failure falls through to the empty-repo path" + assert ".flatpakref" in block, "no app ref is written for one-command installs"