Release Downloader Desktop (vX.Y.Z)
The author triggers this with a single sentence like "go next version 1.7.0". Everything below is pre-authorized (see CLAUDE.md → "Release routine") — do ALL of it without pausing for permission. A release is only "done" when every channel in the checklist at the bottom is verified.
TL;DR — the happy path
# 1. Write pretty Markdown highlights (MANDATORY — never ship a noteless release) to a temp file.
# 2. From the repo root, on a CLEAN tree:
./scripts/release.sh X.Y.Z --yes --notes-file /path/to/notes.md
# 3. Verify every channel (checklist below), then record the release in OpenSpec.
scripts/release.sh is the source of truth and does, in order:
- Preflight — gh authenticated, clean tree, tag doesn't exist, develop ahead of main.
- Bump
VersionPrefix(csproj) +snap/local/VERSIONondevelop, commit + push. - Merge
develop→main(--no-ff, messagerelease: vX.Y.Z (merge develop)), push main. - Tag
vX.Y.Zon main and push the tag. The tag push triggers CI:.github/workflows/release.yml— builds win-x64 / linux-x64 / osx-x64 / osx-arm64 (Downloader-<rid>.tar.gz/.zip), creates the GitHub Release and attaches them. The Linuxcurl | bashinstaller reads "latest", so it serves the new version automatically..github/workflows/snap.yml— builds the.snapand publishes it to the Snap Store stable channel (repo secretSNAPCRAFT_STORE_CREDENTIALSis set).
- Waits (≤30 min) for both macOS archives to attach, then:
- Release notes — sets the body to your Highlights + GitHub's auto "What's Changed".
- Homebrew — updates
bezzad/homebrew-tapCasks/downloader.rb(version + arm64/x64 sha256) and syncs the in-repo mirrorCasks/downloader.rbon develop. - winget — bumps the in-repo mirror
packaging/winget/*.yaml(PackageVersion ×3 + InstallerUrl/InstallerSha256 ofDownloader-win-x64.zip) on develop, then opens a PR tomicrosoft/winget-pkgsundermanifests/b/bezzad/Downloader/X.Y.Z/(dedup-checked: it skips if an open PR for that version exists).
The script is portable (Linux + macOS) — it uses sedi/b64_file/sed-based version parsing
instead of GNU-only sed -i / base64 -w0 / grep -P. Keep it that way.
The CI gate (a release will refuse to start over red or unfinished CI)
release.sh will not publish a commit GitHub has not proved green. After syncing develop it looks up
the workflow runs for that exact commit (a green run on an older commit proves nothing about this one)
and then checks main's head the same way, because main is what gets tagged:
- a run still
queued/in_progress→ it WAITS (default 40 min,CI_WAIT_MINUTESto change), because a run in flight is not a pass; - any completed run that is not
success(failed OR cancelled) → it stops, names the workflow, and changes nothing; - no run at all for that commit after 2 min → it stops (push it and let the workflows run).
Skipped on a resume: the tag already exists, so gating there would only block the remaining channels.
Last resort: --ignore-ci publishes anyway and says so loudly in the log. Use it only when the author
has explicitly decided that a specific failure is irrelevant to the build being shipped — never to get a
release moving because waiting is inconvenient.
Release notes — format rules (HIGH priority)
Write them BEFORE running the script, save to a file, pass via --notes-file. Never plain text:
- One-line summary sentence first.
- Short emoji-grouped sections:
### ✨ New/### 🐛 Fixes/### 🔧 Under the hood, a few concise end-user bullets each (no commit hashes, no internal jargon). - Optionally end with a thin divider + an install hint.
- Derive content from
git log --no-merges --pretty='- %s' v<prev>..developbut rewrite for users. - Good examples on GitHub: v1.0.0 / v1.1.0 / v1.2.0.
Preconditions to check first
gh auth statusOK; working tree clean (git status --porcelainempty); on any branch (script checks out what it needs);developpushed and green (CI +dotnet testfromsrc/).- The version must be three-part
X.Y.Zand match what you'll tell users ("v1.6" ⇒1.6.0). - Never re-tag: re-running a version needs the tag AND the GitHub Release deleted first.
Running unattended (AI session pattern)
The asset wait can take ~10–30 min, longer than a foreground Bash timeout. Run it in the background and poll:
export GH_TOKEN="$(gh auth token)" # REQUIRED in background runs — see gotcha below
./scripts/release.sh X.Y.Z --yes --notes-file notes.md # run_in_background
# poll: gh release view vX.Y.Z --json assets --jq '.assets[].name' (expect 6 binary assets incl. the two extension zips)
# and: gh run list --limit 3 (release.yml + snap.yml conclusions)
macOS keyring gotcha (bit the v1.6.0 run): gh stores its token in the macOS Keychain, which a
detached background process cannot read — gh auth status fails there with "not authenticated"
even though it works interactively. Always export GH_TOKEN="$(gh auth token)" (resolved in the
foreground) as part of the background command.
Snap + AUR publish in CI, NOT from the machine running release.sh (2026-08-21)
Both are machine-independent by design — release.sh must never be the thing that publishes them:
- Snap:
snap.ymlon thev*tag builds + uploads to the stable channel using the repo secretSNAPCRAFT_STORE_CREDENTIALS. Always was CI-side. - AUR: the
aurjob inrelease.yml(needs: build) checksums the released linux-x64 tarball, runsscripts/bump-aur.sh <ver> <sha>, and pushesdownloader-binover SSH using the repo secretAUR_SSH_PRIVATE_KEY. It used to push fromrelease.shwith whatever key the invoking box had, so a release from a different machine/VM silently skipped the AUR (happened on v2.3.0).release.shnow only syncs the in-repo mirror (plain git, works anywhere). scripts/bump-aur.shis the single source of truth for the PKGBUILD/.SRCINFO rewrite — bothrelease.shand the CI job call it, so they can't drift. It self-verifies and exits non-zero on a half-rewrite. The CI job rewrites from the TAG (the tag's tree still has the previous version, since the mirror bump lands on develop only after tagging).- One-time setup for AUR: add the AUR account's PRIVATE key (public half registered at
aur.archlinux.org → My Account) as the repo secret
AUR_SSH_PRIVATE_KEY. Absent ⇒ the job logs a::warning::and skips; it never fails the release.
Verification checklist (all channels — release isn't done until these pass)
gh release view vX.Y.Z --repo bezzad/Downloader.Desktop # body has Highlights; 6 binary assets:
# Downloader-win-x64.zip, Downloader-linux-x64.tar.gz, Downloader-osx-{x64,arm64}.tar.gz,
# downloader-extension-{chrome,firefox}.zip (attached by release.yml's post-matrix extension job)
gh run list --repo bezzad/Downloader.Desktop --limit 5 # release.yml + snap.yml green
gh api repos/bezzad/homebrew-tap/contents/Casks/downloader.rb --jq '.content' | base64 -d | grep version
gh pr list --repo microsoft/winget-pkgs --author @me # X.Y.Z PR open (moderator merges later)
snap info downloader 2>/dev/null | grep latest/stable # Snap Store (may lag a few minutes)
curl -fsS "https://aur.archlinux.org/rpc/v5/info?arg[]=downloader-bin" | grep -o '"Version":"[^"]*"'
If snap CI failed to publish (check the run log): download its artifact and publish manually —
gh run download <id> -n downloader-snap && snapcraft upload --release=stable downloader_X.Y.Z_amd64.snap.
After the release
- Record it in OpenSpec (a short note in the relevant change/archive: version, tag, commit hashes, tap commit, winget PR #) and commit on develop.
- The winget PR waits on a community moderator — nothing to do; check back with
gh pr list --repo microsoft/winget-pkgs --author @me. Close stale older-version PRs instead of stacking duplicates. - If the author later reports "update available" false alarms, see SKILL.md → versioning
(
VersionPrefixmust be the full 3-part semver; release.yml stamps it from the tag).
Known gotchas (learned the hard way)
- Noteless releases are forbidden —
release.ymlhas anotesjob that only fills the body if it's still empty, so it never clobbers curated notes; but curated notes are still mandatory. - Do NOT add
generate_release_notes: trueto the matrix steps in release.yml — 4 concurrent release-creates race (tag_name already_exists) and an asset upload fails (bit v1.4.0). - macOS builds are NOT single-file (compressed single-file crashes on Apple Silicon) — release.yml already handles this; don't "simplify" it.
- winget identifier stays
bezzad.Downloader(Monikerdownloader); installer = portable zip. - The GitHub Release is created by the FIRST matrix job to finish;
gh release editbefore any asset exists can fail — the script retries via its wait loop. - Script must stay BSD/macOS-compatible: use the
sedi/b64_filehelpers, never rawsed -i/base64 -w0/grep -P.
Browser extension distribution (automated pieces)
- Every
v*release also carriesdownloader-extension-chrome.zip+downloader-extension-firefox.zip(release.ymlextensionjob, runs post-matrix — never let it create the release or set notes). - Store uploads are all manual. The
extension.ymlworkflow that auto-submitted to Mozilla AMO was removed 2026-08-24 (it had failed every run since 2026-07-07: AMO validation rejected the package with "A content script defined in the manifest could not be found atcontent.js", and the author decided the extension listings aren't needed). Nothing in a release depends on it. If a store version is ever wanted, upload the zip from the release page in that store's dashboard.
release.sh is now machine-independent + RESUMABLE (2026-07-11, post-v2.0.0)
Five failure classes that previously needed manual rescue are fixed IN the script:
- Resume: if the requested tag already exists, the run WARNS and resumes — skips bump/merge/tag and finishes asset-wait → notes → Homebrew → winget (all idempotent). A mid-run death is recovered by simply re-running the same command. Re-releasing a different dead version still requires deleting the tag+Release first.
- develop==main is OK when the version differs — the bump commit becomes the release commit (pre-merging develop→main to verify CI no longer blocks).
- Git identity is resolved (repo → global → gh account → fallback) and passed explicitly (
-c user.name/email) on every commit incl. the fresh tap clone — no global git config needed. - Token:
GH_TOKENis resolved up front (gh auth token) and embedded in the tap clone URL — works detached/background (macOS keyring) and withoutgh auth setup-git. - retry() (5×, 10 s) wraps every push/pull/clone — transient GitHub connectivity drops don't kill the run.
Validated by re-running
release.sh 2.0.0post-release: resume mode no-ops every channel cleanly.
release.sh preflight asks-and-fixes; exit report (2026-07-11)
A bare bash scripts/release.sh is the intended entry point — it prompts for anything missing
instead of aborting: version (suggests next patch), release notes (multi-line, . to end),
gh auth login runs inline if unauthenticated, and a dirty tree offers to autostash (an EXIT
trap restores it on develop afterwards — success OR failure). Non-interactive runs (no TTY)
still die with exact instructions. The same EXIT trap prints a per-channel report table
(GitHub Release / notes / curl / Snap / Homebrew / winget) on ANY exit, plus a "re-run to
resume" hint on failure. Changelog baseline = PREV_TAG (newest v* tag excluding the tag
being published) — never v$CUR_VERSION, which on a resume equals the new tag itself and
yields an empty changelog.
release.sh waits for ALL platform archives (2026-08-24, post-v2.5.0)
The asset wait used to cover only the two macOS archives, then hashed Downloader-win-x64.zip
unconditionally. With set -euo pipefail, WIN_SHA="$(sha256_of_asset ... | tr ...)" on a
not-yet-attached asset killed the run — v2.5.0 died right after the Homebrew step with
"winget: not started" (a plain re-run resumed and finished it). The wait now covers WAIT_ASSETS
(osx-arm64, osx-x64, win-x64, linux-x64) and the win/linux checksums are || true, so a missing
asset degrades to the existing warn-and-skip instead of aborting. missing is a plain string, not
an array — bash 3.2 on macOS trips over empty arrays under set -u.
Never run release.sh with a dirty tree, even though preflight let you (2026-08-30, v2.8.0)
Preflight checks the tree ONCE, at the start. The mirror steps near the end (cask → winget → aur)
each git pull --rebase develop first, and an unstaged edit made WHILE the script runs makes every one of
those fail — error: cannot pull with rebase: You have unstaged changes — burning all five retry()
attempts per step before it recovers. v2.8.0 survived it (the pushes still landed, exit 0), but it turns a
clean run into a minute of red herrings in the log. If you need to edit during the ~15 min asset wait,
either commit it or hold it until the script exits.
A silent exit 1 right after "Updating Homebrew" = the macOS checksum download failed (2026-09-11, v2.13.0)
The report said "Homebrew: not started" with no error line. ARM_SHA="$(sha256_of_asset …)" downloads the
archive with gh release download (5 tries); when all fail, set -e exits on the assignment itself, before
the || die on the same line runs — so no message. The archive was fine (curl fetched it seconds later).
Fixed with || true inside the substitution so the die names the cause. Either way the answer is the
same: re-run release.sh X.Y.Z — it resumes and every post-tag step is idempotent. General rule for this
script: any VAR="$(cmd)" that can fail needs || true inside, or it dies silently.