release: Gitea release pipeline on v<CLI version> tags; 1.0.1-rc.1 #15

Merged
james.maes merged 2 commits from ci/release-pipeline into main 2026-11-08 13:43:34 +00:00
17 changed files with 513 additions and 296 deletions

No files matched your search

+1
View File
@@ -1,6 +1,7 @@
name: build
on:
push:
branches: ['**']
jobs:
node:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/node.yaml@v1
+332
View File
@@ -0,0 +1,332 @@
# Copyright 2026 QRun.IO, Inc.
# Licensed under the Apache License, Version 2.0 (see LICENSE).
#
# Release on a v<CLI version> tag (docs/release-runbook.md):
# linux scripts/release.mjs gate; Linux, darwin-x64 binaries (cross); draft Gitea release
# 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
name: release
on:
push:
tags: ['v*']
permissions:
contents: write
env:
NODE_VERSION: 22.23.4
BUN_VERSION: 1.4.4
BUN_LINUX_SHA256: e0446eb55995772eb9894bebae9a6be368d985a8b9271ee124486f9946eedd76 # bun-linux-x64-baseline.zip
BUN_DARWIN_SHA256: c240d7f0ccb90c263a849fcdb09b7ee62630d921348ed6aafda86b9d577bc510 # bun-darwin-aarch64.zip
BUN_WINDOWS_SHA256: 0f20ce6674d4f2dbcc8c5d59fdf99f7ba45f8c3b367e0f5781cbfa30a1c78d99 # bun-windows-x64-baseline.zip
jobs:
linux:
runs-on: container-build
timeout-minutes: 60
container:
image: node:22.23.4-bookworm
volumes:
- /var/lib/gitea-job-workspace:${{ github.workspace }}
env:
HOME: /tmp
CI: 'true'
outputs:
release-id: ${{ steps.draft.outputs.id }}
version: ${{ steps.plan.outputs.version }}
sums: ${{ steps.draft.outputs.sums }}
steps:
- uses: builtin:checkout
with:
fetch-depth: 0
- name: Check the tag
id: plan
shell: bash
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
git config --global --add safe.directory "$GITHUB_WORKSPACE"
version=$(node -p 'require("./packages/cli/package.json").version')
[[ $TAG =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || { echo "$TAG is not a release tag"; exit 1; }
[[ $TAG == "v$version" ]] || { echo "Tag $TAG does not match packages/cli/package.json $version"; exit 1; }
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Install Bun
shell: bash
run: |
set -euo pipefail
mkdir -p /tmp/bun && cd /tmp/bun
curl -fsSLo bun.zip "https://github.com/oven-sh/bun/releases/download/bun-v$BUN_VERSION/bun-linux-x64-baseline.zip"
echo "$BUN_LINUX_SHA256 bun.zip" | sha256sum -c -
python3 -c 'import sys, zipfile; zipfile.ZipFile(sys.argv[1]).extractall(".")' bun.zip
chmod +x bun-linux-x64-baseline/bun && echo /tmp/bun/bun-linux-x64-baseline >> "$GITHUB_PATH"
- name: Release gate (scripts/release.mjs)
# npm ci, generate drift, build, readiness (final versions), test, lint, TUI tests and type check, pack dry runs
run: node scripts/release.mjs --skip-binaries
- name: Linux and darwin-x64 binaries
shell: bash
run: |
set -euo pipefail
node scripts/build-binaries.mjs --no-build --targets linux-x64,linux-arm64,linux-x64-musl,linux-arm64-musl,darwin-x64
scripts/verify-binary.sh dist/qqq-linux-x64
- name: Draft Gitea release
id: draft
shell: bash
env:
TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
VERSION: ${{ steps.plan.outputs.version }}
run: |
set -euo pipefail
api="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY"
auth="Authorization: token $TOKEN"
if curl -fsS -o /dev/null -H "$auth" "$api/releases/tags/$TAG" 2>/dev/null; then
echo "A release for $TAG already exists; a version is released once"; exit 1
fi
if [[ -f docs/release-notes/$VERSION.md ]]; then notes=$(cat "docs/release-notes/$VERSION.md"); else
notes=$(awk -v v="[$VERSION]" '/^## / { if (found) exit; if (index($0, "## " v) == 1) { found = 1; next } } found { print }' CHANGELOG.md)
fi
[[ -n ${notes//[[:space:]]/} ]] || notes="qqq $VERSION"
body=$(NOTES="$notes" node -e 'process.stdout.write(JSON.stringify({ tag_name: process.env.TAG,
name: `qqq ${process.env.VERSION}`, body: process.env.NOTES, draft: true, prerelease: process.env.VERSION.includes("-") }))')
id=$(curl -fsS -X POST -H "$auth" -H 'Content-Type: application/json' -d "$body" "$api/releases" |
node -e 'process.stdout.write(String(JSON.parse(require("fs").readFileSync(0, "utf8")).id))')
echo "id=$id" >> "$GITHUB_OUTPUT"
echo "sums=$(cd dist && sha256sum qqq-* | tr '\n' ';')" >> "$GITHUB_OUTPUT"
for f in dist/qqq-*; do
curl -fsS -o /dev/null -X POST -H "$auth" -F "attachment=@$f" "$api/releases/$id/assets?name=$(basename "$f")"
echo "Attached $(basename "$f") to draft release $id"
done
macos:
needs: linux
runs-on: qqq-macos-arm64
timeout-minutes: 45
env:
CI: 'true'
outputs:
sums: ${{ steps.attach.outputs.sums }}
steps:
- uses: builtin:checkout
- name: Install Node.js and Bun
shell: bash
run: |
set -euo pipefail
tools="$(mktemp -d)" && cd "$tools"
node_file="node-v$NODE_VERSION-darwin-arm64.tar.gz"
curl -fsSLO "https://nodejs.org/dist/v$NODE_VERSION/$node_file" && curl -fsSLO "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt"
grep " $node_file\$" SHASUMS256.txt | shasum -a 256 -c -
tar -xzf "$node_file"
curl -fsSLo bun.zip "https://github.com/oven-sh/bun/releases/download/bun-v$BUN_VERSION/bun-darwin-aarch64.zip"
echo "$BUN_DARWIN_SHA256 bun.zip" | shasum -a 256 -c -
unzip -q bun.zip
echo "$tools/node-v$NODE_VERSION-darwin-arm64/bin" >> "$GITHUB_PATH"
echo "$tools/bun-darwin-aarch64" >> "$GITHUB_PATH"
echo "TOOLS_DIR=$tools" >> "$GITHUB_ENV"
- name: Build, test, darwin-arm64 binary
shell: bash
run: |
set -euo pipefail
node --version && bun --version
npm ci
npm run build
npm test
npm run lint
node scripts/build-binaries.mjs --no-build --host
scripts/verify-binary.sh dist/qqq-darwin-arm64
- name: Attach to the draft release
id: attach
shell: bash
env:
TOKEN: ${{ github.token }}
RELEASE_ID: ${{ needs.linux.outputs.release-id }}
run: |
set -euo pipefail
curl -fsS -o /dev/null -X POST -H "Authorization: token $TOKEN" -F "attachment=@dist/qqq-darwin-arm64" \
"$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=qqq-darwin-arm64"
echo "Attached qqq-darwin-arm64"
echo "sums=$(cd dist && shasum -a 256 qqq-darwin-arm64 | tr '\n' ';')" >> "$GITHUB_OUTPUT"
- name: Remove the tools
if: always()
shell: bash
run: if [[ -n ${TOOLS_DIR:-} ]]; then rm -rf "$TOOLS_DIR"; fi
windows:
needs: linux
runs-on: qqq-windows-vm6218
timeout-minutes: 45
env:
CI: 'true'
outputs:
sums: ${{ steps.attach.outputs.sums }}
steps:
- uses: builtin:checkout
- name: Install Node.js and Bun
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue'
$tools = Join-Path ([IO.Path]::GetTempPath()) ("qqq-tools-" + [guid]::NewGuid())
New-Item -ItemType Directory -Path $tools | Out-Null
$nodeFile = "node-v$env:NODE_VERSION-win-x64.zip"
Invoke-WebRequest "https://nodejs.org/dist/v$env:NODE_VERSION/$nodeFile" -OutFile "$tools\$nodeFile"
Invoke-WebRequest "https://nodejs.org/dist/v$env:NODE_VERSION/SHASUMS256.txt" -OutFile "$tools\SHASUMS256.txt"
$want = ((Get-Content "$tools\SHASUMS256.txt") | Where-Object { $_ -match " $([regex]::Escape($nodeFile))$" }).Split(' ')[0]
if ((Get-FileHash "$tools\$nodeFile" -Algorithm SHA256).Hash -ne $want) { throw "$nodeFile checksum mismatch" }
Expand-Archive "$tools\$nodeFile" -DestinationPath $tools
Invoke-WebRequest "https://github.com/oven-sh/bun/releases/download/bun-v$env:BUN_VERSION/bun-windows-x64-baseline.zip" -OutFile "$tools\bun.zip"
if ((Get-FileHash "$tools\bun.zip" -Algorithm SHA256).Hash -ne $env:BUN_WINDOWS_SHA256) { throw "bun.zip checksum mismatch" }
Expand-Archive "$tools\bun.zip" -DestinationPath $tools
Add-Content $env:GITHUB_PATH "$tools\node-v$env:NODE_VERSION-win-x64"
Add-Content $env:GITHUB_PATH "$tools\bun-windows-x64-baseline"
Add-Content $env:GITHUB_ENV "TOOLS_DIR=$tools"
- name: Build, test, windows-x64 binary
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'; $PSNativeCommandUseErrorActionPreference = $true
node --version; bun --version
npm ci
npm run build
npm test
npm run lint
node scripts/build-binaries.mjs --no-build --host
pwsh -NoProfile -File scripts/verify-binary.ps1 -Binary dist\qqq-windows-x64.exe
- name: Attach to the draft release
id: attach
shell: pwsh
env:
TOKEN: ${{ github.token }}
RELEASE_ID: ${{ needs.linux.outputs.release-id }}
run: |
$ErrorActionPreference = 'Stop'; $PSNativeCommandUseErrorActionPreference = $true
curl.exe -fsS -o NUL -X POST -H "Authorization: token $env:TOKEN" -F "attachment=@dist/qqq-windows-x64.exe" `
"$env:GITHUB_SERVER_URL/api/v1/repos/$env:GITHUB_REPOSITORY/releases/$env:RELEASE_ID/assets?name=qqq-windows-x64.exe"
Write-Output "Attached qqq-windows-x64.exe"
$hash = (Get-FileHash dist\qqq-windows-x64.exe -Algorithm SHA256).Hash.ToLowerInvariant()
Add-Content $env:GITHUB_OUTPUT "sums=$hash qqq-windows-x64.exe;"
- name: Remove the tools
if: always()
shell: pwsh
run: if ($env:TOOLS_DIR) { Remove-Item -Recurse -Force $env:TOOLS_DIR -ErrorAction SilentlyContinue }
publish:
needs: [linux, macos, windows]
runs-on: container-build
timeout-minutes: 30
container:
image: node:22.23.4-bookworm
volumes:
- /var/lib/gitea-job-workspace:${{ github.workspace }}
env:
HOME: /tmp
CI: 'true'
TOKEN: ${{ github.token }}
PACKAGES_TOKEN: ${{ secrets.PACKAGES_TOKEN }}
RELEASE_ID: ${{ needs.linux.outputs.release-id }}
VERSION: ${{ needs.linux.outputs.version }}
steps:
- uses: builtin:checkout
- name: SHA256SUMS
shell: bash
env:
SUMS: ${{ needs.linux.outputs.sums }}${{ needs.macos.outputs.sums }}${{ needs.windows.outputs.sums }}
run: |
set -euo pipefail
git config --global --add safe.directory "$GITHUB_WORKSPACE"
mkdir -p /tmp/assets
tr ';' '\n' <<< "$SUMS" | sed '/^$/d' | sort -k2 > /tmp/assets/SHA256SUMS
cat /tmp/assets/SHA256SUMS
for want in qqq-linux-x64 qqq-linux-arm64 qqq-linux-x64-musl qqq-linux-arm64-musl qqq-darwin-x64 qqq-darwin-arm64 qqq-windows-x64.exe; do
grep -Eq "^[0-9a-f]{64} $want\$" /tmp/assets/SHA256SUMS || { echo "No checksum for $want"; exit 1; }
done
curl -fsS -o /dev/null -X POST -H "Authorization: token $TOKEN" -F "attachment=@/tmp/assets/SHA256SUMS" \
"$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=SHA256SUMS"
- name: Publish the release and check the downloads
shell: bash
run: |
set -euo pipefail
prerelease=false; [[ $VERSION == *-* ]] && prerelease=true
curl -fsS -o /dev/null -X PATCH -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
-d "{\"draft\":false,\"prerelease\":$prerelease}" "$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID"
# What users download (the formula, the manifest, install.sh) must match SHA256SUMS.
cd /tmp/assets
while read -r _ file; do
curl -fsSL -o "$file" "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/download/v$VERSION/$file"
done < SHA256SUMS
sha256sum --check --strict SHA256SUMS
echo "Released: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/v$VERSION"
- name: Publish the npm packages
shell: bash
run: |
set -euo pipefail
if [[ -z ${PACKAGES_TOKEN:-} ]]; then echo "PACKAGES_TOKEN is empty"; exit 1; fi
npm ci
npm run build
registry="$GITHUB_SERVER_URL/api/packages/$GITHUB_REPOSITORY_OWNER/npm/"
export NPM_CONFIG_USERCONFIG=/tmp/publish.npmrc
printf '//%s:_authToken=${PACKAGES_TOKEN}\n@qrun-io:registry=%s\n' "${registry#*://}" "$registry" > "$NPM_CONFIG_USERCONFIG"
# In dependency order. A version already in the registry is left alone (versions are immutable),
# so a release republishes nothing that did not change; each package's own version picks its dist-tag.
for dir in packages/ui-api-client packages/core packages/conformance packages/cli; do
name=$(node -p "require('./$dir/package.json').name")
version=$(node -p "require('./$dir/package.json').version")
if npm view "$name@$version" version --registry "$registry" > /dev/null 2>&1; then
echo "$name@$version is already published; skipped"; continue
fi
tag=latest; [[ $version == *-* ]] && tag=next
(cd "$dir" && npm publish --registry "$registry" --tag "$tag")
echo "Published $name@$version ($tag): $(npm view "$name@$version" version --registry "$registry")"
done
- name: Homebrew tap and Scoop bucket
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
+16
View File
@@ -86,6 +86,22 @@ All notable changes to the qqq CLI and its packages. Versions follow [semver](ht
position reports. They are rewritten on the input stream into `ESC [13;2~` outside the startup
cursor-query window.
## [1.0.1-rc.1] - 2026-11-08
Release candidate built and published by Gitea CI from the tag `v1.0.1-rc.1`, the first through the new
release path: `@qrun-io/qqq-cli` 1.0.1-rc.1, `@qrun-io/qqq-core` 1.0.1-rc.1 and
`@qrun-io/qqq-ui-api-client` 1.8.1-rc.1 (dist-tag `next`) with `@qrun-io/qqq-ui-api-conformance` 1.6.0 on
the QRun-IO Gitea npm registry, and the binaries with `SHA256SUMS` on the Gitea release. It carries the
changes listed under Unreleased above as of this tag.
### Changed
- **Releases move to Gitea.** A `v<CLI version>` tag runs `.gitea/workflows/release.yaml`: the
`scripts/release.mjs` gate, the Linux, macOS arm64 (native) and Windows x64 (native) binaries, the npm
packages on `https://git.kof22.com/api/packages/QRun-IO/npm/`, the Gitea release, and for final versions
the Homebrew tap and Scoop bucket. The `cli-v*` tags and GitHub releases are retired; `scripts/install.sh`,
the formula and the Scoop manifest download from `https://git.kof22.com/QRun-IO/qqq-cli/releases`.
## [1.0.0] - 2026-11-01
`qqq` 1.0: the command-line client and the full-screen terminal UI (`qqq ui`), shipped together as one
+6 -6
View File
@@ -2,31 +2,31 @@
# qqq: the QQQ command-line client and terminal UI (qqq ui) in one binary.
# Kept in qqq-cli at docs/packaging/homebrew-tap/Formula/qqq.rb; scripts/update-packaging.mjs fills
# version and sha256 from the SHA256SUMS of a GitHub release, then the file is copied to the tap.
# version and sha256 from the SHA256SUMS of a Gitea release; the release workflow then copies it to the tap.
class Qqq < Formula
desc "Command-line client and terminal UI for QQQ applications"
homepage "https://github.com/QRun-IO/qqq-cli"
homepage "https://git.kof22.com/QRun-IO/qqq-cli"
version "1.0.0"
license "Apache-2.0"
on_macos do
on_arm do
url "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v#{version}/qqq-darwin-arm64"
url "https://git.kof22.com/QRun-IO/qqq-cli/releases/download/v#{version}/qqq-darwin-arm64"
sha256 "5017a32ac5031d9146d541381099a959e5c19bbf04c48f725045d89d6007b211"
end
on_intel do
url "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v#{version}/qqq-darwin-x64"
url "https://git.kof22.com/QRun-IO/qqq-cli/releases/download/v#{version}/qqq-darwin-x64"
sha256 "eb9b1d43d24b639118d94b175c343080a39ae9ba64c24645c1792ff3b16a37f1"
end
end
on_linux do
on_arm do
url "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v#{version}/qqq-linux-arm64"
url "https://git.kof22.com/QRun-IO/qqq-cli/releases/download/v#{version}/qqq-linux-arm64"
sha256 "25f513bdda13bbe40cf536844eebc1e3a7e21cf7804024f25ebe7c42793d7ebc"
end
on_intel do
url "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v#{version}/qqq-linux-x64"
url "https://git.kof22.com/QRun-IO/qqq-cli/releases/download/v#{version}/qqq-linux-x64"
sha256 "804d06cb7c0012c2f185ae5d94c6bb8f9e2482a440bb3c165361f1a14c941e41"
end
end
+6 -5
View File
@@ -1,7 +1,7 @@
{
"version": "1.0.0",
"description": "Command-line client and terminal UI (qqq ui) for QQQ applications, in one binary",
"homepage": "https://github.com/QRun-IO/qqq-cli",
"homepage": "https://git.kof22.com/QRun-IO/qqq-cli",
"license": "Apache-2.0",
"notes": [
"qqq ui (the terminal UI) needs Windows Terminal or another ConPTY terminal; the legacy console is not supported.",
@@ -9,7 +9,7 @@
],
"architecture": {
"64bit": {
"url": "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v1.0.0/qqq-windows-x64.exe",
"url": "https://git.kof22.com/QRun-IO/qqq-cli/releases/download/v1.0.0/qqq-windows-x64.exe",
"hash": "cf418d7842a227cee412171878228004f9bc5098a272c64b89512d1e2d06a176"
}
},
@@ -20,13 +20,14 @@
]
],
"checkver": {
"github": "https://github.com/QRun-IO/qqq-cli",
"regex": "cli-v([\\d.]+(?:-rc\\.\\d+)?)"
"url": "https://git.kof22.com/api/v1/repos/QRun-IO/qqq-cli/releases/latest",
"jsonpath": "$.tag_name",
"regex": "v([\\d.]+)"
},
"autoupdate": {
"architecture": {
"64bit": {
"url": "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v$version/qqq-windows-x64.exe",
"url": "https://git.kof22.com/QRun-IO/qqq-cli/releases/download/v$version/qqq-windows-x64.exe",
"hash": {
"url": "$baseurl/SHA256SUMS"
}
+87 -199
View File
@@ -1,245 +1,133 @@
# Release runbook: qqq 1.0.0
# Release runbook
From "the branch is green" to "1.0.0 is published and installable everywhere". It is written for
1.0.0 but holds for any release; replace the versions. Nothing in the repository tags, pushes or
publishes by itself: `scripts/release.mjs` checks and prints, people run the commands.
From "main is green" to "qqq X.Y.Z is published and installable everywhere". A release is one tag,
`v<CLI version>`, on the merge commit; Gitea CI (`.gitea/workflows/release.yaml`) does the rest. Nothing
publishes from a branch.
> **2026-11-07:** CircleCI and the qqq-orb are retired. Gitea CI (`.gitea/workflows/build.yaml`) verifies
> only; the orb publish steps below (npm packages on a `v<client version>` tag, the platform lanes) have
> no replacement yet. How packages, binaries and the Homebrew/Scoop updates are released is an open
> decision; until then this runbook is the record of the old process.
> **Since 2026-11-08** releases run on Gitea. The CircleCI/qqq-orb path (npm packages on a
> `v<client version>` tag, binaries on a GitHub release `cli-v<version>`, hand-made tap and bucket pull
> requests) is retired. Older releases (1.0.0 and before) stay on GitHub.
What ships:
| Artifact | Version for 1.0 | Where | Made by |
|---|---|---|---|
| `qqq` binaries with the terminal UI, 7 targets, plus `SHA256SUMS` | 1.0.0 | GitHub release `cli-v1.0.0` | `scripts/build-binaries.mjs` (via `release.mjs`) |
| `@qrun-io/qqq-cli` (CLI only, Node 20+) | 1.0.0 | GitHub Packages | the orb, on tag `v1.8.0` |
| `@qrun-io/qqq-core` | 1.0.0 | GitHub Packages | the orb (with PR #165), else by hand |
| `@qrun-io/qqq-ui-api-client` | 1.8.0 | GitHub Packages | the orb |
| `@qrun-io/qqq-ui-api-conformance` | 1.6.0 | GitHub Packages | the orb |
| Artifact | Where | Made by |
|---|---|---|
| `qqq` binaries with the terminal UI, 7 targets, 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 |
**All four npm packages go final together.** The orb (PR #165) derives the dist-tag from all four
versions: if any of client, core, conformance or CLI has a prerelease part, all four are published as
`rc`; only when all four are final do they go out as `latest`. So 1.0.0 ships client 1.8.0 and
conformance 1.6.0 as finals beside CLI and core 1.0.0, under the tags `v1.8.0` (orb publish) and
`cli-v1.0.0` (GitHub release). `release.mjs` refuses a final CLI version while any of the four is still a
prerelease (the readiness gate's "package versions" line).
| Homebrew formula `qqq` | 1.0.0 | `QRun-IO/homebrew-tap` | `scripts/update-packaging.mjs`, then a PR |
| Scoop manifest `qqq` | 1.0.0 | `QRun-IO/scoop-bucket` | `scripts/update-packaging.mjs`, then a PR |
## 1. Versions
## Checklist: what needs James
Nobody else can do these; each says what happens without it.
- [x] **Orb changes released** in `kingsrook/qqq-orb` 0.8.33 (`docs/ci-lanes.md`): publishing
`@qrun-io/qqq-core`, lint, the `operations.ts` drift check, the dist-tag rule above (PR #165), and
the macOS arm64 and Windows x64 lanes, which `.qqq-ci.yml` turns on (`platforms:`) and a release tag
requires before publishing.
- [ ] **CircleCI plan** includes the `m4pro.medium` (macOS) and `windows.medium` resource classes. Without
them the platform lanes cannot start, and because a release tag requires them, the publish waits;
remove `platforms:` from `.qqq-ci.yml` to publish without them.
- [ ] **Apple Developer ID and notarization**: a "Developer ID Application" certificate and a notary
credential (App Store Connect API key) on the release machine. Without it the macOS binaries are
only ad-hoc signed. Homebrew and the curl installer are not affected (they do not set the
quarantine flag), but a binary downloaded with a browser is stopped by Gatekeeper ("Apple cannot
check it for malicious software"). Workaround, documented in the README and the packaging notes:
`xattr -d com.apple.quarantine ./qqq-darwin-arm64`, or Finder, right-click, Open.
- [ ] **Windows Authenticode certificate** (the KofTwentyTwo release standard). Without it SmartScreen
warns on first run ("Windows protected your PC", More info, Run anyway); Scoop installs still work.
- [x] **`QRun-IO/homebrew-tap` and `QRun-IO/scoop-bucket` exist** (created from `docs/packaging/homebrew-tap/` and
`docs/packaging/qqq.json`). After the GitHub release, run `scripts/update-packaging.mjs` on the uploaded
`SHA256SUMS` and commit the filled `Formula/qqq.rb` and `bucket/qqq.json` to them. Without that commit
`brew install` and `scoop install` fail their checksum (the curl installer and the download still work).
- [ ] **CircleCI context** `qqq-maven-registry-credentials` holds a `GITHUB_TOKEN` that can write the
four `@qrun-io` packages (the orb checks it authenticates; write access is only proven by a publish).
- [ ] **Go for 1.0.0 final**: the 1.0.0-rc.1 feedback is accepted and no blocker is open.
## Not verified before 1.0
The platform lanes run the Node packages' build, tests and lint on macOS arm64 and Windows x64 (Node
20), which covers the keyring code paths there, but they do not build or run the binaries.
- `qqq-windows-x64.exe` (CLI and UI) has never run; `scripts/verify-binary.ps1` is the smoke to run on
a Windows machine.
- `qqq-darwin-x64` has never run (no Intel Mac or Rosetta on the build machine).
- Windows Terminal rendering of `qqq ui`, and the DPAPI keyring outside unit tests.
Verified on the build machine: darwin-arm64 (CLI, keyring, completion, `qqq ui` tmux smoke and width
smoke against the sample server), linux-x64 glibc, linux-arm64 glibc and linux-arm64-musl in Docker
(CLI and `qqq ui` in tmux), the npm tarballs installed on Node 20, the Homebrew formula in the
`homebrew/brew` container, and the curl installer from a local mirror.
## 1. Branch green
- `feat/m5-cli-release` (and whatever else is in the release) is merged to `main`; the orb pipeline on
`main` is green: `cli_verify_publish` (Linux, `cimg/node:22.23`) and the `cli-macos-arm64` and
`cli-windows-x64` lanes.
- Locally on `main`, clean tree: `npm ci && npm run build && npm test && npm run lint && npm run test:tui`.
## 2. Versions: release candidate to final
**For 1.0.0 this step is done** on the branch `release/1.0.0` (34bbd2a, on top of the rc.1 commit
6517373): all four packages final, the exact workspace pins, the `## [1.0.0]` CHANGELOG entry,
`docs/release-notes/1.0.0.md`, and the READMEs at 1.0.0. That branch (with the release docs merged into
it) is fast-forwarded onto `main`; nothing else changes versions:
```sh
git checkout main && git pull --ff-only
git merge --ff-only release/1.0.0 # fails if main moved: rebase release/1.0.0 onto main first
git push origin main
```
For later releases, the table below is the recipe.
On a branch `release/1.0.0` from `main`:
On a branch from `main`:
| File | Change |
|---|---|
| `packages/cli/package.json`, `packages/cli/src/version.ts` (`CLI_VERSION`) | `1.0.0-rc.N` → `1.0.0` |
| `packages/core/package.json` | `1.0.0-rc.N` → `1.0.0` |
| `packages/ui-api-client/package.json` | `1.8.0-rc.N` → `1.8.0` (the client follows the UI API version) |
| `packages/conformance/package.json` | `1.6.0-rc.N` → `1.6.0` |
| `packages/cli/package.json`, `packages/cli/src/version.ts` (`CLI_VERSION`) | the CLI version, e.g. `1.1.0-rc.1` or `1.1.0` |
| `packages/core/package.json`, `packages/conformance/package.json` | bump any package whose content changed |
| `packages/ui-api-client/package.json` | follows the UI API version of `openapi/qqq-v1.json` |
| workspace dependencies in `packages/cli` and `packages/core` | the same versions, exact |
| `CHANGELOG.md` | a `## [1.0.0] - <date>` entry above the rc entries |
| `README.md`, `packages/cli/README.md` | the version and `@rc` in the npm command (`@latest`, or no tag) |
| `CHANGELOG.md` | a `## [X.Y.Z] - <date>` entry (it becomes the release notes; `docs/release-notes/X.Y.Z.md` wins if present) |
All four packages move to finals in the same commit; none may stay `-rc` (the orb would then publish all
four as `rc`, and `release.mjs` refuses). Then `npm install` (refreshes `package-lock.json`),
`npm run lint`, which fails if any version disagrees, and `npm run release -- --check`, which must say
READY (parity 100% in both columns, every finding filed, the CHANGELOG entry, documented commands,
package versions). Commit (`git commit -S -m "release: 1.0.0"`), merge to `main` through a pull request.
Then `npm install` (refreshes `package-lock.json`), `npm run lint` (fails if any version disagrees) and
`node scripts/release.mjs` (the same gate CI runs; add `--skip-binaries` to save time). A final version
must pass the readiness gate (`npm run release -- --check`: parity, findings filed, CHANGELOG entry,
documented commands, all four packages final); a prerelease is not gated. Merge through a pull request.
## 3. Release check and binaries
Each package version is published once. The publish job skips a package whose exact version is already
in the registry (unchanged packages ride along), so bump every package whose content changed.
On `main` at the release commit, clean tree:
## 2. Tag
```sh
node scripts/release.mjs # the readiness gate (final versions), npm ci, generate drift, build, test,
# lint, TUI tests and type check, pack dry runs, the 7 binaries with the UI
# and dist/SHA256SUMS
scripts/verify-binary.sh dist/qqq-darwin-arm64 --server http://localhost:8077 # sample server running
git checkout main && git pull --ff-only
git tag -s v1.1.0 -m "qqq 1.1.0" && git push origin v1.1.0
```
`release.mjs` prints the commands for steps 5 and 6 with the right versions.
Or create the tag on the merge commit through the Gitea API (`POST /repos/QRun-IO/qqq-cli/tags`). The tag
must equal `packages/cli/package.json` or the release fails at once. A version with a `-` (`-rc.N`) is a
prerelease: npm dist-tag `next` for prerelease packages, a Gitea prerelease, and no tap or bucket update.
## 4. Sign (James's credentials)
## 3. What CI does
Signing changes the files, so it comes before the checksums are final. Each script reads its
credentials from the environment, does nothing (and says what is missing) without them, has
`--dry-run`, and recomputes `dist/SHA256SUMS`.
`.gitea/workflows/release.yaml`, in order; any failure stops it:
1. **linux** (`container-build`, `node:22.23.4-bookworm`, Bun 1.4.4 checksum-pinned):
`scripts/release.mjs --skip-binaries` (npm ci, generate drift, build, readiness for finals, tests, lint,
TUI tests and type check, pack dry runs), then the linux-x64, linux-arm64, both musl and darwin-x64
binaries (`scripts/build-binaries.mjs`), the `scripts/verify-binary.sh` smoke on linux-x64, and a
**draft** Gitea release with those binaries. An existing release for the tag fails the run.
2. **macos** (`qqq-macos-arm64`) and **windows** (`qqq-windows-vm6218`), in parallel: pinned Node.js
(`SHASUMS256.txt`) and Bun (pinned sha256), `npm ci`, build, test, lint, the native binary
(`--host`), the `verify-binary.sh` / `verify-binary.ps1` smoke, and the upload to the draft.
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).
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>`.
## 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
pull request on each:
```sh
QQQ_MACOS_SIGN_IDENTITY="Developer ID Application: <name> (<team>)" QQQ_NOTARY_PROFILE=qqq-notary \
scripts/sign-macos.sh # codesign, hardened runtime + scripts/macos-entitlements.plist, notarytool --wait
QQQ_WINDOWS_PFX=<cert.pfx> QQQ_WINDOWS_PFX_PASSWORD_FILE=<file> \
scripts/sign-windows.sh # osslsigncode with an RFC 3161 timestamp
scripts/verify-binary.sh dist/qqq-darwin-arm64 --server http://localhost:8077 # still runs after signing
spctl --assess --type execute -vv dist/qqq-darwin-arm64 # "accepted, source=Notarized Developer ID"
curl -fsSLO https://git.kof22.com/QRun-IO/qqq-cli/releases/download/v1.1.0/qqq.rb
curl -fsSLO https://git.kof22.com/QRun-IO/qqq-cli/releases/download/v1.1.0/qqq.json
# QRun-IO/homebrew-tap: Formula/qqq.rb QRun-IO/scoop-bucket: bucket/qqq.json
```
A bare executable cannot be stapled: Gatekeeper fetches the notarization ticket online the first time a
quarantined (browser-downloaded) copy runs, and refuses offline until it can. Homebrew and the curl
installer do not quarantine, so they work signed or not. Tried here without real credentials: an ad-hoc
hardened-runtime signature with the entitlements (the CLI and the `qqq ui` smoke still pass), and an
osslsigncode signature with a throwaway certificate that verifies. A real Developer ID, notarization
and an Authenticode certificate are untried until James's credentials are used.
Compare each sum with the release's `SHA256SUMS`. A wrong sum makes `brew install` and `scoop install`
fail their checksum; the curl installer and direct downloads are unaffected.
## 5. Tags and packages
## 5. Signing (not automated)
```sh
git tag -s v1.8.0 -m "UI API client 1.8.0, qqq 1.0.0" # the orb publishes on v<client version>
git tag -s cli-v1.0.0 -m "qqq 1.0.0" # the GitHub release with the binaries
git push origin v1.8.0 cli-v1.0.0
```
The binaries are not signed by CI: there is no Apple Developer ID / notary credential or Authenticode
certificate on the runners. Homebrew and the curl installer do not quarantine, so they work unsigned; a
browser-downloaded macOS binary needs `xattr -d com.apple.quarantine ./qqq-darwin-arm64`, and Windows
SmartScreen warns on first run. `scripts/sign-macos.sh` and `scripts/sign-windows.sh` remain for a manual
signed release (they recompute `dist/SHA256SUMS`); wiring them into the native jobs needs the credentials
as runner-side secrets (owner decision).
The tag `v1.8.0` makes the orb's `cli` profile verify the commit again and publish client, core,
conformance and CLI to GitHub Packages, all as `latest` because all four are final. Watch the
`publish_release` workflow. Only if the orb release with PR #165 is not out yet, finish by hand:
Gitea has no equivalent of GitHub's artifact attestations; `SHA256SUMS` on the release and the signed tag
are the provenance.
```sh
npm publish -w @qrun-io/qqq-core --registry https://npm.pkg.github.com --tag latest
for p in qqq-ui-api-client qqq-core qqq-ui-api-conformance qqq-cli; do
npm dist-tag add "@qrun-io/$p@<its version>" latest --registry https://npm.pkg.github.com
done
```
A version can never be published twice: if the orb job fails after publishing some packages, bump the
patch version of the rest instead of retrying the same one.
## 6. GitHub release
```sh
gh release create cli-v1.0.0 --repo QRun-IO/qqq-cli --latest --title "qqq 1.0.0" \
--notes-file <(sed -n '/^## \[1\.0\.0\]/,/^## \[/p' CHANGELOG.md | sed '$d') \
dist/qqq-* dist/SHA256SUMS
```
`dist/qqq-*` is the seven binaries (source maps are in `dist/sourcemaps/`, not uploaded). For a release
candidate use `--prerelease` instead of `--latest`. The curl installer takes the newest stable `cli-v*`
release (the channel Homebrew and Scoop follow); a release candidate is reached with `--pre` or
`--version 1.x.y-rc.N`.
## 7. Homebrew tap and Scoop bucket
```sh
gh release download cli-v1.0.0 --repo QRun-IO/qqq-cli -p SHA256SUMS -D /tmp/qqq-1.0.0
node scripts/update-packaging.mjs /tmp/qqq-1.0.0/SHA256SUMS
git diff docs/packaging # every sha256 differs from the placeholder, none is all zeros
git commit -S -am "packaging: qqq 1.0.0" && git push # in qqq-cli, through a pull request
```
The script exits 1 and writes nothing if `SHA256SUMS` lacks a target or has a malformed sum. Compare each sum
in the diff with the release's `SHA256SUMS` by eye before committing: the tap's CI and the bucket's manifest
tests catch a wrong sum (the download will not match) only when someone opens those pull requests and reads
their checks.
Then copy `docs/packaging/homebrew-tap/Formula/qqq.rb` to `Formula/qqq.rb` in `QRun-IO/homebrew-tap`
and `docs/packaging/qqq.json` to `bucket/qqq.json` in `QRun-IO/scoop-bucket`, a pull request on each. The tap's
CI fetches every platform's download, installs the formula and runs its test (which insists on
`binary with ui`). Scoop's `checkver`/`autoupdate` can take later versions from `SHA256SUMS` by itself.
## 8. Smoke after publishing
## 6. Smoke after publishing
Against a real server (`--server`), from clean machines or containers:
```sh
# curl installer (macOS, Linux)
curl -fsSL https://raw.githubusercontent.com/QRun-IO/qqq-cli/main/scripts/install.sh | sh -s -- --version 1.0.0
curl -fsSL https://git.kof22.com/QRun-IO/qqq-cli/raw/branch/main/scripts/install.sh | sh -s -- --version 1.1.0
scripts/verify-binary.sh ~/.local/bin/qqq --server https://<server>
# Homebrew
brew install qrun-io/tap/qqq && brew test qrun-io/tap/qqq && scripts/verify-binary.sh "$(brew --prefix)/bin/qqq"
brew install qrun-io/tap/qqq && brew test qrun-io/tap/qqq
# npm, Node 20
echo "@qrun-io:registry=https://npm.pkg.github.com" >> ~/.npmrc
npm install -g @qrun-io/qqq-cli@1.0.0 && scripts/verify-binary.sh qqq --cli-only --server https://<server>
# Docker (glibc and musl)
docker run --rm --platform linux/amd64 -v "$PWD/dist:/d:ro" -v "$PWD/scripts:/s:ro" debian:bookworm-slim bash /s/verify-binary.sh /d/qqq-linux-x64
# npm, Node 20+
echo "@qrun-io:registry=https://git.kof22.com/api/packages/QRun-IO/npm/" >> ~/.npmrc
npm install -g @qrun-io/qqq-cli@1.1.0 && scripts/verify-binary.sh qqq --cli-only --server https://<server>
```
```powershell
# Windows, PowerShell
scoop bucket add qrun https://github.com/QRun-IO/scoop-bucket; scoop install qqq
scoop bucket add qrun https://git.kof22.com/QRun-IO/scoop-bucket; scoop install qqq
pwsh scripts\verify-binary.ps1 -Binary (Get-Command qqq).Source -Server https://<server>
```
And by hand once: download `qqq-darwin-arm64` with a browser on a Mac and run it (notarized: it runs;
not notarized: the Gatekeeper prompt, cleared by `xattr -d com.apple.quarantine`), then `qqq ui` in
Windows Terminal.
## 9. After
- Merge `main` back into the development branch; the next development version can stay at 1.0.0 until
the next bump (lint keeps the versions consistent either way).
- Announce with the release link and the install commands from the README.
Users who added the tap or the bucket from GitHub re-add them from Gitea
(`brew tap qrun-io/tap https://git.kof22.com/QRun-IO/homebrew-tap`).
## When something goes wrong
- **A package is broken**: `npm deprecate @qrun-io/<pkg>@<version> "<reason>"` and publish a patch; a
version cannot be unpublished from GitHub Packages without breaking installs that pinned it.
- **A binary is broken**: delete the asset from the GitHub release, rebuild, re-sign, upload, recompute
`SHA256SUMS` and rerun step 7 (the formula and manifest carry the sums).
- **The tap's CI fails on the new formula**: do not merge its pull request; `brew install` keeps
serving the previous version.
- **The run fails before the publish job**: nothing is public. Delete the draft release (and the tag, if
the version must change), fix, and tag again.
- **A package is broken**: `npm deprecate @qrun-io/<pkg>@<version> "<reason>"` against the Gitea registry
and publish a new version; a version is never republished.
- **A binary is broken**: delete the release, bump the version and release again; the formula and the
manifest carry the sums, so never replace an asset in place.
- **The tap's or bucket's check fails on the new file**: do not merge; users keep the previous version.
+6 -6
View File
@@ -2954,11 +2954,11 @@
},
"packages/cli": {
"name": "@qrun-io/qqq-cli",
"version": "1.0.0",
"version": "1.0.1-rc.1",
"license": "Apache-2.0",
"dependencies": {
"@qrun-io/qqq-core": "1.0.0",
"@qrun-io/qqq-ui-api-client": "1.8.0",
"@qrun-io/qqq-core": "1.0.1-rc.1",
"@qrun-io/qqq-ui-api-client": "1.8.1-rc.1",
"@qrun-io/qqq-ui-api-conformance": "1.6.0",
"commander": "15.0.0"
},
@@ -2986,10 +2986,10 @@
},
"packages/core": {
"name": "@qrun-io/qqq-core",
"version": "1.0.0",
"version": "1.0.1-rc.1",
"license": "Apache-2.0",
"dependencies": {
"@qrun-io/qqq-ui-api-client": "1.8.0",
"@qrun-io/qqq-ui-api-client": "1.8.1-rc.1",
"jmespath": "0.16.0",
"yaml": "2.9.1"
},
@@ -3002,7 +3002,7 @@
},
"packages/ui-api-client": {
"name": "@qrun-io/qqq-ui-api-client",
"version": "1.8.0",
"version": "1.8.1-rc.1",
"license": "Apache-2.0",
"dependencies": {
"openapi-fetch": "0.17.0"
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@qrun-io/qqq-cli",
"version": "1.0.0",
"version": "1.0.1-rc.1",
"description": "Reference command-line client for QQQ applications, built only on the QQQ UI API (/qqq/v1).",
"keywords": [
"qqq",
@@ -50,7 +50,7 @@
"node": ">=20"
},
"publishConfig": {
"registry": "https://npm.pkg.github.com"
"registry": "https://git.kof22.com/api/packages/QRun-IO/npm/"
},
"scripts": {
"build": "tsc -p tsconfig.json",
@@ -58,8 +58,8 @@
"prepack": "node ../../scripts/copy-license.mjs"
},
"dependencies": {
"@qrun-io/qqq-core": "1.0.0",
"@qrun-io/qqq-ui-api-client": "1.8.0",
"@qrun-io/qqq-core": "1.0.1-rc.1",
"@qrun-io/qqq-ui-api-client": "1.8.1-rc.1",
"@qrun-io/qqq-ui-api-conformance": "1.6.0",
"commander": "15.0.0"
}
+2 -1
View File
@@ -18,6 +18,7 @@ import { mkdtempSync, readFileSync, readdirSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { runCli, sandbox, startFakeServer, type FakeServer } from './harness.js'
import { CLI_VERSION } from '../version.js'
type J = Record<string, unknown>
@@ -266,7 +267,7 @@ describe('qqq print and qqq printer', () => {
assert.deepEqual(filter.orderBys, [{ fieldName: 'id', isAscending: false }])
assert.equal(run.values.filterDescription, 'lastName EQUALS Odd')
// the app header and footer
assert.match(String(run.values.footerLeft), /^qqq \d+\.\d+\.\d+ · 127\.0\.0\.1:\d+$/)
assert.equal(String(run.values.footerLeft).replace(/:\d+$/, ':PORT'), `qqq ${CLI_VERSION} · 127.0.0.1:PORT`)
assert.match(String(run.values.headerServer), /^127\.0\.0\.1:\d+$/)
assert.equal(run.values.headerTitle, 'Person')
assert.match(String(run.values.headerPrintedAt), /\d/)
+1 -1
View File
@@ -18,7 +18,7 @@ declare const QQQ_BUILD_SHA: string | undefined
declare const QQQ_BUILD_TARGET: string | undefined
declare const QQQ_BUILD_UI: boolean | undefined
export const CLI_VERSION = '1.0.0'
export const CLI_VERSION = '1.0.1-rc.1'
/** The oldest UI API a server may serve for this CLI (operationIds, clientCapabilities and traceId since 1.6.0). */
export const UI_API_MIN_VERSION = '1.6.0'
+1 -1
View File
@@ -39,7 +39,7 @@
"node": ">=20"
},
"publishConfig": {
"registry": "https://npm.pkg.github.com"
"registry": "https://git.kof22.com/api/packages/QRun-IO/npm/"
},
"scripts": {
"build": "tsc -p tsconfig.json",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@qrun-io/qqq-core",
"version": "1.0.0",
"version": "1.0.1-rc.1",
"description": "Client kernel for QQQ terminal clients: sessions, metadata, filter grammar, records, processes, jobs, render model and output formats. No terminal code.",
"keywords": [
"qqq",
@@ -42,7 +42,7 @@
"node": ">=20"
},
"publishConfig": {
"registry": "https://npm.pkg.github.com"
"registry": "https://git.kof22.com/api/packages/QRun-IO/npm/"
},
"scripts": {
"build": "tsc -p tsconfig.json",
@@ -50,7 +50,7 @@
"prepack": "node ../../scripts/copy-license.mjs"
},
"dependencies": {
"@qrun-io/qqq-ui-api-client": "1.8.0",
"@qrun-io/qqq-ui-api-client": "1.8.1-rc.1",
"jmespath": "0.16.0",
"yaml": "2.9.1"
},
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@qrun-io/qqq-ui-api-client",
"version": "1.8.0",
"version": "1.8.1-rc.1",
"description": "Typed TypeScript client for the QQQ UI API (/qqq/v1), generated from the merged OpenAPI document (core + extensions), with OAuth2 device-flow sign-in, token refresh and a realtime (SSE) reader.",
"keywords": [
"qqq",
@@ -47,7 +47,7 @@
"node": ">=20"
},
"publishConfig": {
"registry": "https://npm.pkg.github.com"
"registry": "https://git.kof22.com/api/packages/QRun-IO/npm/"
},
"scripts": {
"generate": "openapi-typescript openapi/qqq-v1.json --output src/schema.ts && node ../../scripts/fix-recursive-types.mjs src/schema.ts",
+20 -20
View File
@@ -2,13 +2,13 @@
# Copyright 2026 QRun.IO, Inc.
# Licensed under the Apache License, Version 2.0 (see LICENSE).
#
# Installs the qqq CLI single-file binary for this machine from the GitHub releases of
# QRun-IO/qqq-cli, after checking it against the release's SHA256SUMS.
# Installs the qqq CLI single-file binary for this machine from the Gitea releases of
# QRun-IO/qqq-cli (git.kof22.com), after checking it against the release's SHA256SUMS.
#
# curl -fsSL https://raw.githubusercontent.com/QRun-IO/qqq-cli/main/scripts/install.sh | sh
# curl -fsSL https://git.kof22.com/QRun-IO/qqq-cli/raw/branch/main/scripts/install.sh | sh
# curl -fsSL .../install.sh | sh -s -- --version 1.0.0 --dir /usr/local/bin
#
# By default it installs the newest stable cli-v* release (the channel Homebrew and Scoop follow);
# By default it installs the newest stable v* release (the channel Homebrew and Scoop follow);
# --pre takes the newest release including prereleases. --help lists the options.
set -eu
@@ -16,31 +16,31 @@ set -eu
repo="QRun-IO/qqq-cli"
version="${QQQ_VERSION:-}"
dir="${QQQ_INSTALL_DIR:-$HOME/.local/bin}"
base="${QQQ_DOWNLOAD_BASE:-https://github.com/$repo/releases/download}"
base="${QQQ_DOWNLOAD_BASE:-https://git.kof22.com/$repo/releases/download}"
musl=""
pre=""
dry_run=""
releases_url="${QQQ_RELEASES_URL:-https://api.github.com/repos/$repo/releases?per_page=30}"
releases_url="${QQQ_RELEASES_URL:-https://git.kof22.com/api/v1/repos/$repo/releases?limit=30}"
usage() {
cat <<'USAGE'
Install qqq (the CLI and the terminal UI, one binary) from https://github.com/QRun-IO/qqq-cli/releases
Install qqq (the CLI and the terminal UI, one binary) from https://git.kof22.com/QRun-IO/qqq-cli/releases
curl -fsSL https://raw.githubusercontent.com/QRun-IO/qqq-cli/main/scripts/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/QRun-IO/qqq-cli/main/scripts/install.sh | sh -s -- [options]
curl -fsSL https://git.kof22.com/QRun-IO/qqq-cli/raw/branch/main/scripts/install.sh | sh
curl -fsSL https://git.kof22.com/QRun-IO/qqq-cli/raw/branch/main/scripts/install.sh | sh -s -- [options]
Options (or environment):
--version V (QQQ_VERSION) a release such as 1.0.0; default: the newest stable cli-v* release
--version V (QQQ_VERSION) a release such as 1.0.0; default: the newest stable v* release
--pre (QQQ_PRE=1) with no --version, take the newest release including prereleases
--dir D (QQQ_INSTALL_DIR) where qqq goes; default: ~/.local/bin
--musl take the musl build on Linux (detected on Alpine and other musl hosts)
--dry-run print the version and download URL, change nothing
-h, --help this help
QQQ_DOWNLOAD_BASE release download root (default https://github.com/QRun-IO/qqq-cli/releases/download),
QQQ_DOWNLOAD_BASE release download root (default https://git.kof22.com/QRun-IO/qqq-cli/releases/download),
for mirrors and testing
Supported: macOS arm64 and x64, Linux x64 and arm64 (glibc and musl). On Windows use Scoop:
scoop bucket add qrun https://github.com/QRun-IO/scoop-bucket
scoop bucket add qrun https://git.kof22.com/QRun-IO/scoop-bucket
scoop install qqq
USAGE
}
@@ -73,7 +73,7 @@ fi
case "$(uname -s)" in
Darwin) os=darwin ;;
Linux) os=linux ;;
MINGW*|MSYS*|CYGWIN*) die "on Windows install with Scoop (scoop bucket add qrun https://github.com/QRun-IO/scoop-bucket; scoop install qqq) or download qqq-windows-x64.exe from https://github.com/$repo/releases" ;;
MINGW*|MSYS*|CYGWIN*) die "on Windows install with Scoop (scoop bucket add qrun https://git.kof22.com/QRun-IO/scoop-bucket; scoop install qqq) or download qqq-windows-x64.exe from https://git.kof22.com/$repo/releases" ;;
*) die "unsupported operating system $(uname -s)" ;;
esac
case "$(uname -m)" in
@@ -91,26 +91,26 @@ fi
[ -n "${QQQ_PRE:-}" ] && [ "${QQQ_PRE}" != 0 ] && pre=1
if [ -z "$version" ]; then
# The releases API lists newest first; each release's "tag_name" comes before its "prerelease".
# The Gitea releases API lists newest first; each release's "tag_name" comes before its "prerelease".
# Escaped quotes inside release notes never match these patterns.
version="$(fetch_text "$releases_url" | tr ',' '\n' | awk -v pre="$pre" '
/"tag_name": *"/ { tag = $0; sub(/.*"tag_name": *"/, "", tag); sub(/".*/, "", tag); next }
/"prerelease": *(true|false)/ {
if (tag ~ /^cli-v/ && (pre != "" || $0 ~ /false/)) { sub(/^cli-v/, "", tag); print tag; exit }
if (tag ~ /^v[0-9]/ && (pre != "" || $0 ~ /false/)) { sub(/^v/, "", tag); print tag; exit }
tag = ""
}')" || true
if [ -z "$version" ]; then
if [ -n "$pre" ]; then die "could not find a cli-v* release of $repo; pass --version"; fi
die "could not find a stable cli-v* release of $repo; pass --version, or --pre for prereleases"
if [ -n "$pre" ]; then die "could not find a v* release of $repo; pass --version"; fi
die "could not find a stable v* release of $repo; pass --version, or --pre for prereleases"
fi
fi
version="${version#v}"
version="${version#cli-v}"
file="qqq-$os-$arch$musl"
url="$base/cli-v$version/$file"
url="$base/v$version/$file"
if [ -n "$dry_run" ]; then
say "qqq $version: $url (checked against $base/cli-v$version/SHA256SUMS), into $dir/qqq"
say "qqq $version: $url (checked against $base/v$version/SHA256SUMS), into $dir/qqq"
exit 0
fi
tmp="$(mktemp -d 2>/dev/null || mktemp -d -t qqq)"
@@ -119,7 +119,7 @@ trap 'exit 130' INT TERM
say "Downloading qqq $version ($file)"
fetch "$url" "$tmp/$file" || die "download failed: $url"
fetch "$base/cli-v$version/SHA256SUMS" "$tmp/SHA256SUMS" || die "download failed: $base/cli-v$version/SHA256SUMS"
fetch "$base/v$version/SHA256SUMS" "$tmp/SHA256SUMS" || die "download failed: $base/v$version/SHA256SUMS"
expected="$(awk -v f="$file" '$2 == f { print $1 }' "$tmp/SHA256SUMS")"
[ -n "$expected" ] || die "SHA256SUMS has no entry for $file"
+18 -44
View File
@@ -5,18 +5,20 @@
*/
/**
* Release check for the CLI packages. Runs everything a release needs locally and then prints the
* commands that tag and publish it. It never tags, pushes or publishes.
* Release check for the CLI packages. Runs everything a release needs and then prints how to tag it.
* It never tags, pushes or publishes; the release workflow (.gitea/workflows/release.yaml) runs it as
* its gate on the tag and then publishes.
*
* 1. the working tree is clean (no staged, unstaged or untracked changes) and the tags are free
* 2. npm ci, then the steps the qqq-orb `cli` profile runs: npm run generate with no drift in
* 1. the working tree is clean (no staged, unstaged or untracked changes) and the tag v<CLI version>
* is free, or already points at this commit (the release workflow runs on the tag)
* 2. npm ci, then the CI steps: npm run generate with no drift in
* packages/ui-api-client/src/schema.ts or packages/core/src/api/operations.ts, build, test;
* plus npm run lint (versions, headers) and the TUI's Bun tests and type check (npm run test:tui,
* typecheck:tui; the TUI ships inside the binaries)
* 3. npm pack --dry-run for each published package
* 4. the single-file binaries with the terminal UI (scripts/build-binaries.mjs; --cli-only leaves it
* out) and dist/SHA256SUMS
* 5. prints the tag, GitHub release and npm publish commands
* 5. prints how to tag the release
*
* A final (non-prerelease) version also has to pass the readiness gate (scripts/readiness.mjs: parity
* 100% in both columns, every server finding filed, a CHANGELOG entry, documented commands exist);
@@ -40,11 +42,6 @@ const shell = process.platform === 'win32'
/** Published packages, in publish order (each depends only on the ones before it). */
const PUBLISHED = ['packages/ui-api-client', 'packages/core', 'packages/conformance', 'packages/cli']
/** A literal text as a sed basic regular expression (its metacharacters and the `/` delimiter escaped). */
function sedPattern(text) {
return text.replace(/[\\/.*[\]^$]/g, '\\$&')
}
function fail(message) {
console.error(`\nrelease: ${message}`)
process.exit(1)
@@ -62,10 +59,8 @@ function step(title, command, args) {
const pkg = (dir) => JSON.parse(readFileSync(join(root, dir, 'package.json'), 'utf8'))
const versions = Object.fromEntries(PUBLISHED.map((dir) => [dir, pkg(dir)]))
const client = versions['packages/ui-api-client']
const cli = versions['packages/cli']
const orbTag = `v${client.version}`
const cliTag = `cli-v${cli.version}`
const tag = `v${cli.version}`
if (argv.has('--check')) {
if (!existsSync(join(root, 'packages/cli/dist/program.js'))) step('build', 'npm', ['run', 'build'])
@@ -79,8 +74,8 @@ for (const { name, version } of Object.values(versions)) console.log(` ${name.p
const status = git('status', '--porcelain')
if (status && !argv.has('--allow-dirty')) fail(`the working tree is not clean:\n${status}\n(commit or stash first; --allow-dirty for a trial run)`)
for (const tag of [orbTag, cliTag]) {
if (git('tag', '--list', tag)) fail(`tag ${tag} already exists; bump the versions first`)
if (git('tag', '--list', tag) && !git('tag', '--points-at', 'HEAD').split(/\s+/).includes(tag)) {
fail(`tag ${tag} already exists on another commit; bump the versions first`)
}
const sha = git('rev-parse', '--short=12', 'HEAD')
const branch = git('rev-parse', '--abbrev-ref', 'HEAD')
@@ -102,42 +97,21 @@ step('TUI type check', 'npm', ['run', 'typecheck:tui'])
for (const dir of PUBLISHED) step(`pack ${versions[dir].name}`, 'npm', ['pack', '--dry-run', '--silent', '-w', versions[dir].name])
if (!argv.has('--skip-binaries')) step('binaries', 'node', ['scripts/build-binaries.mjs', '--no-build', '--musl', ...(argv.has('--cli-only') ? ['--cli-only'] : [])])
const registry = 'https://npm.pkg.github.com'
// the orb publishes all four packages as rc when any of them is a prerelease, and as latest only when
// all four are final; the GitHub release follows the CLI version
const prerelease = Object.values(versions).some((p) => p.version.includes('-'))
const distTag = prerelease ? 'rc' : 'latest'
const releaseKind = prerelease ? '--prerelease' : '--latest'
console.log(`
All checks passed at ${branch}@${sha}. Nothing was tagged or published. To release:
All checks passed at ${branch}@${sha}. Nothing was tagged or published. To release, with this commit
merged to main, tag the merge commit and push the tag:
# 1. tag (signed) and push. ${orbTag} makes the qqq-orb cli profile publish the client, conformance
# and CLI packages to GitHub Packages with dist-tag ${distTag} (the orb checks ${orbTag} = client version).${prerelease ? '' : '\n # All four versions are final, so the orb publishes them as latest (qqq-orb with PR #165).'}
git tag -s ${orbTag} -m "UI API client ${client.version}, qqq-cli ${cli.version}"
git tag -s ${cliTag} -m "qqq-cli ${cli.version}"
git push origin ${orbTag} ${cliTag}
git tag -s ${tag} -m "qqq ${cli.version}" && git push origin ${tag}
# 2. until the orb release with PR #165, the orb does not publish @qrun-io/qqq-core, and the CLI
# depends on it: publish it by hand right after the orb job, or the CLI cannot be installed.
npm publish -w ${versions['packages/core'].name} --registry ${registry} --tag ${distTag}
# without the orb (all four, in dependency order):
${PUBLISHED.map((dir) => ` npm publish -w ${versions[dir].name} --registry ${registry} --tag ${distTag}`).join('\n')}
# 3. the binaries with their checksums (${prerelease ? "a prerelease" : "the latest release"})
gh release create ${cliTag} --repo QRun-IO/qqq-cli ${releaseKind} --title "qqq ${cli.version}" \\
--notes-file ${existsSync(join(root, `docs/release-notes/${cli.version}.md`)) ? `docs/release-notes/${cli.version}.md` : `<(sed -n '/^## \\[${sedPattern(cli.version)}\\]/,/^## \\[/p' CHANGELOG.md | sed '$d')`} \\
dist/qqq-* dist/SHA256SUMS
# 4. node scripts/update-packaging.mjs <SHA256SUMS of the GitHub release>, then copy
# docs/packaging/homebrew-tap/Formula/qqq.rb to the tap and docs/packaging/qqq.json to the Scoop
# bucket (docs/release-runbook.md steps 7 and 8). With the certificates, sign the macOS and Windows
# binaries and recompute dist/SHA256SUMS before step 3 (runbook step 4).
The release workflow (.gitea/workflows/release.yaml) then reruns this check, builds the binaries (Linux
targets in the container, macOS arm64 and Windows x64 on native runners), publishes the four packages to
the QRun-IO Gitea npm registry with dist-tag ${prerelease ? 'next' : 'latest'}, creates the Gitea ${prerelease ? 'prerelease' : 'release'} ${tag} with
the binaries and SHA256SUMS${prerelease ? '' : ', and updates the Homebrew tap and the Scoop bucket'} (docs/release-runbook.md).
`)
if (!report.ready) {
console.log('Readiness for a final release (not required for this prerelease):')
printReport(report, { limit: 5 })
console.log('')
}
const conformance = versions['packages/conformance']
console.log(`Note: the orb also publishes ${conformance.name} ${conformance.version} on ${orbTag}; bump it first if that version is already in the registry.`)
console.log('Note: every package version must be new in the registry; bump any that is already published.')
+6 -3
View File
@@ -7,8 +7,9 @@
/**
* Fills the version and sha256 values of the packaging files (docs/packaging/homebrew-tap/Formula/qqq.rb for Homebrew,
* docs/packaging/qqq.json for Scoop) from packages/cli/package.json and a release's SHA256SUMS.
* Run it on the binaries that were uploaded to the GitHub release, not on a local rebuild: the
* commit is stamped into each binary, so every build has different checksums.
* The release workflow (.gitea/workflows/release.yaml) runs it on the SHA256SUMS of the binaries uploaded to
* the Gitea release, never on a local rebuild: the commit is stamped into each binary, so every build has
* different checksums.
*
* Refuses to write anything (exit 1, both files untouched) when a sum is missing, is not 64 lowercase hex digits,
* is all zeros, or when a placeholder in the files is left unfilled.
@@ -20,6 +21,8 @@ import { readFileSync, realpathSync, writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
const HEX64 = /^[0-9a-f]{64}$/
/** Release downloads on Gitea: <RELEASES>/v<version>/<file> (the release tag is v<CLI version>). */
export const RELEASES = 'https://git.kof22.com/QRun-IO/qqq-cli/releases/download'
const root = resolve(import.meta.dirname, '..')
const FORMULA_SUMS = /(url "[^"]*\/(qqq-[a-z0-9-]+)"\n\s*sha256 ")([0-9a-f]{64})"/g
@@ -58,7 +61,7 @@ export function updatePackaging({ version, sums, formula, manifest }) {
const parsed = JSON.parse(manifest)
parsed.version = version
parsed.architecture['64bit'].url = `https://github.com/QRun-IO/qqq-cli/releases/download/cli-v${version}/qqq-windows-x64.exe`
parsed.architecture['64bit'].url = `${RELEASES}/v${version}/qqq-windows-x64.exe`
parsed.architecture['64bit'].hash = sumFor(sums, 'qqq-windows-x64.exe')
return { formula: newFormula, manifest: JSON.stringify(parsed, null, 4) + '\n', formulaCount }
}
+2 -1
View File
@@ -20,7 +20,7 @@ const sumsText = (omit = []) =>
[...FILES, 'qqq-linux-x64-musl', 'qqq-windows-x64.exe'].filter((f) => !omit.includes(f)).map((f, i) => `${'abcdef0123456'[i].repeat(64)} ${f}`).join('\n') + '\n'
const formula = `class Qqq < Formula
version "0.0.0"
${FILES.map((f) => ` url "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v#{version}/${f}"\n sha256 "${ZERO}"`).join('\n')}
${FILES.map((f) => ` url "https://git.kof22.com/QRun-IO/qqq-cli/releases/download/v#{version}/${f}"\n sha256 "${ZERO}"`).join('\n')}
end
`
const manifest = JSON.stringify({ version: '0.0.0', architecture: { '64bit': { url: 'x', hash: ZERO } } }, null, 4) + '\n'
@@ -32,6 +32,7 @@ test('fills every sum from shasum output and never leaves zeros', () => {
assert.match(out.formula, /version "1\.0\.0"/)
assert.ok(out.formula.includes(`qqq-darwin-arm64"\n sha256 "${h('a')}"`))
assert.equal(JSON.parse(out.manifest).architecture['64bit'].hash, h('f'))
assert.equal(JSON.parse(out.manifest).architecture['64bit'].url, 'https://git.kof22.com/QRun-IO/qqq-cli/releases/download/v1.0.0/qqq-windows-x64.exe')
})
test('a missing target throws', () => {