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 <jatinkrmalik@gmail.com>
This commit is contained in:
devin-ai-integration[bot]
2026-09-29 23:18:38 -07:00
committed by GitHub
co-authored by Jatin K Malik
parent 5e531cbac0
commit 24fddc4344
6 changed files with 162 additions and 5 deletions
+3 -2
View File
@@ -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
+123
View File
@@ -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<digit>…: $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