ci(release): open tap and bucket pull requests instead of pushing to main #19

Merged
james.maes merged 1 commits from ci/packaging-pull-requests into main 2026-11-08 23:29:24 +00:00
3 changed files with 101 additions and 42 deletions

No files matched your search

+6 -35
View File
@@ -7,7 +7,7 @@
# macos native darwin-arm64 binary, built and smoke-tested on the macOS runner
# windows native windows-x64 binary, built and smoke-tested on the Windows runner
# publish SHA256SUMS; publish the release and check its downloads; the four npm packages to the
# QRun-IO Gitea npm registry; for a final version the Homebrew tap and the Scoop bucket
# QRun-IO Gitea npm registry; for a final version pull requests to the Homebrew tap and the Scoop bucket
name: release
on:
push:
@@ -296,38 +296,9 @@ jobs:
echo "Published $name@$version ($tag): $(npm view "$name@$version" version --registry "$registry")"
done
- name: Homebrew tap and Scoop bucket
- name: Homebrew tap and Scoop bucket (pull requests)
# Final versions: branch release/qqq-<version> and a pull request "qqq <version>" in QRun-IO/homebrew-tap
# and QRun-IO/scoop-bucket (PACKAGES_TOKEN); a human merges. Prereleases, or a refused push, attach
# qqq.rb / qqq.json to the release instead. Never fails the release.
shell: bash
run: |
set -euo pipefail
node scripts/update-packaging.mjs /tmp/assets/SHA256SUMS
api="$GITHUB_SERVER_URL/api/v1"
# Final versions only: the tap and the bucket follow the stable channel, like scripts/install.sh.
# Pushed when PACKAGES_TOKEN can write the repository; otherwise the filled file goes on the release.
can_write() {
curl -fsS -H "Authorization: token $PACKAGES_TOKEN" "$api/repos/$1" 2>/dev/null |
node -e 'try { process.stdout.write(String(JSON.parse(require("fs").readFileSync(0, "utf8")).permissions.push === true)) } catch { process.stdout.write("false") }' || echo false
}
push_file() {
local repo=$1 src=$2 dest=$3 dir header
dir=$(mktemp -d)
header="Authorization: Basic $(printf 'ci:%s' "$PACKAGES_TOKEN" | base64 -w0)"
git -c http.extraHeader="$header" clone -q --depth 1 "$GITHUB_SERVER_URL/$repo.git" "$dir"
cp "$src" "$dir/$dest"
git -C "$dir" add "$dest"
if git -C "$dir" diff --cached --quiet; then echo "$repo already has qqq $VERSION"; return 0; fi
git -C "$dir" -c user.name=CI -c user.email=ci@kof22.com commit -q -m "qqq $VERSION"
git -C "$dir" -c http.extraHeader="$header" push -q origin HEAD:main
echo "Pushed qqq $VERSION to $repo"
}
for spec in "QRun-IO/homebrew-tap docs/packaging/homebrew-tap/Formula/qqq.rb Formula/qqq.rb" \
"QRun-IO/scoop-bucket docs/packaging/qqq.json bucket/qqq.json"; do
read -r repo src dest <<< "$spec"
if [[ $VERSION != *-* && $(can_write "$repo") == true ]]; then
push_file "$repo" "$src" "$dest"
else
curl -fsS -o /dev/null -X POST -H "Authorization: token $TOKEN" -F "attachment=@$src" \
"$api/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=$(basename "$src")"
echo "$repo not updated (prerelease, or PACKAGES_TOKEN cannot write it): $(basename "$src") is attached to the release for a pull request"
fi
done
run: bash scripts/publish-packaging.sh
+10 -7
View File
@@ -14,8 +14,8 @@ What ships:
|---|---|---|
| `qqq` binaries with the terminal UI, 5 targets (linux x64/arm64, darwin x64/arm64, windows x64), plus `SHA256SUMS` | Gitea release `v<version>` of `QRun-IO/qqq-cli` | `release.yaml`: Linux targets and darwin-x64 (cross) in the container, darwin-arm64 on the macOS runner, windows-x64 on the Windows runner |
| `@qrun-io/qqq-ui-api-client`, `qqq-core`, `qqq-ui-api-conformance`, `qqq-cli` | QRun-IO Gitea npm registry, `https://git.kof22.com/api/packages/QRun-IO/npm/` | `release.yaml` publish job |
| Homebrew formula `qqq` | `QRun-IO/homebrew-tap`, `Formula/qqq.rb` | `release.yaml` (final versions), else a pull request |
| Scoop manifest `qqq` | `QRun-IO/scoop-bucket`, `bucket/qqq.json` | `release.yaml` (final versions), else a pull request |
| Homebrew formula `qqq` | `QRun-IO/homebrew-tap`, `Formula/qqq.rb` | pull request from `release.yaml` (final versions), merged by a human |
| Scoop manifest `qqq` | `QRun-IO/scoop-bucket`, `bucket/qqq.json` | pull request from `release.yaml` (final versions), merged by a human |
## 1. Versions
@@ -63,9 +63,11 @@ prerelease: npm dist-tag `next` for prerelease packages, a Gitea prerelease, and
3. **publish** (`container-build`): `SHA256SUMS` from the three jobs' checksums, uploaded; the release is
published and every download is checked against `SHA256SUMS`; the four packages go to the Gitea npm
registry with `PACKAGES_TOKEN`; `scripts/update-packaging.mjs` fills the formula and the manifest.
For a final version they are pushed to `QRun-IO/homebrew-tap` and `QRun-IO/scoop-bucket` when
`PACKAGES_TOKEN` can write those repositories; otherwise `qqq.rb` and `qqq.json` are attached to the
release (step 4).
For a final version `scripts/publish-packaging.sh` pushes branch `release/qqq-<version>` to
`QRun-IO/homebrew-tap` and `QRun-IO/scoop-bucket` with `PACKAGES_TOKEN` and opens a pull request
`qqq <version>` in each; a human checks the sums and merges. When the push or the pull request is refused
(or for a prerelease) `qqq.rb` and `qqq.json` are attached to the release instead (step 4); the release
never fails over this. Dry run, no writes: `DRY_RUN=1 VERSION=<version> SUMS_FILE=<SHA256SUMS> scripts/publish-packaging.sh`.
Watch it under Actions in `QRun-IO/qqq-cli`. The release page is
`https://git.kof22.com/QRun-IO/qqq-cli/releases/tag/v<version>`.
@@ -76,8 +78,9 @@ musl hosts use the npm package (`@qrun-io/qqq-cli`, CLI only) until a musl build
## 4. Homebrew tap and Scoop bucket (when CI could not push)
`PACKAGES_TOKEN` is the package-scoped publisher token; unless it is granted write on the tap and the
bucket, CI attaches the filled files to the release instead. For a final version, download them and open a
`PACKAGES_TOKEN` is the package-scoped publisher token (user `qqq-package-publisher`); unless it is granted
write on the tap and the bucket, CI attaches the filled files to the release instead. Their `main` branches
should be protected so CI can only propose. For a final version, download them and open a
pull request on each:
```sh
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# Copyright 2026 QRun.IO, Inc.
# Licensed under the Apache License, Version 2.0 (see LICENSE).
#
# Offer the filled Homebrew formula and Scoop manifest (scripts/update-packaging.mjs) to the tap and the
# bucket as pull requests; a human merges them. Called by the release workflow's publish job.
#
# final version push branch release/qqq-<version> to QRun-IO/homebrew-tap and QRun-IO/scoop-bucket with
# PACKAGES_TOKEN and open a pull request "qqq <version>" in each
# otherwise (prerelease, or the push or the pull request is refused) attach qqq.rb / qqq.json to the
# release instead, for a hand-made pull request (docs/release-runbook.md step 4)
#
# Never fails the release over the tap or the bucket. DRY_RUN=1 clones and commits locally but pushes,
# opens and attaches nothing; it needs only VERSION (and GITHUB_SERVER_URL, default https://git.kof22.com):
# DRY_RUN=1 VERSION=1.2.3 scripts/publish-packaging.sh
#
# Environment: VERSION, GITHUB_SERVER_URL, GITHUB_REPOSITORY, RELEASE_ID, TOKEN (job token, release assets),
# PACKAGES_TOKEN (tap and bucket), DRY_RUN.
set -euo pipefail
server=${GITHUB_SERVER_URL:-https://git.kof22.com}
api="$server/api/v1"
dry=${DRY_RUN:-0}
: "${VERSION:?VERSION is required}"
[[ $dry == 1 ]] || : "${TOKEN:?TOKEN is required}" "${RELEASE_ID:?RELEASE_ID is required}" "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
branch="release/qqq-$VERSION"
title="qqq $VERSION"
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
json() { node -e 'const o = {}; for (let i = 1; i < process.argv.length; i += 2) o[process.argv[i]] = process.argv[i + 1];
process.stdout.write(JSON.stringify(o))' -- "$@"; }
field() { node -e 'try { const v = JSON.parse(require("fs").readFileSync(0, "utf8")); const r = [].concat(v)
.find((p) => !process.argv[2] || (p.head && p.head.ref === process.argv[2])); process.stdout.write(String(r ? r[process.argv[1]] ?? "" : "")) }
catch { }' -- "$@"; }
attach() { # attach <file>: the fallback, a hand-made pull request from the release asset
local src=$1 name
name=$(basename "$src")
if [[ $dry == 1 ]]; then echo "DRY RUN: would attach $name to the release"; return 0; fi
curl -fsS -o /dev/null -X POST -H "Authorization: token $TOKEN" -F "attachment=@$src" \
"$api/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=$name" ||
{ echo "::warning::could not attach $name to the release"; return 0; }
echo "Attached $name to the release for a hand-made pull request"
}
propose() { # propose <repo> <src> <dest>: branch + pull request; non-zero means fall back
local repo=$1 src=$2 dest=$3 dir="$work/${1##*/}" header=() base pr body
[[ $dry == 1 || -n ${PACKAGES_TOKEN:-} ]] || { echo "PACKAGES_TOKEN is empty"; return 1; }
[[ -n ${PACKAGES_TOKEN:-} ]] && header=(-c "http.extraHeader=Authorization: Basic $(printf 'ci:%s' "$PACKAGES_TOKEN" | base64 | tr -d '\n')")
git "${header[@]}" clone -q --depth 1 "$server/$repo.git" "$dir" || return 1
base=$(git -C "$dir" rev-parse --abbrev-ref HEAD)
git -C "$dir" checkout -q -b "$branch"
mkdir -p "$(dirname "$dir/$dest")"
cp "$src" "$dir/$dest"
git -C "$dir" add "$dest"
if git -C "$dir" diff --cached --quiet; then echo "$repo $base already has qqq $VERSION"; return 0; fi
git -C "$dir" -c user.name=CI -c user.email=ci@kof22.com commit -q -m "$title"
git -C "$dir" --no-pager show --stat --format='%s' HEAD
if [[ $dry == 1 ]]; then
echo "DRY RUN: would push $branch to $repo and open pull request \"$title\" into $base"; return 0
fi
# CI owns release/qqq-<version>: a re-run replaces it.
git -C "$dir" "${header[@]}" push -q --force origin "HEAD:refs/heads/$branch" || { echo "push to $repo refused"; return 1; }
body="Formula/manifest for qqq $VERSION, filled by the qqq-cli release workflow from the release's SHA256SUMS: $server/$GITHUB_REPOSITORY/releases/tag/v$VERSION. Check the sums, then merge."
pr=$(curl -fsS -X POST -H "Authorization: token $PACKAGES_TOKEN" -H 'Content-Type: application/json' \
-d "$(json head "$branch" base "$base" title "$title" body "$body")" "$api/repos/$repo/pulls" 2>/dev/null | field html_url) || pr=''
if [[ -z $pr ]]; then # a re-run: the pull request may already be open
pr=$(curl -fsS -H "Authorization: token $PACKAGES_TOKEN" "$api/repos/$repo/pulls?state=open&limit=50" 2>/dev/null |
field html_url "$branch") || pr=''
fi
[[ -n $pr ]] || { echo "pushed $branch to $repo but could not open the pull request"; return 1; }
echo "Opened $pr"
}
node "$(dirname "$0")/update-packaging.mjs" "${SUMS_FILE:-/tmp/assets/SHA256SUMS}"
for spec in "QRun-IO/homebrew-tap docs/packaging/homebrew-tap/Formula/qqq.rb Formula/qqq.rb" \
"QRun-IO/scoop-bucket docs/packaging/qqq.json bucket/qqq.json"; do
read -r repo src dest <<< "$spec"
# Final versions only: the tap and the bucket follow the stable channel, like scripts/install.sh.
if [[ $VERSION == *-* ]]; then
echo "$repo not updated: $VERSION is a prerelease"; attach "$src"; continue
fi
propose "$repo" "$src" "$dest" || { echo "$repo not updated: falling back to the release asset"; attach "$src"; }
done