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:
co-authored by
Jatin K Malik
parent
5e531cbac0
commit
24fddc4344
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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_<version>_amd64.snap` on GitHub.
|
||||
Listing: [snapcraft.io/vocalinux](https://snapcraft.io/vocalinux). `stable` is promoted from `candidate` after QA. Tagged releases also attach `vocalinux_<version>_amd64.snap` on GitHub.
|
||||
|
||||
```bash
|
||||
sudo snap install vocalinux --edge
|
||||
|
||||
+1
-1
@@ -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_<version>_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_<version>_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:
|
||||
|
||||
|
||||
@@ -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 <artifact> --repo VocaHQ/vocalinux`
|
||||
- [ ] Verify PyPI package was published (if applicable), and that its wheel sha256
|
||||
matches the line for that wheel in `SHA256SUMS`
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user