From 24fddc4344d7af716cf0732c3b3002380b02471c 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 23:18:38 -0700 Subject: [PATCH] chore(release): add snap-promote workflow for gated stable releases (#881) * chore(release): add snap-promote workflow for gated stable releases Issue #783 option 2 (safer): v* tags keep auto-publishing the snap to edge + candidate in release.yml, and latest/stable is promoted by a manual snap-promote.yml dispatch after candidate QA. The dispatch resolves the revision on latest/candidate and refuses to promote unless its version matches the tag, so a typo or stale candidate fails the run instead of shipping the wrong revision. Declaring the snap-stable environment with required reviewers adds an approval gate on top of the dispatch. Fixes #783 * fix(ci): serialize snap promotions and re-verify candidate awk used the gawk-only \S which mawk reads literally, so the latest-track candidate row never matched. The promote step also trusted a revision resolved minutes earlier, letting an overlapping promotion downgrade stable. Serializes dispatches and re-resolves the candidate right before release. --------- Co-authored-by: Jatin K Malik --- .github/workflows/release.yml | 5 +- .github/workflows/snap-promote.yml | 123 +++++++++++++++++++++++++++++ README.md | 2 +- docs/INSTALL.md | 2 +- docs/RELEASE_PROCESS.md | 10 ++- tests/test_snap_packaging.py | 25 ++++++ 6 files changed, 162 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/snap-promote.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 09e49db..441a0c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -253,7 +253,8 @@ jobs: ### Snap Tagged releases also attach `vocalinux_${{ steps.get_version.outputs.VERSION }}_amd64.snap` - and try the Snap Store (`edge` and `candidate`). `stable` is a manual promote. + and try the Snap Store (`edge` and `candidate`). `stable` is promoted + from candidate after QA by the `snap-promote.yml` dispatch workflow. Super-privileged plugs such as `uinput` can hold the Store revision in human review; the GitHub `.snap` is the sideload in that case: ```bash @@ -764,7 +765,7 @@ jobs: # GitHub Release first, Store second. uinput is super-privileged, so the # Store can queue a human review and refuse edge/candidate; the .snap still # has to land on the tag for sideload. grade: stable allows candidate; do - # not auto-release to stable — promote after QA. + # not auto-release to stable — promote after QA via snap-promote.yml (#783). # Secret: SNAPCRAFT_STORE_CREDENTIALS (snapcraft export-login). build-snap: name: Build Snap diff --git a/.github/workflows/snap-promote.yml b/.github/workflows/snap-promote.yml new file mode 100644 index 0000000..9e0c52c --- /dev/null +++ b/.github/workflows/snap-promote.yml @@ -0,0 +1,123 @@ +name: Promote snap to stable + +# Issue #783, the "safer" option: `v*` tags keep auto-publishing the snap to +# edge + candidate in release.yml; latest/stable stays gated on candidate QA. +# After QA (install, tray, mic, model download, typing into a real app), run: +# gh workflow run snap-promote.yml -f tag=v0.18.0 +# +# The candidate revision must carry the tag's version, so a typo'd tag or a +# stale candidate fails the run instead of promoting the wrong revision. +# The `snap-stable` environment is an optional second gate: add required +# reviewers to it in repo settings and each dispatch waits for an approval. +# Caller-supplied tag values stay in the environment, never in run: source. + +on: + workflow_dispatch: + inputs: + tag: + description: "Release tag whose candidate snap to promote (e.g. v0.18.0)" + required: true + type: string + +permissions: + contents: read + +# Promotions serialize: a second dispatch queues instead of racing the first. +concurrency: + group: snap-promote-stable + cancel-in-progress: false + +jobs: + promote: + name: Promote latest/candidate to latest/stable + runs-on: ubuntu-latest + permissions: + contents: read + environment: + name: snap-stable + steps: + - name: Require a version tag + env: + TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + case "$TAG" in + v[0-9]*) ;; + *) + echo "refusing tag that is not v…: $TAG" >&2 + exit 1 + ;; + esac + if printf '%s' "$TAG" | grep -q '[^A-Za-z0-9._-]'; then + echo "refusing tag with unexpected characters: $TAG" >&2 + exit 1 + fi + + - name: Require Snap Store credentials + env: + SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} + run: | + if [ -z "$SNAPCRAFT_STORE_CREDENTIALS" ]; then + echo "SNAPCRAFT_STORE_CREDENTIALS is unset; cannot promote the snap" + exit 1 + fi + + - name: Install snapcraft + run: sudo snap install snapcraft --classic + + # `snapcraft status` prints the channel map grouped by track and arch; + # the candidate row is a continuation line, so column 1 is the channel. + - name: Resolve the candidate revision + id: candidate + env: + SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} + TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + VERSION="${TAG#v}" + /snap/bin/snapcraft status vocalinux | tee /tmp/snap-status.txt + # POSIX: mawk has no \S — use a literal non-space class. + row=$(awk '/^[^ \t]/ { track = $1 } + track == "latest" && $1 == "candidate" { print $2, $3; exit }' \ + /tmp/snap-status.txt) + read -r seen_version revision <<<"$row" + echo "latest/candidate holds version=${seen_version:-none} revision=${revision:-none}" + case "${revision:-}" in + ''|*[!0-9]*) + echo "No promotable revision on latest/candidate (stale or under review?)" >&2 + exit 1 + ;; + esac + if [ "$seen_version" != "$VERSION" ]; then + echo "candidate version '$seen_version' does not match tag version '$VERSION'" >&2 + exit 1 + fi + echo "revision=$revision" >> "$GITHUB_OUTPUT" + + # Re-resolve right before releasing: if a newer release published a + # different candidate after the first check, stable would downgrade. + - name: Re-verify the candidate did not move + env: + SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} + TAG: ${{ inputs.tag }} + REVISION: ${{ steps.candidate.outputs.revision }} + run: | + set -euo pipefail + VERSION="${TAG#v}" + row=$(awk '/^[^ \t]/ { track = $1 } + track == "latest" && $1 == "candidate" { print $2, $3; exit }' \ + <(/snap/bin/snapcraft status vocalinux)) + read -r seen_version revision <<<"$row" + if [ "${revision:-}" != "$REVISION" ] || [ "$seen_version" != "$VERSION" ]; then + echo "latest/candidate moved on (now version=${seen_version:-none} revision=${revision:-none}); aborting instead of promoting the stale revision" >&2 + exit 1 + fi + + - name: Release the resolved revision to latest/stable + env: + SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} + REVISION: ${{ steps.candidate.outputs.revision }} + run: | + set -euo pipefail + /snap/bin/snapcraft release vocalinux "$REVISION" latest/stable + /snap/bin/snapcraft status vocalinux diff --git a/README.md b/README.md index 473f72f..ce05540 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ Ships whisper.cpp with Vulkan. It is **not on Flathub** (submission [flathub#936 ### Snap (Ubuntu Snap Store) -Listing: [snapcraft.io/vocalinux](https://snapcraft.io/vocalinux). `stable` is still a manual promote after QA. Tagged releases also attach `vocalinux__amd64.snap` on GitHub. +Listing: [snapcraft.io/vocalinux](https://snapcraft.io/vocalinux). `stable` is promoted from `candidate` after QA. Tagged releases also attach `vocalinux__amd64.snap` on GitHub. ```bash sudo snap install vocalinux --edge diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 08902d9..141f6cd 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -134,7 +134,7 @@ Whisper.cpp + Vulkan. It is **not on Flathub** (submission [flathub#9368](https: ## Snap (Ubuntu Snap Store) -Listing: [snapcraft.io/vocalinux](https://snapcraft.io/vocalinux) (issue [#48](https://github.com/VocaHQ/vocalinux/issues/48)). Recipe: `snap/snapcraft.yaml`. Tagged `v*` releases attach `vocalinux__amd64.snap` to the GitHub Release and try Snap Store `edge` and `candidate` when credentials are set. `stable` is still a manual promote after QA. +Listing: [snapcraft.io/vocalinux](https://snapcraft.io/vocalinux) (issue [#48](https://github.com/VocaHQ/vocalinux/issues/48)). Recipe: `snap/snapcraft.yaml`. Tagged `v*` releases attach `vocalinux__amd64.snap` to the GitHub Release and try Snap Store `edge` and `candidate` when credentials are set. `stable` is promoted from `candidate` after QA. Store install, once Canonical lists the revision: diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index 02d8fcc..084a199 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -396,7 +396,9 @@ After pushing the tag, the GitHub Actions workflow will automatically: 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 + must not block the GitHub `.snap`. `stable` does not auto-release (#783): + after candidate QA, dispatch `snap-promote.yml`, which releases the + candidate revision to `latest/stable` 10. Mark as pre-release if version contains alpha/beta/rc Monitor at: https://github.com/VocaHQ/vocalinux/actions @@ -409,6 +411,12 @@ Monitor at: https://github.com/VocaHQ/vocalinux/actions users to run `sha256sum -c` against it - [ ] If the Store held the snap for `uinput` review, confirm the GitHub `.snap` is still attached and the notes document `snap install --dangerous` +- [ ] After candidate QA (install, tray, mic, model download, typing into a real + app), promote the snap to stable: + `gh workflow run snap-promote.yml -f tag=vX.Y.Z` - it fails unless + `latest/candidate` carries a revision whose version matches the tag. An + optional `snap-stable` environment with required reviewers adds a second + approval gate on the dispatch - [ ] Verify provenance: `gh attestation verify --repo VocaHQ/vocalinux` - [ ] Verify PyPI package was published (if applicable), and that its wheel sha256 matches the line for that wheel in `SHA256SUMS` diff --git a/tests/test_snap_packaging.py b/tests/test_snap_packaging.py index 43b2629..b82095a 100644 --- a/tests/test_snap_packaging.py +++ b/tests/test_snap_packaging.py @@ -168,3 +168,28 @@ def test_snap_backfill_dispatch_input_never_reaches_the_shell() -> None: assert "${{" not in line, f"a run: step interpolates a template expression: {line.strip()}" assert "TAG: ${{ inputs.tag }}" in text assert 'gh release upload "$TAG"' in text + + +def test_snap_promote_dispatch_gates_stable_on_candidate() -> None: + """stable is the gated option of #783: a manual dispatch releasing the candidate revision.""" + text = (REPO_ROOT / ".github" / "workflows" / "snap-promote.yml").read_text(encoding="utf-8") + assert "workflow_dispatch:" in text + assert 'snapcraft release vocalinux "$REVISION" latest/stable' in text + assert "SNAPCRAFT_STORE_CREDENTIALS is unset; cannot promote the snap" in text + assert "environment:" in text + # The promote must prove the candidate revision carries the tag's version. + assert "snapcraft status vocalinux" in text + assert 'if [ "$seen_version" != "$VERSION" ]' in text + + +def test_snap_promote_dispatch_input_never_reaches_the_shell() -> None: + """Same rule as snap-backfill.yml: interpolating the tag into run: is injection.""" + import re + + text = (REPO_ROOT / ".github" / "workflows" / "snap-promote.yml").read_text(encoding="utf-8") + run_lines = [line for line in text.splitlines() if re.match(r"^\s*-?\s*run:", line)] + assert run_lines, "found no run: step, so this guard is scanning nothing" + for line in run_lines: + assert "${{" not in line, f"a run: step interpolates a template expression: {line.strip()}" + assert "TAG: ${{ inputs.tag }}" in text + assert "REVISION: ${{ steps.candidate.outputs.revision }}" in text