diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..9fd593f --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,375 @@ +name: Release + +# PRD F8, Gitea edition. On a v* tag push, build the single-file kigi binary +# for the five supported targets, package kigi--. +# {tar.gz|zip} (binary + LICENSE + NOTICE + THIRD-PARTY-NOTICES), and publish +# them as a Gitea Release alongside SHA256SUMS. install.sh, install.ps1 and +# auto_update::release_asset_name() all resolve these exact names — keep the +# naming in lockstep with them. +# +# RUNNERS. Gitea has no hosted runners; every label below must be a runner you +# registered yourself (docs/RELEASE.md has the register commands): +# +# ubuntu-latest Linux x86_64, container mode — the label the Dokploy box's +# runner already advertises. Builds both Linux targets; +# aarch64 is a cross-compile, so this leg installs the +# aarch64 GNU toolchain. Because the image is a bare +# runner-image, the Linux legs provision their own apt +# packages and rustup (see the provision steps below). +# Registering a dedicated `linux-x86_64:docker://...` runner +# instead is fine — swap the three labels back if you do. +# macos-arm64 Apple Silicon Mac, host mode. Builds both Darwin targets; +# x86_64 is a cross-compile against the same SDK. macOS +# CANNOT be built anywhere else — Apple's SDK licence +# restricts it to Apple hardware. +# windows-x86_64 Windows x86_64, host mode, with VS 2022 Build Tools +# (C++ workload). Needed because the tree links ring, +# mimalloc, zstd-sys and libz-sys against a static MSVC CRT +# (-C target-feature=+crt-static). +# +# SHELLS. Every step names its shell explicitly and the Windows leg never uses +# bash. On a normal Windows box `bash` resolves to WSL (not Git Bash), and +# `pwsh` (PowerShell 7) is usually absent — so Windows steps use `powershell`, +# which is the built-in 5.1. Keep those steps 5.1-compatible: no `&&`, no +# ternary, no null-coalescing. +# +# Note: because this file exists, Gitea ignores .github/workflows entirely. +# +# PUBLISH ORDER. The release is created as a draft and only made public once +# SHA256SUMS is attached, so `releases/latest` never hands the installer a +# release whose checksum manifest is still missing. + +on: + push: + tags: ["v*"] + +permissions: + contents: write + +env: + CARGO_TERM_COLOR: always + # Matches the version pinned by the dotslash launcher in bin/protoc. That + # launcher is not used here: it has no Windows entry and needs a `cargo + # install dotslash` first, so every platform fetches protoc directly. + PROTOC_VERSION: "29.3" + +jobs: + prepare: + name: prepare draft release + runs-on: ubuntu-latest + outputs: + release_id: ${{ steps.draft.outputs.release_id }} + steps: + - uses: actions/checkout@v4 + + - name: Check tag matches workspace version + shell: bash + run: | + tag_version="${GITHUB_REF_NAME#v}" + cargo_version="$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -n 1)" + if [ "$tag_version" != "$cargo_version" ]; then + echo "Tag $GITHUB_REF_NAME does not match workspace version $cargo_version" >&2 + exit 1 + fi + + - name: Create or reuse draft release + id: draft + shell: bash + env: + TOKEN: ${{ secrets.GITEA_TOKEN }} + API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} + run: | + set -eu + # The runner-image is bare; jq and curl are both used below. + if ! command -v jq >/dev/null || ! command -v curl >/dev/null; then + SUDO=""; command -v sudo >/dev/null && SUDO=sudo + export DEBIAN_FRONTEND=noninteractive + $SUDO apt-get update -qq + $SUDO apt-get install -y -qq --no-install-recommends jq curl ca-certificates + fi + + # Idempotent so a re-run of a failed tag build reuses the same draft + # instead of erroring on a duplicate tag_name. + existing="$(curl -sS -H "Authorization: token $TOKEN" \ + "$API/releases/tags/$GITHUB_REF_NAME")" + release_id="$(printf '%s' "$existing" | jq -r '.id // empty')" + + if [ -z "$release_id" ]; then + prerelease=false + case "$GITHUB_REF_NAME" in *-*) prerelease=true ;; esac + created="$(curl -fsS -X POST \ + -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"$GITHUB_REF_NAME\",\"draft\":true,\"prerelease\":$prerelease}" \ + "$API/releases")" + release_id="$(printf '%s' "$created" | jq -r '.id')" + echo "Created draft release $release_id (prerelease=$prerelease)" + else + echo "Reusing release $release_id for $GITHUB_REF_NAME" + fi + + echo "release_id=$release_id" >> "$GITHUB_OUTPUT" + + build: + name: build (${{ matrix.target }}) + needs: prepare + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + runner: ubuntu-latest + protoc: linux-x86_64 + ext: tar.gz + linux: true + - target: aarch64-unknown-linux-gnu + runner: ubuntu-latest + protoc: linux-x86_64 + ext: tar.gz + linux: true + cross_gnu: true + - target: aarch64-apple-darwin + runner: macos-arm64 + protoc: osx-aarch_64 + ext: tar.gz + - target: x86_64-apple-darwin + runner: macos-arm64 + protoc: osx-aarch_64 + ext: tar.gz + - target: x86_64-pc-windows-msvc + runner: windows-x86_64 + protoc: win64 + ext: zip + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + + # ── linux container provisioning ────────────────────────────────────── + # The Linux legs run in a bare gitea runner-image, which ships git and + # node for the JavaScript actions but no compiler, no unzip (protoc) and + # no rustup. Host-mode macOS and Windows already have their toolchains + # installed, so this step is Linux-only. + - name: Provision Linux build deps + if: matrix.linux + shell: bash + run: | + set -eu + SUDO=""; command -v sudo >/dev/null && SUDO=sudo + export DEBIAN_FRONTEND=noninteractive + $SUDO apt-get update -qq + $SUDO apt-get install -y -qq --no-install-recommends \ + build-essential pkg-config ca-certificates curl unzip tar jq + + # ── protoc ──────────────────────────────────────────────────────────── + - name: Install protoc (unix) + if: matrix.ext == 'tar.gz' + shell: bash + run: | + set -eu + url="https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-${{ matrix.protoc }}.zip" + curl -fsSL -o protoc.zip "$url" + rm -rf "$HOME/protoc" + unzip -q -o protoc.zip -d "$HOME/protoc" + chmod +x "$HOME/protoc/bin/protoc" + echo "$HOME/protoc/bin" >> "$GITHUB_PATH" + + - name: Install protoc (windows) + if: matrix.ext == 'zip' + shell: powershell + run: | + $ErrorActionPreference = "Stop" + $url = "https://github.com/protocolbuffers/protobuf/releases/download/v$env:PROTOC_VERSION/protoc-$env:PROTOC_VERSION-${{ matrix.protoc }}.zip" + Invoke-WebRequest -Uri $url -OutFile protoc.zip + $dest = Join-Path $env:USERPROFILE "protoc" + if (Test-Path $dest) { Remove-Item $dest -Recurse -Force } + Expand-Archive protoc.zip -DestinationPath $dest -Force + Add-Content -Path $env:GITHUB_PATH -Value (Join-Path $dest "bin") + + # ── toolchain ───────────────────────────────────────────────────────── + # rust-toolchain.toml pins the channel (1.97.0) and preinstalls both + # Linux targets; `rustup show` materialises exactly that. + - name: Install toolchain (unix) + if: matrix.ext == 'tar.gz' + shell: bash + run: | + set -eu + # No-op on the macOS host runner, which has rustup installed already. + # `--default-toolchain none` leaves the channel choice to + # rust-toolchain.toml, which `rustup show` then materialises. + if ! command -v rustup >/dev/null; then + curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs \ + | sh -s -- -y --no-modify-path --profile minimal --default-toolchain none + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + export PATH="$HOME/.cargo/bin:$PATH" + fi + rustup show + rustup target add ${{ matrix.target }} + + - name: Install toolchain (windows) + if: matrix.ext == 'zip' + shell: powershell + run: | + $ErrorActionPreference = "Stop" + rustup show + rustup target add ${{ matrix.target }} + + # ring, mimalloc, zstd-sys and libz-sys are all cc-rs builds, so the + # cross target needs its own C toolchain — cc-rs picks these up from the + # target-suffixed vars. + - name: Install aarch64 cross toolchain + if: matrix.cross_gnu + shell: bash + run: | + set -eu + SUDO=""; command -v sudo >/dev/null && SUDO=sudo + $SUDO apt-get update -qq + $SUDO apt-get install -y -qq gcc-aarch64-linux-gnu g++-aarch64-linux-gnu + { + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" + echo "CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc" + echo "CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++" + echo "AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar" + } >> "$GITHUB_ENV" + + # ── build ───────────────────────────────────────────────────────────── + - name: Build kigi (unix) + if: matrix.ext == 'tar.gz' + shell: bash + run: cargo build --profile release-dist -p kigi-bin --locked --target ${{ matrix.target }} + + - name: Build kigi (windows) + if: matrix.ext == 'zip' + shell: powershell + run: | + $ErrorActionPreference = "Stop" + cargo build --profile release-dist -p kigi-bin --locked --target ${{ matrix.target }} + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + # ── package, upload, checksum ───────────────────────────────────────── + # Archives go straight to the release rather than through the artifact + # store: they are large, and the release is the only place install.sh + # can read them from anyway. Only the checksum line travels as an + # artifact, for the publish job to concatenate. + - name: Package and upload (unix) + if: matrix.ext == 'tar.gz' + shell: bash + env: + TOKEN: ${{ secrets.GITEA_TOKEN }} + API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} + RELEASE_ID: ${{ needs.prepare.outputs.release_id }} + run: | + set -eu + version="${GITHUB_REF_NAME#v}" + archive="kigi-${version}-${{ matrix.target }}.tar.gz" + staging="$(mktemp -d)" + cp "target/${{ matrix.target }}/release-dist/kigi" "$staging/kigi" + cp LICENSE "$staging/" + [ -f NOTICE ] && cp NOTICE "$staging/" + for f in THIRD-PARTY-NOTICES THIRD-PARTY-NOTICES.md; do + [ -f "$f" ] && cp "$f" "$staging/" + done + tar -C "$staging" -czf "$archive" . + + curl -fsS -X POST \ + -H "Authorization: token $TOKEN" \ + -F "attachment=@${archive}" \ + "$API/releases/$RELEASE_ID/assets?name=${archive}" > /dev/null + echo "Uploaded $archive" + + if command -v sha256sum >/dev/null; then + sha256sum "$archive" > "checksum-${{ matrix.target }}.txt" + else + shasum -a 256 "$archive" > "checksum-${{ matrix.target }}.txt" + fi + cat "checksum-${{ matrix.target }}.txt" + + - name: Package and upload (windows) + if: matrix.ext == 'zip' + shell: powershell + env: + TOKEN: ${{ secrets.GITEA_TOKEN }} + API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} + RELEASE_ID: ${{ needs.prepare.outputs.release_id }} + run: | + $ErrorActionPreference = "Stop" + $version = $env:GITHUB_REF_NAME.TrimStart("v") + $archive = "kigi-$version-${{ matrix.target }}.zip" + $staging = Join-Path $env:RUNNER_TEMP "staging" + if (Test-Path $staging) { Remove-Item $staging -Recurse -Force } + New-Item -ItemType Directory -Force -Path $staging | Out-Null + Copy-Item "target/${{ matrix.target }}/release-dist/kigi.exe" (Join-Path $staging "kigi.exe") + Copy-Item LICENSE $staging + if (Test-Path NOTICE) { Copy-Item NOTICE $staging } + foreach ($f in @("THIRD-PARTY-NOTICES", "THIRD-PARTY-NOTICES.md")) { + if (Test-Path $f) { Copy-Item $f $staging } + } + Compress-Archive -Path (Join-Path $staging "*") -DestinationPath $archive -Force + + # System32\curl.exe, not PowerShell's `curl` alias for + # Invoke-WebRequest, which cannot do multipart uploads. + $api = "$env:API/releases/$env:RELEASE_ID/assets?name=$archive" + & "$env:SystemRoot\System32\curl.exe" -fsS -X POST ` + -H "Authorization: token $env:TOKEN" ` + -F "attachment=@$archive" $api + if ($LASTEXITCODE -ne 0) { throw "asset upload failed ($LASTEXITCODE)" } + Write-Host "Uploaded $archive" + + # sha256sum's exact format, so the publish job can just concatenate. + # ASCII keeps the BOM out — a BOM would corrupt the first hash. + $hash = (Get-FileHash -Algorithm SHA256 -Path $archive).Hash.ToLowerInvariant() + "$hash $archive" | Out-File -FilePath "checksum-${{ matrix.target }}.txt" -Encoding ascii + Get-Content "checksum-${{ matrix.target }}.txt" + + - uses: actions/upload-artifact@v3 + with: + name: checksum-${{ matrix.target }} + path: checksum-${{ matrix.target }}.txt + if-no-files-found: error + + publish: + name: publish release + needs: [prepare, build] + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v3 + with: + path: checksums + + - name: Assemble SHA256SUMS + shell: bash + run: | + set -eu + # download-artifact v3 nests each artifact in its own directory. The + # Windows line arrives CRLF-terminated; strip CR so every line is LF + # for install.sh's `read -r hash name`. + find checksums -name 'checksum-*.txt' -exec cat {} + \ + | tr -d '\r' | grep -v '^$' | sort -k2 > SHA256SUMS + lines="$(wc -l < SHA256SUMS)" + if [ "$lines" -ne 5 ]; then + echo "expected 5 checksum lines, got $lines" >&2 + cat SHA256SUMS >&2 + exit 1 + fi + cat SHA256SUMS + + - name: Attach SHA256SUMS and publish + shell: bash + env: + TOKEN: ${{ secrets.GITEA_TOKEN }} + API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} + RELEASE_ID: ${{ needs.prepare.outputs.release_id }} + run: | + set -eu + curl -fsS -X POST \ + -H "Authorization: token $TOKEN" \ + -F "attachment=@SHA256SUMS" \ + "$API/releases/$RELEASE_ID/assets?name=SHA256SUMS" > /dev/null + + # Flip the draft public only now that every asset install.sh looks + # for is attached. + curl -fsS -X PATCH \ + -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"draft":false}' \ + "$API/releases/$RELEASE_ID" > /dev/null + echo "Published $GITHUB_REF_NAME" diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 90eb8ee..e771474 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -1,10 +1,14 @@ # Release checklist (PRD F8) Kigi ships as a single-file binary for five targets, published on this -repo's GitHub Releases by `.github/workflows/release.yml`. Users install via +repo's Gitea Releases by `.gitea/workflows/release.yml`. Users install via `install.sh` / `install.ps1` and stay current through the in-app self-updater (`kigi-update`), which resolves the same Releases API. +`.github/workflows/release.yml` is the retired GitHub pipeline. Gitea ignores +`.github/workflows` entirely whenever `.gitea/workflows` is non-empty, so it +does not run — it is kept only as a reference for the GitHub-hosted layout. + ## Cutting a release 1. Bump `[workspace.package] version` in `Cargo.toml`; land the change on @@ -30,16 +34,146 @@ self-updater (`kigi-update`), which resolves the same Releases API. `release-dist` profile, packages `kigi--.{tar.gz|zip}` archives (binary + LICENSE + NOTICE + THIRD-PARTY-NOTICES), generates `SHA256SUMS`, and - publishes the GitHub Release. Tags containing `-` (e.g. + publishes the Gitea Release. Tags containing `-` (e.g. `v0.2.0-alpha.1`) publish as pre-releases, which only the `alpha` update channel picks up. + + The release is created as a **draft** and only flipped public once + `SHA256SUMS` is attached, so `releases/latest` never hands the installer + a release whose checksum manifest is still missing. A failed run leaves + the draft in place; re-pushing the tag reuses it rather than erroring on + a duplicate `tag_name`. 5. Smoke-test an installed artifact: ```sh - curl -fsSL https://raw.githubusercontent.com/ZacharyZhang-NY/Kigi-CLI/main/install.sh | sh + curl -fsSL https://kigicli.dev/install.sh | sh ~/.kigi/bin/kigi --version ``` +## Build runners + +Gitea has no hosted runners, so all three `runs-on` labels below are machines +we register ourselves with `gitea-runner` (the binary formerly called +`act_runner`). Labels are declared in the runner's `config.yaml` as +`