From 3a233a8ff9bf66961864bc4778e0cad882f9cc27 Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sun, 11 Jan 2026 12:59:42 -0700 Subject: [PATCH 01/12] Add installation instructions for Homebrew --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/README.md b/README.md index e6e45f9..45815e1 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,29 @@ publishing books and documents. It is part of the broader Keystone ecosystem, wh - [keystone-hello-world](https://github.com/knight-owl-dev/keystone-hello-world) – a "Hello World" sample project based on the `core-slim` template, demonstrating Keystone capabilities with sample content +## Installation (macOS) + +Keystone CLI is distributed via Homebrew. + +First, add the Knight Owl Homebrew tap: + +```bash +brew tap Knight-Owl-Dev/tap +``` + +Then install the CLI: + +```bash +brew install keystone-cli +``` + +After installation, verify that everything is working: + +```bash +keystone-cli info +man keystone-cli +``` + For license details and third-party references, see [NOTICE.md](NOTICE.md). ## Project Structure From d5151973bc1515477eb7774fe3d018148b0dd340 Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sun, 11 Jan 2026 13:34:56 -0700 Subject: [PATCH 02/12] Add YAML file formatting rules to .editorconfig --- .editorconfig | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.editorconfig b/.editorconfig index 497d0e8..41a8005 100644 --- a/.editorconfig +++ b/.editorconfig @@ -595,8 +595,14 @@ resharper_xmldoc_space_before_self_closing = true resharper_xmldoc_wrap_tags_and_pi = false resharper_xmldoc_wrap_text = true + [{*.har,*.inputactions,*.jsb2,*.jsb3,*.json,.babelrc,.eslintrc,.stylelintrc,bowerrc,jest.config}] indent_size = 2 +# YAML files (match common ecosystem conventions) +[*.{yml,yaml}] +indent_size = 2 +tab_width = 2 + [*.{appxmanifest,asax,ascx,aspx,axaml,build,c,c++,cc,cginc,compute,cp,cpp,cs,cshtml,cu,cuh,cxx,dtd,fs,fsi,fsscript,fsx,fx,fxh,h,hh,hlsl,hlsli,hlslinc,hpp,hxx,inc,inl,ino,ipp,master,ml,mli,mpp,mq4,mq5,mqh,nuspec,paml,razor,resw,resx,shader,skin,tpp,usf,ush,vb,xaml,xamlx,xoml,xsd}] tab_width = 4 From fd62c3094f58d4e331e9bd329e3b9d12947e77b0 Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sun, 11 Jan 2026 13:35:16 -0700 Subject: [PATCH 03/12] Add CI configuration for automated testing with .NET 10 --- .github/workflows/ci.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e08d132 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + + - name: Restore + run: dotnet restore + + - name: Test + run: dotnet test ./tests/Keystone.Cli.UnitTests/Keystone.Cli.UnitTests.csproj -c Release From 74688f2d23e65786ae30b22db02c57ebdf93a640 Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sun, 11 Jan 2026 13:37:31 -0700 Subject: [PATCH 04/12] Add GitHub Actions workflow for automated release process --- .github/workflows/release.yml | 41 +++++++++++++++++++++++++++++++++++ scripts/package-release.sh | 30 ++++++++++++++++++++++++- 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..483ff74 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,41 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: write + +jobs: + release: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + + - name: Extract version from tag + id: v + run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + + # Build publish outputs (your project expects artifacts/bin/... layout) + - name: Publish (osx-arm64) + run: dotnet publish ./src/Keystone.Cli/Keystone.Cli.csproj -c Release -r osx-arm64 + + - name: Publish (osx-x64) + run: dotnet publish ./src/Keystone.Cli/Keystone.Cli.csproj -c Release -r osx-x64 + + - name: Package tarballs + print SHA256 + run: bash ./scripts/package-release.sh "${{ steps.v.outputs.version }}" + + - name: Create GitHub Release and upload assets + uses: softprops/action-gh-release@v2 + with: + name: "keystone-cli v${{ steps.v.outputs.version }}" + files: | + artifacts/release/keystone-cli_${{ steps.v.outputs.version }}_osx-arm64.tar.gz + artifacts/release/keystone-cli_${{ steps.v.outputs.version }}_osx-x64.tar.gz diff --git a/scripts/package-release.sh b/scripts/package-release.sh index 6caf34b..b6725ff 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -6,8 +6,36 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" cd "${REPO_ROOT}" -VERSION="${1:-0.1.0}" +VERSION="" + +usage() { + echo "Usage: $(basename "$0") [version]" >&2 + echo " version: Optional release version (e.g., 0.1.0). Defaults to the current CLI version in Keystone.Cli.csproj if omitted." >&2 +} + +if [[ $# -gt 1 ]]; then + usage + exit 2 +fi + +if [[ $# -eq 1 ]]; then + VERSION="$1" +fi + TFM="net10.0" + +if [[ -z "$VERSION" ]]; then + # Best-effort: extract ... from the CLI project file. + # (Keeps this script dependency-free; falls back to 0.1.0 if not found.) + if [[ -f "./src/Keystone.Cli/Keystone.Cli.csproj" ]]; then + VERSION="$(sed -n 's:.*\(.*\).*:\1:p' ./src/Keystone.Cli/Keystone.Cli.csproj | head -n 1)" + fi + + if [[ -z "$VERSION" ]]; then + VERSION="0.1.0" + fi +fi + OUT_DIR="artifacts/release" mkdir -p "$OUT_DIR" From 1869e3a52e606e9b241bb92a6fb50b7c3d096fea Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sun, 11 Jan 2026 13:55:26 -0700 Subject: [PATCH 05/12] Add release process documentation to RELEASE.md --- docs/RELEASE.md | 81 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 docs/RELEASE.md diff --git a/docs/RELEASE.md b/docs/RELEASE.md new file mode 100644 index 0000000..025f509 --- /dev/null +++ b/docs/RELEASE.md @@ -0,0 +1,81 @@ +# How To Release + +Keystone CLI uses **tag-driven releases**. + +- Pull requests must have passing unit tests before merge. +- Releases are created **only** when a version tag (e.g., `v0.1.0`) is pushed. +- A GitHub Actions workflow builds and publishes release artifacts. + +--- + +## Prerequisites + +- You have push access to the [Knight-Owl-Dev/keystone-cli](https://github.com/knight-owl-dev/keystone-cli) repository. +- Unit tests are green on `main`. +- Homebrew tap repository [Knight-Owl-Dev/homebrew-tap](https://github.com/knight-owl-dev/homebrew-tap) is available for + formula updates. + +--- + +## Release steps + +1. Sync and validate locally: + + ```bash + git checkout main + git pull + dotnet test ./tests/Keystone.Cli.UnitTests/Keystone.Cli.UnitTests.csproj -c Release + ``` + +2. Ensure the `` value in `Keystone.Cli.csproj` is updated to match the intended release version. + + This version **must match** the git tag you are about to create (e.g., `0.1.1` → `v0.1.1`). + +3. Create and push an annotated tag: + + ```bash + git tag -a vX.Y.Z -m "keystone-cli vX.Y.Z" + git push origin vX.Y.Z + ``` + + Example: + + ```bash + git tag -a v0.1.0 -m "keystone-cli v0.1.0" + git push origin v0.1.0 + ``` + +4. Monitor the GitHub Actions **Release** workflow. + + The workflow will: + + - `dotnet publish` for `osx-arm64` and `osx-x64` + - run `scripts/package-release.sh` to build `.tar.gz` assets + - compute SHA-256 values + - create a GitHub Release for the tag and upload assets + +5. Update the Homebrew formula in `Knight-Owl-Dev/homebrew-tap`: + + - Update the version and release URLs to `vX.Y.Z` + - Replace the `sha256` values with the ones printed by the workflow + - Commit and push the formula update + +6. Validate installation on macOS: + + ```bash + brew update + brew install keystone-cli + keystone-cli info + man keystone-cli + ``` + +--- + +## Notes + +- The release assets must be publicly downloadable. +- GitHub **Release immutability** is enabled to prevent replacing assets after publishing. +- The packaging script includes: + - `keystone-cli` + - `appsettings.json` + - `keystone-cli.1` (man page) From 148bc84ae1642bb070eb7b1edaa3cadefd85d5e1 Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sun, 11 Jan 2026 14:13:13 -0700 Subject: [PATCH 06/12] Add GitHub Actions workflow for tagging releases --- .github/workflows/tag-release.yml | 54 +++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 .github/workflows/tag-release.yml diff --git a/.github/workflows/tag-release.yml b/.github/workflows/tag-release.yml new file mode 100644 index 0000000..b29c48e --- /dev/null +++ b/.github/workflows/tag-release.yml @@ -0,0 +1,54 @@ +name: Tag release + +on: + workflow_dispatch: + +permissions: + contents: write + +jobs: + tag: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # required to create/push tags reliably + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + + - name: Read version from csproj + id: v + shell: bash + run: | + VERSION="$(sed -n 's:.*\(.*\).*:\1:p' ./src/Keystone.Cli/Keystone.Cli.csproj | head -n 1)" + if [[ -z "$VERSION" ]]; then + echo "ERROR: Could not read from ./src/Keystone.Cli/Keystone.Cli.csproj" >&2 + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Run unit tests (gate tagging) + run: dotnet test ./tests/Keystone.Cli.UnitTests/Keystone.Cli.UnitTests.csproj -c Release + + - name: Ensure tag does not already exist + shell: bash + run: | + TAG="v${{ steps.v.outputs.version }}" + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "ERROR: Tag already exists: $TAG" >&2 + exit 1 + fi + + - name: Create and push tag + shell: bash + run: | + TAG="v${{ steps.v.outputs.version }}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "$TAG" -m "keystone-cli $TAG" + git push origin "$TAG" + + - name: Summary + run: echo "Pushed tag v${{ steps.v.outputs.version }}. This will trigger the Release workflow." From 7613e53ecff18e1be2743d553af982683b3e9263 Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sun, 11 Jan 2026 14:21:28 -0700 Subject: [PATCH 07/12] Enhance release workflow with version validation and SHA-256 checksum generation --- .github/workflows/release.yml | 65 ++++++++++++++++++++++++++++++++--- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 483ff74..768e8cf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,16 +11,40 @@ permissions: jobs: release: runs-on: macos-latest + steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 - - uses: actions/setup-dotnet@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 with: dotnet-version: "10.0.x" - name: Extract version from tag id: v - run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + shell: bash + run: | + echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + + - name: Validate csproj version matches tag + shell: bash + run: | + CS_VERSION="$(sed -n 's:.*\(.*\).*:\1:p' ./src/Keystone.Cli/Keystone.Cli.csproj | head -n 1)" + if [[ -z "$CS_VERSION" ]]; then + echo "ERROR: Could not read from ./src/Keystone.Cli/Keystone.Cli.csproj" >&2 + exit 1 + fi + + if [[ "$CS_VERSION" != "${{ steps.v.outputs.version }}" ]]; then + echo "ERROR: csproj ($CS_VERSION) does not match tag (v${{ steps.v.outputs.version }})" >&2 + exit 1 + fi + + - name: Run unit tests (gate release) + run: dotnet test ./tests/Keystone.Cli.UnitTests/Keystone.Cli.UnitTests.csproj -c Release # Build publish outputs (your project expects artifacts/bin/... layout) - name: Publish (osx-arm64) @@ -29,13 +53,46 @@ jobs: - name: Publish (osx-x64) run: dotnet publish ./src/Keystone.Cli/Keystone.Cli.csproj -c Release -r osx-x64 - - name: Package tarballs + print SHA256 + - name: Package tarballs + shell: bash run: bash ./scripts/package-release.sh "${{ steps.v.outputs.version }}" + - name: Compute SHA-256 (for release notes) + id: sha + shell: bash + run: | + ARM_FILE="artifacts/release/keystone-cli_${{ steps.v.outputs.version }}_osx-arm64.tar.gz" + X64_FILE="artifacts/release/keystone-cli_${{ steps.v.outputs.version }}_osx-x64.tar.gz" + + if [[ ! -f "$ARM_FILE" ]]; then + echo "ERROR: Missing release asset: $ARM_FILE" >&2 + exit 1 + fi + + if [[ ! -f "$X64_FILE" ]]; then + echo "ERROR: Missing release asset: $X64_FILE" >&2 + exit 1 + fi + + ARM_SHA="$(shasum -a 256 "$ARM_FILE" | awk '{print $1}')" + X64_SHA="$(shasum -a 256 "$X64_FILE" | awk '{print $1}')" + + echo "arm_sha=$ARM_SHA" >> "$GITHUB_OUTPUT" + echo "x64_sha=$X64_SHA" >> "$GITHUB_OUTPUT" + + printf "SHA256 checksums\n\n- osx-arm64: %s\n- osx-x64: %s\n" "$ARM_SHA" "$X64_SHA" > artifacts/release/checksums.txt + - name: Create GitHub Release and upload assets uses: softprops/action-gh-release@v2 with: name: "keystone-cli v${{ steps.v.outputs.version }}" + body: | + Automated release for v${{ steps.v.outputs.version }}. + + SHA256 checksums: + - osx-arm64: `${{ steps.sha.outputs.arm_sha }}` + - osx-x64: `${{ steps.sha.outputs.x64_sha }}` files: | artifacts/release/keystone-cli_${{ steps.v.outputs.version }}_osx-arm64.tar.gz artifacts/release/keystone-cli_${{ steps.v.outputs.version }}_osx-x64.tar.gz + artifacts/release/checksums.txt From 08bba6e0dc90d180db0f5ef88e1eda9b6d31858e Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sun, 11 Jan 2026 14:34:32 -0700 Subject: [PATCH 08/12] Enhance package-release.sh to support optional runtime identifier (RID) for packaging --- scripts/package-release.sh | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/scripts/package-release.sh b/scripts/package-release.sh index b6725ff..619a268 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -8,18 +8,39 @@ cd "${REPO_ROOT}" VERSION="" +RID="" + usage() { - echo "Usage: $(basename "$0") [version]" >&2 - echo " version: Optional release version (e.g., 0.1.0). Defaults to the current CLI version in Keystone.Cli.csproj if omitted." >&2 + echo "Usage: $(basename "$0") [version] [rid]" >&2 + echo " version: Optional release version (e.g., 0.1.0)." >&2 + echo " Defaults to the current value in Keystone.Cli.csproj if omitted." >&2 + echo " rid: Optional runtime identifier (e.g., osx-arm64, osx-x64, linux-x64)." >&2 + echo " If provided, only that RID archive will be produced." >&2 + echo "" >&2 + echo "Examples:" >&2 + echo " $(basename "$0")" >&2 + echo " $(basename "$0") 0.1.0" >&2 + echo " $(basename "$0") 0.1.0 osx-arm64" >&2 + echo " $(basename "$0") osx-arm64" >&2 } -if [[ $# -gt 1 ]]; then +if [[ $# -gt 2 ]]; then usage exit 2 fi if [[ $# -eq 1 ]]; then + # Support either "version" OR "rid" as the sole argument. + if [[ "$1" =~ ^(osx|win|linux)(-|$) ]]; then + RID="$1" + else + VERSION="$1" + fi +fi + +if [[ $# -eq 2 ]]; then VERSION="$1" + RID="$2" fi TFM="net10.0" @@ -83,7 +104,11 @@ package() { fi } -package osx-arm64 -package osx-x64 +if [[ -n "$RID" ]]; then + package "$RID" +else + package osx-arm64 + package osx-x64 +fi echo "Done." From fe4076750994951033fc9608ae01b30e0d17d687 Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sun, 11 Jan 2026 14:48:12 -0700 Subject: [PATCH 09/12] Refactor release workflow to include validation and support for multiple runtime identifiers (RIDs) --- .github/workflows/release.yml | 129 ++++++++++++++++++++++++---------- 1 file changed, 92 insertions(+), 37 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 768e8cf..852ca3d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,12 +5,13 @@ on: tags: - "v*.*.*" -permissions: - contents: write - jobs: - release: - runs-on: macos-latest + validate: + name: Validate + runs-on: ubuntu-latest + + outputs: + version: ${{ steps.v.outputs.version }} steps: - name: Checkout @@ -46,53 +47,107 @@ jobs: - name: Run unit tests (gate release) run: dotnet test ./tests/Keystone.Cli.UnitTests/Keystone.Cli.UnitTests.csproj -c Release - # Build publish outputs (your project expects artifacts/bin/... layout) - - name: Publish (osx-arm64) - run: dotnet publish ./src/Keystone.Cli/Keystone.Cli.csproj -c Release -r osx-arm64 + build-assets: + name: Build assets (${{ matrix.rid }}) + needs: validate + runs-on: ${{ matrix.runner }} + + strategy: + fail-fast: false + matrix: + include: + - runner: macos-latest + rid: osx-arm64 + - runner: macos-latest + rid: osx-x64 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" - - name: Publish (osx-x64) - run: dotnet publish ./src/Keystone.Cli/Keystone.Cli.csproj -c Release -r osx-x64 + - name: Publish (${{ matrix.rid }}) + run: dotnet publish ./src/Keystone.Cli/Keystone.Cli.csproj -c Release -r ${{ matrix.rid }} - - name: Package tarballs + - name: Package tarball (${{ matrix.rid }}) shell: bash - run: bash ./scripts/package-release.sh "${{ steps.v.outputs.version }}" + run: bash ./scripts/package-release.sh "${{ needs.validate.outputs.version }}" "${{ matrix.rid }}" + + - name: Upload tarball artifact + uses: actions/upload-artifact@v4 + with: + name: dist-${{ matrix.rid }} + path: artifacts/release/keystone-cli_${{ needs.validate.outputs.version }}_${{ matrix.rid }}.tar.gz + if-no-files-found: error - - name: Compute SHA-256 (for release notes) - id: sha + publish-release: + name: Publish Release + needs: [validate, build-assets] + runs-on: ubuntu-latest + + permissions: + contents: write + + steps: + - name: Download all tarballs + uses: actions/download-artifact@v4 + with: + path: dist + + - name: Generate checksums.txt shell: bash run: | - ARM_FILE="artifacts/release/keystone-cli_${{ steps.v.outputs.version }}_osx-arm64.tar.gz" - X64_FILE="artifacts/release/keystone-cli_${{ steps.v.outputs.version }}_osx-x64.tar.gz" + set -euo pipefail - if [[ ! -f "$ARM_FILE" ]]; then - echo "ERROR: Missing release asset: $ARM_FILE" >&2 + echo "Release assets:" + files=$(find dist -maxdepth 3 -type f -name '*.tar.gz' -print) + if [[ -z "$files" ]]; then + echo "ERROR: No .tar.gz files found under dist/" >&2 exit 1 fi - if [[ ! -f "$X64_FILE" ]]; then - echo "ERROR: Missing release asset: $X64_FILE" >&2 - exit 1 - fi + printf "%s\n" $files | sort - ARM_SHA="$(shasum -a 256 "$ARM_FILE" | awk '{print $1}')" - X64_SHA="$(shasum -a 256 "$X64_FILE" | awk '{print $1}')" + sha256sum $files | sort > checksums.txt - echo "arm_sha=$ARM_SHA" >> "$GITHUB_OUTPUT" - echo "x64_sha=$X64_SHA" >> "$GITHUB_OUTPUT" + echo "" + echo "checksums.txt:" + cat checksums.txt - printf "SHA256 checksums\n\n- osx-arm64: %s\n- osx-x64: %s\n" "$ARM_SHA" "$X64_SHA" > artifacts/release/checksums.txt + - name: Prepare release notes + shell: bash + run: | + set -euo pipefail + + cat > release-body.md <<'EOF' + Automated release for ${GITHUB_REF_NAME}. + + SHA256 checksums: + ``` + EOF + + cat checksums.txt >> release-body.md + + cat >> release-body.md <<'EOF' + ``` + EOF + + echo "" + echo "release-body.md:" + cat release-body.md - name: Create GitHub Release and upload assets uses: softprops/action-gh-release@v2 with: - name: "keystone-cli v${{ steps.v.outputs.version }}" - body: | - Automated release for v${{ steps.v.outputs.version }}. - - SHA256 checksums: - - osx-arm64: `${{ steps.sha.outputs.arm_sha }}` - - osx-x64: `${{ steps.sha.outputs.x64_sha }}` + name: "keystone-cli v${{ needs.validate.outputs.version }}" + body_path: release-body.md files: | - artifacts/release/keystone-cli_${{ steps.v.outputs.version }}_osx-arm64.tar.gz - artifacts/release/keystone-cli_${{ steps.v.outputs.version }}_osx-x64.tar.gz - artifacts/release/checksums.txt + dist/**/keystone-cli_${{ needs.validate.outputs.version }}_*.tar.gz + checksums.txt + release-body.md From 21c35f4da1e935fa34cd80815ad9d434fdb5c96b Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sun, 11 Jan 2026 14:53:38 -0700 Subject: [PATCH 10/12] Update release documentation to clarify automated and manual release flows --- docs/RELEASE.md | 115 ++++++++++++++++++++++++++++++++++-------------- 1 file changed, 81 insertions(+), 34 deletions(-) diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 025f509..11ce726 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -17,48 +17,64 @@ Keystone CLI uses **tag-driven releases**. --- -## Release steps +## Release flows -1. Sync and validate locally: +Keystone CLI supports two release flows: - ```bash - git checkout main - git pull - dotnet test ./tests/Keystone.Cli.UnitTests/Keystone.Cli.UnitTests.csproj -c Release - ``` +1. **Automated GitHub release (preferred)** — push-button, reproducible, and audited +2. **Manual release (backup)** — for emergencies or CI outages -2. Ensure the `` value in `Keystone.Cli.csproj` is updated to match the intended release version. +--- - This version **must match** the git tag you are about to create (e.g., `0.1.1` → `v0.1.1`). +## Automated release (via GitHub Actions) -3. Create and push an annotated tag: +This is the **recommended** way to publish a new version. - ```bash - git tag -a vX.Y.Z -m "keystone-cli vX.Y.Z" - git push origin vX.Y.Z - ``` +### Prerequisites - Example: +- `` in `Keystone.Cli.csproj` is updated to the intended release version +- All changes are merged into `main` +- Unit tests are passing - ```bash - git tag -a v0.1.0 -m "keystone-cli v0.1.0" - git push origin v0.1.0 +### Steps + +1. Update the project version: + + ```xml + X.Y.Z ``` -4. Monitor the GitHub Actions **Release** workflow. + This value **must match** the git tag that will be created (`vX.Y.Z`). + +2. Push changes to `main` (via PR as usual). + +3. Trigger the **Tag release** workflow in GitHub: + + - Go to **Actions → Tag release** + - Click **Run workflow** The workflow will: - - `dotnet publish` for `osx-arm64` and `osx-x64` - - run `scripts/package-release.sh` to build `.tar.gz` assets - - compute SHA-256 values - - create a GitHub Release for the tag and upload assets + - read `` from `Keystone.Cli.csproj` + - run unit tests (release gate) + - create and push the annotated tag `vX.Y.Z` -5. Update the Homebrew formula in `Knight-Owl-Dev/homebrew-tap`: +4. The tag push automatically triggers the **Release** workflow. - - Update the version and release URLs to `vX.Y.Z` - - Replace the `sha256` values with the ones printed by the workflow - - Commit and push the formula update + That workflow will: + + - validate `` matches the tag + - build and publish binaries (matrix-based by RID) + - package `.tar.gz` release assets + - compute SHA-256 checksums + - generate release notes + - create a GitHub Release and upload all assets + +5. (Optional) Update the Homebrew formula in `Knight-Owl-Dev/homebrew-tap`: + + - Update the version and release URLs to `vX.Y.Z` + - Replace the `sha256` values using `checksums.txt` from the release + - Commit and push (or merge the automated PR, if enabled) 6. Validate installation on macOS: @@ -71,11 +87,42 @@ Keystone CLI uses **tag-driven releases**. --- -## Notes +## Manual release (backup / emergency) + +Use this flow **only** if GitHub Actions is unavailable or requires debugging. + +### Steps + +1. Sync and validate locally: + + ```bash + git checkout main + git pull + dotnet test ./tests/Keystone.Cli.UnitTests/Keystone.Cli.UnitTests.csproj -c Release + ``` + +2. Ensure `` in `Keystone.Cli.csproj` matches the intended release. + +3. Build and package release assets locally: + + ```bash + dotnet publish ./src/Keystone.Cli/Keystone.Cli.csproj -c Release -r osx-arm64 + dotnet publish ./src/Keystone.Cli/Keystone.Cli.csproj -c Release -r osx-x64 + + ./scripts/package-release.sh X.Y.Z + ``` + +4. Create and push the annotated tag: + + ```bash + git tag -a vX.Y.Z -m "keystone-cli vX.Y.Z" + git push origin vX.Y.Z + ``` + +5. Create the GitHub Release manually if it didn't get created automatically: + + - Upload the generated `.tar.gz` files + - Include `checksums.txt` in the release assets + - Paste checksum contents into the release description -- The release assets must be publicly downloadable. -- GitHub **Release immutability** is enabled to prevent replacing assets after publishing. -- The packaging script includes: - - `keystone-cli` - - `appsettings.json` - - `keystone-cli.1` (man page) +6. Update the Homebrew formula as usual. From 4a88ecd15bdcb7e9abcda9b88bbb4cfb5ea531f3 Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sun, 11 Jan 2026 15:01:05 -0700 Subject: [PATCH 11/12] Update README to include links for releases and license information --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 45815e1..f35046e 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,9 @@ A command-line interface for Keystone. +- 📦 [Releases](https://github.com/Knight-Owl-Dev/keystone-cli/releases) — binaries & checksums +- 📄 [License & Notices](NOTICE.md) + This CLI is designed to operate alongside official Keystone templates, which define the structure and build behavior for publishing books and documents. It is part of the broader Keystone ecosystem, which includes: @@ -35,8 +38,6 @@ keystone-cli info man keystone-cli ``` -For license details and third-party references, see [NOTICE.md](NOTICE.md). - ## Project Structure The project is organized into three main parts: From eb7edc418abbf6479354bf81a46f775bdff39ba0 Mon Sep 17 00:00:00 2001 From: Oleksandr Akhtyrskiy Date: Sun, 11 Jan 2026 15:41:03 -0700 Subject: [PATCH 12/12] Refactor command controllers to use `IConsole` for output handling --- src/Keystone.Cli/Application/Console.cs | 16 ++++++++++ .../Application/Utility/IConsole.cs | 17 ++++++++++ .../Configuration/DependenciesInstaller.cs | 2 ++ .../Presentation/BrowseCommandController.cs | 5 +-- .../Presentation/InfoCommandController.cs | 5 +-- .../Presentation/NewCommandController.cs | 7 +++-- .../Project/SwitchTemplateSubCommand.cs | 11 ++++--- .../Application/Utility/NullConsole.cs | 31 +++++++++++++++++++ .../DependenciesInstallerTests.cs | 1 + .../BrowseCommandControllerTests.cs | 3 +- .../InfoCommandControllerTests.cs | 3 +- .../Presentation/NewCommandControllerTests.cs | 3 +- .../Project/SwitchTemplateSubCommandTests.cs | 3 +- 13 files changed, 91 insertions(+), 16 deletions(-) create mode 100644 src/Keystone.Cli/Application/Console.cs create mode 100644 src/Keystone.Cli/Application/Utility/IConsole.cs create mode 100644 tests/Keystone.Cli.UnitTests/Application/Utility/NullConsole.cs diff --git a/src/Keystone.Cli/Application/Console.cs b/src/Keystone.Cli/Application/Console.cs new file mode 100644 index 0000000..0d1865f --- /dev/null +++ b/src/Keystone.Cli/Application/Console.cs @@ -0,0 +1,16 @@ +using Keystone.Cli.Application.Utility; + + +namespace Keystone.Cli.Application; + +/// +/// The default implementation of that uses the system console. +/// +public class Console : IConsole +{ + /// + public TextWriter Out => System.Console.Out; + + /// + public TextWriter Error => System.Console.Error; +} diff --git a/src/Keystone.Cli/Application/Utility/IConsole.cs b/src/Keystone.Cli/Application/Utility/IConsole.cs new file mode 100644 index 0000000..e9b66eb --- /dev/null +++ b/src/Keystone.Cli/Application/Utility/IConsole.cs @@ -0,0 +1,17 @@ +namespace Keystone.Cli.Application.Utility; + +/// +/// Abstraction over the console for easier testing. +/// +public interface IConsole +{ + /// + /// The standard output writer. + /// + TextWriter Out { get; } + + /// + /// The standard error writer. + /// + TextWriter Error { get; } +} diff --git a/src/Keystone.Cli/Configuration/DependenciesInstaller.cs b/src/Keystone.Cli/Configuration/DependenciesInstaller.cs index 3571d66..37234ea 100644 --- a/src/Keystone.Cli/Configuration/DependenciesInstaller.cs +++ b/src/Keystone.Cli/Configuration/DependenciesInstaller.cs @@ -11,6 +11,7 @@ using Keystone.Cli.Application.Utility; using Keystone.Cli.Application.Utility.Serialization; using Microsoft.Extensions.DependencyInjection; +using Console = Keystone.Cli.Application.Console; namespace Keystone.Cli.Configuration; @@ -28,6 +29,7 @@ public static void AddDependencies(this IServiceCollection services) => services .AddHttpClient() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/src/Keystone.Cli/Presentation/BrowseCommandController.cs b/src/Keystone.Cli/Presentation/BrowseCommandController.cs index a80db1c..abf18ed 100644 --- a/src/Keystone.Cli/Presentation/BrowseCommandController.cs +++ b/src/Keystone.Cli/Presentation/BrowseCommandController.cs @@ -1,5 +1,6 @@ using Cocona; using Keystone.Cli.Application.Commands.Browse; +using Keystone.Cli.Application.Utility; using Keystone.Cli.Domain; @@ -8,7 +9,7 @@ namespace Keystone.Cli.Presentation; /// /// The "browse" command controller. /// -public class BrowseCommandController(IBrowseCommand browseCommand) +public class BrowseCommandController(IConsole console, IBrowseCommand browseCommand) { [Command("browse", Description = "Opens the template repository in the default browser")] public int Browse([Argument(Description = "The template name")] string? templateName) @@ -21,7 +22,7 @@ public int Browse([Argument(Description = "The template name")] string? template } catch (KeyNotFoundException ex) { - Console.Error.WriteLine(ex.Message); + console.Error.WriteLine(ex.Message); return CliCommandResults.Error; } diff --git a/src/Keystone.Cli/Presentation/InfoCommandController.cs b/src/Keystone.Cli/Presentation/InfoCommandController.cs index fb58ad9..c9823c4 100644 --- a/src/Keystone.Cli/Presentation/InfoCommandController.cs +++ b/src/Keystone.Cli/Presentation/InfoCommandController.cs @@ -1,5 +1,6 @@ using Cocona; using Keystone.Cli.Application.Commands.Info; +using Keystone.Cli.Application.Utility; namespace Keystone.Cli.Presentation; @@ -7,7 +8,7 @@ namespace Keystone.Cli.Presentation; /// /// The "info" command controller. /// -public class InfoCommandController(IInfoCommand infoCommand) +public class InfoCommandController(IConsole console, IInfoCommand infoCommand) { [Command("info", Description = "Prints the template information")] public void Info() @@ -15,6 +16,6 @@ public void Info() var info = infoCommand.GetInfo(); var text = info.GetFormattedText(); - Console.WriteLine(text); + console.Out.WriteLine(text); } } diff --git a/src/Keystone.Cli/Presentation/NewCommandController.cs b/src/Keystone.Cli/Presentation/NewCommandController.cs index 64628f4..840d927 100644 --- a/src/Keystone.Cli/Presentation/NewCommandController.cs +++ b/src/Keystone.Cli/Presentation/NewCommandController.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using Cocona; using Keystone.Cli.Application.Commands.New; +using Keystone.Cli.Application.Utility; using Keystone.Cli.Domain; using Keystone.Cli.Domain.Policies; using Keystone.Cli.Presentation.ComponentModel.DataAnnotations; @@ -11,7 +12,7 @@ namespace Keystone.Cli.Presentation; /// /// The "new" command controller. /// -public class NewCommandController(INewCommand newCommand) +public class NewCommandController(IConsole console, INewCommand newCommand) { [Command("new", Description = "Creates a new project from a template")] public async Task NewAsync( @@ -46,13 +47,13 @@ await newCommand.CreateNewAsync( } catch (InvalidOperationException ex) { - await Console.Error.WriteLineAsync(ex.Message); + await console.Error.WriteLineAsync(ex.Message); return CliCommandResults.Error; } catch (KeyNotFoundException ex) { - await Console.Error.WriteLineAsync(ex.Message); + await console.Error.WriteLineAsync(ex.Message); return CliCommandResults.Error; } diff --git a/src/Keystone.Cli/Presentation/Project/SwitchTemplateSubCommand.cs b/src/Keystone.Cli/Presentation/Project/SwitchTemplateSubCommand.cs index fc10449..51f149f 100644 --- a/src/Keystone.Cli/Presentation/Project/SwitchTemplateSubCommand.cs +++ b/src/Keystone.Cli/Presentation/Project/SwitchTemplateSubCommand.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using Cocona; using Keystone.Cli.Application.Commands.Project; +using Keystone.Cli.Application.Utility; using Keystone.Cli.Domain; using Keystone.Cli.Domain.Project; using Keystone.Cli.Presentation.ComponentModel.DataAnnotations; @@ -11,7 +12,7 @@ namespace Keystone.Cli.Presentation.Project; /// /// The implementation of the "switch-template" sub-command for the project command. /// -public class SwitchTemplateSubCommand(IProjectCommand projectCommand) +public class SwitchTemplateSubCommand(IConsole console, IProjectCommand projectCommand) { public async Task SwitchTemplateAsync( [Argument(Description = "The name of the new template to switch to"), @@ -27,12 +28,12 @@ public async Task SwitchTemplateAsync( ? Path.GetFullPath(".") : Path.GetFullPath(projectPath); - Console.WriteLine($"Switching template to '{newTemplateName}' for project at '{fullPath}'."); + await console.Out.WriteLineAsync($"Switching template to '{newTemplateName}' for project at '{fullPath}'."); try { var result = await projectCommand.SwitchTemplateAsync(newTemplateName, fullPath, cancellationToken); - Console.WriteLine( + await console.Out.WriteLineAsync( result ? $"Switched to template '{newTemplateName}' successfully." : $"The project already uses template `{newTemplateName}`." @@ -42,13 +43,13 @@ public async Task SwitchTemplateAsync( } catch (KeyNotFoundException exception) { - await Console.Error.WriteLineAsync(exception.Message); + await console.Error.WriteLineAsync(exception.Message); return CliCommandResults.Error; } catch (ProjectNotLoadedException exception) { - await Console.Error.WriteLineAsync(exception.Message); + await console.Error.WriteLineAsync(exception.Message); return CliCommandResults.Error; } diff --git a/tests/Keystone.Cli.UnitTests/Application/Utility/NullConsole.cs b/tests/Keystone.Cli.UnitTests/Application/Utility/NullConsole.cs new file mode 100644 index 0000000..185816b --- /dev/null +++ b/tests/Keystone.Cli.UnitTests/Application/Utility/NullConsole.cs @@ -0,0 +1,31 @@ +using Keystone.Cli.Application.Utility; + + +namespace Keystone.Cli.UnitTests.Application.Utility; + +/// +/// The null implementation of that discards all output. +/// +/// +/// Use for testing purposes. +/// +public class NullConsole : IConsole +{ + /// + /// The only instance of . + /// + public static NullConsole Instance { get; } = new(); + + /// + /// The private constructor to prevent external instantiation. + /// + private NullConsole() + { + } + + /// + public TextWriter Out => TextWriter.Null; + + /// + public TextWriter Error => TextWriter.Null; +} diff --git a/tests/Keystone.Cli.UnitTests/Configuration/DependenciesInstallerTests.cs b/tests/Keystone.Cli.UnitTests/Configuration/DependenciesInstallerTests.cs index b0e0d78..7ae776b 100644 --- a/tests/Keystone.Cli.UnitTests/Configuration/DependenciesInstallerTests.cs +++ b/tests/Keystone.Cli.UnitTests/Configuration/DependenciesInstallerTests.cs @@ -22,6 +22,7 @@ public class DependenciesInstallerTests private static readonly Type[] ExpectedTypes = [ typeof(IBrowseCommand), + typeof(IConsole), typeof(IContentHashService), typeof(IEnvironmentFileSerializer), typeof(IFileSystemCopyService), diff --git a/tests/Keystone.Cli.UnitTests/Presentation/BrowseCommandControllerTests.cs b/tests/Keystone.Cli.UnitTests/Presentation/BrowseCommandControllerTests.cs index a77317c..4b3eeee 100644 --- a/tests/Keystone.Cli.UnitTests/Presentation/BrowseCommandControllerTests.cs +++ b/tests/Keystone.Cli.UnitTests/Presentation/BrowseCommandControllerTests.cs @@ -1,6 +1,7 @@ using Keystone.Cli.Application.Commands.Browse; using Keystone.Cli.Domain; using Keystone.Cli.Presentation; +using Keystone.Cli.UnitTests.Application.Utility; using NSubstitute; @@ -10,7 +11,7 @@ namespace Keystone.Cli.UnitTests.Presentation; public class BrowseCommandControllerTests { private static BrowseCommandController Ctor(IBrowseCommand? browseCommand = null) - => new(browseCommand ?? Substitute.For()); + => new(NullConsole.Instance, browseCommand ?? Substitute.For()); [Test] public void Browse_OnSuccess_ReturnsCliSuccess() diff --git a/tests/Keystone.Cli.UnitTests/Presentation/InfoCommandControllerTests.cs b/tests/Keystone.Cli.UnitTests/Presentation/InfoCommandControllerTests.cs index 4ec55a8..6d0812b 100644 --- a/tests/Keystone.Cli.UnitTests/Presentation/InfoCommandControllerTests.cs +++ b/tests/Keystone.Cli.UnitTests/Presentation/InfoCommandControllerTests.cs @@ -1,6 +1,7 @@ using Keystone.Cli.Application.Commands.Info; using Keystone.Cli.Domain; using Keystone.Cli.Presentation; +using Keystone.Cli.UnitTests.Application.Utility; using NSubstitute; @@ -10,7 +11,7 @@ namespace Keystone.Cli.UnitTests.Presentation; public class InfoCommandControllerTests { private static InfoCommandController Ctor(IInfoCommand? infoCommand = null) - => new(infoCommand ?? Substitute.For()); + => new(NullConsole.Instance, infoCommand ?? Substitute.For()); [Test] public void Info_ExecutesCommand() diff --git a/tests/Keystone.Cli.UnitTests/Presentation/NewCommandControllerTests.cs b/tests/Keystone.Cli.UnitTests/Presentation/NewCommandControllerTests.cs index 05418cb..ef8ab7e 100644 --- a/tests/Keystone.Cli.UnitTests/Presentation/NewCommandControllerTests.cs +++ b/tests/Keystone.Cli.UnitTests/Presentation/NewCommandControllerTests.cs @@ -2,6 +2,7 @@ using Keystone.Cli.Domain; using Keystone.Cli.Domain.Policies; using Keystone.Cli.Presentation; +using Keystone.Cli.UnitTests.Application.Utility; using NSubstitute; @@ -11,7 +12,7 @@ namespace Keystone.Cli.UnitTests.Presentation; public class NewCommandControllerTests { private static NewCommandController Ctor(INewCommand? newCommand = null) - => new(newCommand ?? Substitute.For()); + => new(NullConsole.Instance, newCommand ?? Substitute.For()); [Test] public async Task NewAsync_OnSuccess_ReturnsCliSuccessAsync() diff --git a/tests/Keystone.Cli.UnitTests/Presentation/Project/SwitchTemplateSubCommandTests.cs b/tests/Keystone.Cli.UnitTests/Presentation/Project/SwitchTemplateSubCommandTests.cs index 10869ba..35356f7 100644 --- a/tests/Keystone.Cli.UnitTests/Presentation/Project/SwitchTemplateSubCommandTests.cs +++ b/tests/Keystone.Cli.UnitTests/Presentation/Project/SwitchTemplateSubCommandTests.cs @@ -2,6 +2,7 @@ using Keystone.Cli.Domain; using Keystone.Cli.Domain.Project; using Keystone.Cli.Presentation.Project; +using Keystone.Cli.UnitTests.Application.Utility; using NSubstitute; using NSubstitute.ExceptionExtensions; @@ -12,7 +13,7 @@ namespace Keystone.Cli.UnitTests.Presentation.Project; public class SwitchTemplateSubCommandTests { private static SwitchTemplateSubCommand Ctor(IProjectCommand? projectCommand = null) - => new(projectCommand ?? Substitute.For()); + => new(NullConsole.Instance, projectCommand ?? Substitute.For()); [Test] public async Task SwitchTemplateAsync_OnSuccess_ReturnsCliSuccessAsync()