From bf9c093bdd5a339b3ed6ca3e2b666849c78e1584 Mon Sep 17 00:00:00 2001 From: James Maes Date: Sun, 8 Nov 2026 17:23:00 -0600 Subject: [PATCH] ci(release): open tap and bucket pull requests instead of pushing to main For a final version the publish job now pushes branch release/qqq- to QRun-IO/homebrew-tap and QRun-IO/scoop-bucket with PACKAGES_TOKEN and opens a pull request "qqq " in each; a human merges. A prerelease, or a refused push or pull request, attaches qqq.rb / qqq.json to the release as before, and never fails the release. The logic moves to scripts/publish-packaging.sh, with a DRY_RUN=1 path that clones and commits locally but writes nothing remote. Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/release.yaml | 41 +++-------------- docs/release-runbook.md | 17 ++++--- scripts/publish-packaging.sh | 85 +++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 42 deletions(-) create mode 100755 scripts/publish-packaging.sh diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index 5b1db91..f1cfb72 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -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- and a pull request "qqq " 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 diff --git a/docs/release-runbook.md b/docs/release-runbook.md index e74a8f6..a2e6599 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -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` 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-` to + `QRun-IO/homebrew-tap` and `QRun-IO/scoop-bucket` with `PACKAGES_TOKEN` and opens a pull request + `qqq ` 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= SUMS_FILE= 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`. @@ -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 diff --git a/scripts/publish-packaging.sh b/scripts/publish-packaging.sh new file mode 100755 index 0000000..a99346d --- /dev/null +++ b/scripts/publish-packaging.sh @@ -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- to QRun-IO/homebrew-tap and QRun-IO/scoop-bucket with +# PACKAGES_TOKEN and open a pull request "qqq " 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 : 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 : 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-: 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 -- 2.54.0