diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index c5acecc..06aebf0 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -1,6 +1,7 @@ name: build on: push: + branches: ['**'] jobs: node: uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/node.yaml@v1 diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml new file mode 100644 index 0000000..50e710f --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -0,0 +1,332 @@ +# Copyright 2026 QRun.IO, Inc. +# Licensed under the Apache License, Version 2.0 (see LICENSE). +# +# Release on a v 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 83a8945..db237ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` 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 diff --git a/docs/packaging/homebrew-tap/Formula/qqq.rb b/docs/packaging/homebrew-tap/Formula/qqq.rb index be1ae58..8fa2864 100644 --- a/docs/packaging/homebrew-tap/Formula/qqq.rb +++ b/docs/packaging/homebrew-tap/Formula/qqq.rb @@ -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 diff --git a/docs/packaging/qqq.json b/docs/packaging/qqq.json index 6eb3284..a1a75b8 100644 --- a/docs/packaging/qqq.json +++ b/docs/packaging/qqq.json @@ -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" } diff --git a/docs/release-runbook.md b/docs/release-runbook.md index 82812e1..a1273d4 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -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`, 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` 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` tag, binaries on a GitHub release `cli-v`, 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` 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] - ` 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] - ` 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`. + +## 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: ()" QQQ_NOTARY_PROFILE=qqq-notary \ - scripts/sign-macos.sh # codesign, hardened runtime + scripts/macos-entitlements.plist, notarytool --wait -QQQ_WINDOWS_PFX= QQQ_WINDOWS_PFX_PASSWORD_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 -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@" 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:// # 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:// - -# 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:// ``` ```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:// ``` -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/@ ""` 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/@ ""` 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. diff --git a/package-lock.json b/package-lock.json index 05b9c38..6afe7ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/packages/cli/package.json b/packages/cli/package.json index f654990..6aa7e0a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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" } diff --git a/packages/cli/src/test/print.test.ts b/packages/cli/src/test/print.test.ts index 38890b8..bf16b7c 100644 --- a/packages/cli/src/test/print.test.ts +++ b/packages/cli/src/test/print.test.ts @@ -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 @@ -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/) diff --git a/packages/cli/src/version.ts b/packages/cli/src/version.ts index 60a8aca..6863be6 100644 --- a/packages/cli/src/version.ts +++ b/packages/cli/src/version.ts @@ -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' diff --git a/packages/conformance/package.json b/packages/conformance/package.json index 0b11156..f132ba6 100644 --- a/packages/conformance/package.json +++ b/packages/conformance/package.json @@ -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", diff --git a/packages/core/package.json b/packages/core/package.json index 7e52254..b816570 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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" }, diff --git a/packages/ui-api-client/package.json b/packages/ui-api-client/package.json index 54b496e..2c52228 100644 --- a/packages/ui-api-client/package.json +++ b/packages/ui-api-client/package.json @@ -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", diff --git a/scripts/install.sh b/scripts/install.sh index 9a84737..f0929e2 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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" diff --git a/scripts/release.mjs b/scripts/release.mjs index c51d022..a209f86 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -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 + * 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 , 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.') diff --git a/scripts/update-packaging.mjs b/scripts/update-packaging.mjs index 085502b..347131f 100644 --- a/scripts/update-packaging.mjs +++ b/scripts/update-packaging.mjs @@ -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: /v/ (the release tag is v). */ +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 } } diff --git a/scripts/update-packaging.test.mjs b/scripts/update-packaging.test.mjs index a175f5e..49b7d08 100644 --- a/scripts/update-packaging.test.mjs +++ b/scripts/update-packaging.test.mjs @@ -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', () => {