diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml index 45c655e..7e6a1db 100644 --- a/.github/workflows/release-binaries.yml +++ b/.github/workflows/release-binaries.yml @@ -3,8 +3,7 @@ name: Build and Publish Release on: push: tags: - - "v*" - workflow_dispatch: + - "v*.*.*" permissions: contents: write @@ -63,6 +62,8 @@ jobs: if [ "$TAG" != "$VERSION" ]; then echo "Tag does not match pyproject.toml" + echo "Tag: $TAG" + echo "Version: $VERSION" exit 1 fi @@ -76,27 +77,70 @@ jobs: - name: Build PyInstaller binary run: python scripts/build_pyinstaller.py - # Generate checksum - - name: Generate checksum + - name: Generate binary checksum shell: bash run: | - cd node_version/dist/native/${{ matrix.target }} - for f in explainthisrepo*; do - if command -v sha256sum >/dev/null 2>&1; then - sha256sum "$f" > "$f.sha256" + set -euo pipefail + + TARGET="${{ matrix.target }}" + DIR="node_version/dist/native/${TARGET}" + + if [[ "${TARGET}" == win-* ]]; then + BINARY="explainthisrepo.exe" + else + BINARY="explainthisrepo" + fi + + test -f "${DIR}/${BINARY}" || { + echo "Expected binary not found: ${DIR}/${BINARY}" + exit 1 + } + + cd "${DIR}" + + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "${BINARY}" > "${BINARY}.sha256" + else + shasum -a 256 "${BINARY}" > "${BINARY}.sha256" + fi + + - name: Verify binary checksum + shell: bash + run: | + set -euo pipefail + + TARGET="${{ matrix.target }}" + DIR="node_version/dist/native/${TARGET}" + + cd "${DIR}" + + if command -v sha256sum >/dev/null 2>&1; then + if [[ "${TARGET}" == win-* ]]; then + sha256sum -c explainthisrepo.exe.sha256 else - shasum -a 256 "$f" > "$f.sha256" + sha256sum -c explainthisrepo.sha256 + fi + else + if [[ "${TARGET}" == win-* ]]; then + EXPECTED="$(awk '{print $1}' explainthisrepo.exe.sha256)" + ACTUAL="$(shasum -a 256 explainthisrepo.exe | awk '{print $1}')" + else + EXPECTED="$(awk '{print $1}' explainthisrepo.sha256)" + ACTUAL="$(shasum -a 256 explainthisrepo | awk '{print $1}')" fi - done + + test "${EXPECTED}" = "${ACTUAL}" + fi - name: Upload native binary + checksum uses: actions/upload-artifact@v4 with: name: ${{ matrix.target }} path: node_version/dist/native/${{ matrix.target }} + if-no-files-found: error release: - name: Publish npm and GitHub Release + name: Package and Publish Release needs: build runs-on: ubuntu-latest @@ -112,13 +156,19 @@ jobs: - name: Extract canonical version shell: bash run: | + set -euo pipefail + mkdir -p .ci + VERSION="$(python scripts/get_version.py)" + printf '%s\n' "$VERSION" > .ci/version.txt - name: Validate git tag matches pyproject version shell: bash run: | + set -euo pipefail + if [[ "${GITHUB_REF}" != refs/tags/* ]]; then echo "This workflow must run from a git tag" exit 1 @@ -129,12 +179,16 @@ jobs: if [ "$TAG" != "$VERSION" ]; then echo "Tag does not match pyproject.toml" + echo "Tag: $TAG" + echo "Version: $VERSION" exit 1 fi - name: Rewrite manifests from canonical version shell: bash run: | + set -euo pipefail + python - <<'PY' import json import re @@ -146,6 +200,7 @@ jobs: package_json_path = Path("node_version/package.json") package_json = json.loads(package_json_path.read_text(encoding="utf-8")) package_json["version"] = version + package_json_path.write_text( json.dumps(package_json, indent=2) + "\n", encoding="utf-8", @@ -153,13 +208,19 @@ jobs: # node_version/package-lock.json package_lock_path = Path("node_version/package-lock.json") + if package_lock_path.exists(): - package_lock = json.loads(package_lock_path.read_text(encoding="utf-8")) + package_lock = json.loads( + package_lock_path.read_text(encoding="utf-8") + ) + package_lock["version"] = version packages = package_lock.get("packages") + if isinstance(packages, dict): root_pkg = packages.get("") + if isinstance(root_pkg, dict): root_pkg["version"] = version @@ -171,6 +232,7 @@ jobs: # dotnet_version/ExplainThisRepo.csproj csproj_path = Path("dotnet_version/ExplainThisRepo.csproj") csproj_text = csproj_path.read_text(encoding="utf-8") + new_text, count = re.subn( r".*?", f"{version}", @@ -178,8 +240,12 @@ jobs: count=1, flags=re.S, ) + if count != 1: - raise SystemExit("Could not update in ExplainThisRepo.csproj") + raise SystemExit( + "Could not update in ExplainThisRepo.csproj" + ) + csproj_path.write_text(new_text, encoding="utf-8") # Generated version files @@ -189,7 +255,10 @@ jobs: Path("node_version/_version.py"), ]: if version_file.exists(): - version_file.write_text(f'VERSION = "{version}"\n', encoding="utf-8") + version_file.write_text( + f'VERSION = "{version}"\n', + encoding="utf-8", + ) PY - name: Download native binaries @@ -197,72 +266,333 @@ jobs: with: path: artifacts - - name: Rehydrate native folder + - name: Rehydrate native folders and human-readable release assets shell: bash run: | - mkdir -p node_version/dist/native dotnet_version/native release + set -euo pipefail + + VERSION="$(cat .ci/version.txt)" + + mkdir -p \ + node_version/dist/native \ + dotnet_version/native \ + release for artifact in artifacts/*; do [ -d "$artifact" ] || continue - target="$(basename "$artifact")" - - mkdir -p "node_version/dist/native/$target" - mkdir -p "dotnet_version/native/$target" - cp "$artifact"/explainthisrepo* "node_version/dist/native/$target/" - cp "$artifact"/explainthisrepo* "dotnet_version/native/$target/" + TARGET="$(basename "$artifact")" - case "$target" in + case "$TARGET" in darwin-arm64) - release_name="ExplainThisRepo-macOS-Apple-Silicon-arm64" - binary="explainthisrepo" + RELEASE_NAME="ExplainThisRepo-macOS-Apple-Silicon-arm64" + BINARY="explainthisrepo" ;; + darwin-x64) - release_name="ExplainThisRepo-macOS-Intel-x64" - binary="explainthisrepo" + RELEASE_NAME="ExplainThisRepo-macOS-Intel-x64" + BINARY="explainthisrepo" ;; + linux-arm64) - release_name="ExplainThisRepo-Linux-ARM64" - binary="explainthisrepo" + RELEASE_NAME="ExplainThisRepo-Linux-ARM64" + BINARY="explainthisrepo" ;; + linux-x64) - release_name="ExplainThisRepo-Linux-x64" - binary="explainthisrepo" + RELEASE_NAME="ExplainThisRepo-Linux-x64" + BINARY="explainthisrepo" ;; + win-arm64) - release_name="ExplainThisRepo-Windows-ARM64.exe" - binary="explainthisrepo.exe" + RELEASE_NAME="ExplainThisRepo-Windows-ARM64.exe" + BINARY="explainthisrepo.exe" ;; + win-x64) - release_name="ExplainThisRepo-Windows-x64.exe" - binary="explainthisrepo.exe" + RELEASE_NAME="ExplainThisRepo-Windows-x64.exe" + BINARY="explainthisrepo.exe" ;; + *) - echo "Unknown target: $target" + echo "Unknown target: $TARGET" exit 1 ;; esac - cp "$artifact/$binary" "release/$release_name" - cp "$artifact/$binary.sha256" "release/$release_name.sha256" - done + test -f "$artifact/$BINARY" || { + echo "Missing binary: $artifact/$BINARY" + exit 1 + } + + test -f "$artifact/$BINARY.sha256" || { + echo "Missing checksum: $artifact/$BINARY.sha256" + exit 1 + } + + mkdir -p "node_version/dist/native/$TARGET" + mkdir -p "dotnet_version/native/$TARGET" + + cp "$artifact/$BINARY" \ + "node_version/dist/native/$TARGET/$BINARY" + + cp "$artifact/$BINARY.sha256" \ + "node_version/dist/native/$TARGET/$BINARY.sha256" + + cp "$artifact/$BINARY" \ + "dotnet_version/native/$TARGET/$BINARY" + + cp "$artifact/$BINARY.sha256" \ + "dotnet_version/native/$TARGET/$BINARY.sha256" - # HARD STOP if tampered - - name: Verify checksums + cp "$artifact/$BINARY" \ + "release/$RELEASE_NAME" + + cp "$artifact/$BINARY.sha256" \ + "release/$RELEASE_NAME.sha256" + done + + - name: Verify all native binary checksums shell: bash run: | - for checksum in $(find node_version/dist/native -name '*.sha256' | sort); do - dir="$(dirname "$checksum")" + set -euo pipefail + + for target_dir in node_version/dist/native/*; do + [ -d "$target_dir" ] || continue + + TARGET="$(basename "$target_dir")" + + if [[ "${TARGET}" == win-* ]]; then + BINARY="explainthisrepo.exe" + else + BINARY="explainthisrepo" + fi + + test -f "${target_dir}/${BINARY}" + test -f "${target_dir}/${BINARY}.sha256" + + ( + cd "${target_dir}" + sha256sum -c "${BINARY}.sha256" + ) + done + + - name: Verify exactly six native targets exist + shell: bash + run: | + set -euo pipefail + + EXPECTED_TARGETS=" + darwin-arm64 + darwin-x64 + linux-arm64 + linux-x64 + win-arm64 + win-x64 + " + + for target in $EXPECTED_TARGETS; do + test -f "node_version/dist/native/${target}/explainthisrepo" \ + || test -f "node_version/dist/native/${target}/explainthisrepo.exe" \ + || { + echo "Missing native target: ${target}" + exit 1 + } + done + + - name: Package CLI installer archives + shell: bash + run: | + set -euo pipefail + + VERSION="$(cat .ci/version.txt)" + + package_unix() { + TARGET="$1" + BINARY="$2" + + SOURCE_DIR="node_version/dist/native/${TARGET}" + ARCHIVE="explainthisrepo-v${VERSION}-cli-installer-${TARGET}.tar.gz" + + STAGING="$(mktemp -d)" + + cleanup_staging() { + rm -rf "${STAGING}" + } + + trap cleanup_staging RETURN + + cp "${SOURCE_DIR}/${BINARY}" "${STAGING}/${BINARY}" + + ( + cd "${STAGING}" + tar -czf "${GITHUB_WORKSPACE}/release/${ARCHIVE}" "${BINARY}" + ) + + sha256sum \ + "release/${ARCHIVE}" \ + > "release/${ARCHIVE}.sha256" + } + + package_windows() { + TARGET="$1" + BINARY="explainthisrepo.exe" + + SOURCE_DIR="node_version/dist/native/${TARGET}" + ARCHIVE="explainthisrepo-v${VERSION}-cli-installer-${TARGET}.zip" + + STAGING="$(mktemp -d)" + + cleanup_staging() { + rm -rf "${STAGING}" + } + + trap cleanup_staging RETURN + + cp "${SOURCE_DIR}/${BINARY}" "${STAGING}/${BINARY}" + ( - cd "$dir" - sha256sum -c "$(basename "$checksum")" + cd "${STAGING}" + zip -q "${GITHUB_WORKSPACE}/release/${ARCHIVE}" "${BINARY}" ) + + sha256sum \ + "release/${ARCHIVE}" \ + > "release/${ARCHIVE}.sha256" + } + + package_unix "linux-x64" "explainthisrepo" + package_unix "linux-arm64" "explainthisrepo" + package_unix "darwin-x64" "explainthisrepo" + package_unix "darwin-arm64" "explainthisrepo" + + package_windows "win-x64" + package_windows "win-arm64" + + - name: Verify CLI installer archives + shell: bash + run: | + set -euo pipefail + + VERSION="$(cat .ci/version.txt)" + + for archive in release/*-cli-installer-*.tar.gz; do + test -f "${archive}" + sha256sum -c "${archive}.sha256" done - - name: Verify native binaries exist + for archive in release/*-cli-installer-*.zip; do + test -f "${archive}" + sha256sum -c "${archive}.sha256" + done + + - name: Verify archive contents shell: bash run: | - find node_version/dist/native -type f | sort + set -euo pipefail + + VERSION="$(cat .ci/version.txt)" + + verify_tar() { + ARCHIVE="$1" + + CONTENTS="$(tar -tzf "${ARCHIVE}")" + + EXPECTED="explainthisrepo" + + if [ "${CONTENTS}" != "${EXPECTED}" ]; then + echo "Invalid archive contents: ${ARCHIVE}" + echo "Expected:" + echo "${EXPECTED}" + echo "Actual:" + echo "${CONTENTS}" + exit 1 + fi + } + + verify_zip() { + ARCHIVE="$1" + + CONTENTS="$(unzip -Z1 "${ARCHIVE}")" + + EXPECTED="explainthisrepo.exe" + + if [ "${CONTENTS}" != "${EXPECTED}" ]; then + echo "Invalid archive contents: ${ARCHIVE}" + echo "Expected:" + echo "${EXPECTED}" + echo "Actual:" + echo "${CONTENTS}" + exit 1 + fi + } + + verify_tar \ + "release/explainthisrepo-v${VERSION}-cli-installer-linux-x64.tar.gz" + + verify_tar \ + "release/explainthisrepo-v${VERSION}-cli-installer-linux-arm64.tar.gz" + + verify_tar \ + "release/explainthisrepo-v${VERSION}-cli-installer-darwin-x64.tar.gz" + + verify_tar \ + "release/explainthisrepo-v${VERSION}-cli-installer-darwin-arm64.tar.gz" + + verify_zip \ + "release/explainthisrepo-v${VERSION}-cli-installer-win-x64.zip" + + verify_zip \ + "release/explainthisrepo-v${VERSION}-cli-installer-win-arm64.zip" + + - name: Verify release asset layout + shell: bash + run: | + set -euo pipefail + + VERSION="$(cat .ci/version.txt)" + + expected_assets=" + ExplainThisRepo-Linux-x64 + ExplainThisRepo-Linux-x64.sha256 + ExplainThisRepo-Linux-ARM64 + ExplainThisRepo-Linux-ARM64.sha256 + ExplainThisRepo-macOS-Apple-Silicon-arm64 + ExplainThisRepo-macOS-Apple-Silicon-arm64.sha256 + ExplainThisRepo-macOS-Intel-x64 + ExplainThisRepo-macOS-Intel-x64.sha256 + ExplainThisRepo-Windows-ARM64.exe + ExplainThisRepo-Windows-ARM64.exe.sha256 + ExplainThisRepo-Windows-x64.exe + ExplainThisRepo-Windows-x64.exe.sha256 + explainthisrepo-v${VERSION}-cli-installer-linux-x64.tar.gz + explainthisrepo-v${VERSION}-cli-installer-linux-x64.tar.gz.sha256 + explainthisrepo-v${VERSION}-cli-installer-linux-arm64.tar.gz + explainthisrepo-v${VERSION}-cli-installer-linux-arm64.tar.gz.sha256 + explainthisrepo-v${VERSION}-cli-installer-darwin-arm64.tar.gz + explainthisrepo-v${VERSION}-cli-installer-darwin-arm64.tar.gz.sha256 + explainthisrepo-v${VERSION}-cli-installer-darwin-x64.tar.gz + explainthisrepo-v${VERSION}-cli-installer-darwin-x64.tar.gz.sha256 + explainthisrepo-v${VERSION}-cli-installer-win-x64.zip + explainthisrepo-v${VERSION}-cli-installer-win-x64.zip.sha256 + explainthisrepo-v${VERSION}-cli-installer-win-arm64.zip + explainthisrepo-v${VERSION}-cli-installer-win-arm64.zip.sha256 + " + + for asset in $expected_assets; do + test -f "release/${asset}" || { + echo "Missing release asset: ${asset}" + exit 1 + } + done + + ASSET_COUNT="$(find release -maxdepth 1 -type f | wc -l | tr -d ' ')" + + if [ "${ASSET_COUNT}" != "24" ]; then + echo "Expected 24 release assets, found ${ASSET_COUNT}" + find release -maxdepth 1 -type f -printf '%f\n' | sort + exit 1 + fi - name: Setup Node uses: actions/setup-node@v4 @@ -276,7 +606,9 @@ jobs: working-directory: node_version shell: bash run: | - PACKAGE_NAME=$(node -p "require('./package.json').name") + set -euo pipefail + + PACKAGE_NAME="$(node -p "require('./package.json').name")" VERSION="$(cat ../.ci/version.txt)" if npm view "$PACKAGE_NAME@$VERSION" version >/dev/null 2>&1; then @@ -298,20 +630,23 @@ jobs: - name: Verify package contains native binaries working-directory: node_version + shell: bash run: | - TAR=$(ls *.tgz) - tar -tf "$TAR" | grep "dist/native" || (echo "Missing native binaries in package" && exit 1) + set -euo pipefail + + TAR="$(ls *.tgz)" + + tar -tf "$TAR" | + grep "dist/native" || + ( + echo "Missing native binaries in package" + exit 1 + ) - name: Package check working-directory: node_version run: npm pack --dry-run - - name: Publish to npm - working-directory: node_version - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: npm publish --access public - - name: Setup .NET uses: actions/setup-dotnet@v4 with: @@ -321,10 +656,14 @@ jobs: working-directory: dotnet_version shell: bash run: | - PACKAGE_ID=$(grep -oPm1 "(?<=)[^<]+" ExplainThisRepo.csproj) + set -euo pipefail + + PACKAGE_ID="$(grep -oPm1 "(?<=)[^<]+" ExplainThisRepo.csproj)" VERSION="$(cat ../.ci/version.txt)" - if curl -fsSL "https://api.nuget.org/v3-flatcontainer/${PACKAGE_ID,,}/index.json" | grep "\"$VERSION\"" >/dev/null; then + if curl -fsSL \ + "https://api.nuget.org/v3-flatcontainer/${PACKAGE_ID,,}/index.json" | + grep "\"$VERSION\"" >/dev/null; then echo "Version already exists on NuGet" exit 1 fi @@ -333,14 +672,25 @@ jobs: working-directory: dotnet_version run: dotnet pack -c Release + - name: Publish GitHub Release + uses: softprops/action-gh-release@v3 + with: + files: release/* + generate_release_notes: true + fail_on_unmatched_files: true + + - name: Publish to npm + working-directory: node_version + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm publish --access public + - name: Publish to NuGet working-directory: dotnet_version env: NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} - run: dotnet nuget push nupkg/*.nupkg -k ${{ secrets.NUGET_API_KEY }} -s https://api.nuget.org/v3/index.json - - - name: Publish GitHub Release - uses: softprops/action-gh-release@v2 - with: - files: release/* - generate_release_notes: true \ No newline at end of file + run: | + dotnet nuget push \ + nupkg/*.nupkg \ + -k "${NUGET_API_KEY}" \ + -s https://api.nuget.org/v3/index.json \ No newline at end of file diff --git a/README.md b/README.md index e425231..c699cfe 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,8 @@ ExplainThisRepo supports multiple installation methods: - pip (Python package) - npm (prebuilt native binaries) - .NET global tool -- standalone binaries +- standalone install CLI +- manual standalone binaries ### Quick install @@ -71,6 +72,42 @@ npx explainthisrepo owner/repo dotnet tool install -g ExplainThisRepo ``` +#### Standalone CLI Install + +Install ExplainThisRepo with one command: + +```bash +curl -fsSL https://cli.explainthisrepo.com/install.sh | sh +``` + +The shell installer automatically detects your operating system and CPU architecture, downloads the correct native binary, verifies it, and installs `explainthisrepo` to `/usr/local/bin`. + +### Supported platforms + +| Operating system| Architecture +|---------------- |---------------| +| Linux| x64 | +| Linux| ARM64 | +| macOS| Intel | +| macOS| Apple Silicon | +| Windows| x64 | +| Windows| ARM64 | + +The one-command shell installer currently supports Linux, macOS and Windows through with Git Bash/WSL. Windows (through Powershell or Commnd Prompt) users can download the appropriate Windows executable from the latest [release](https://github.com/calchiwo/ExplainThisRepo/releases/latest). + +You do not need Python, pip, Node.js, npm to use the standalone CLI installation. + +The installer downloads a prebuilt native ExplainThisRepo binary. + +#### Manual standalone binaries installation + +If you prefer to install manually, download the binary for your platform from the latest [GitHub release](https://github.com/calchiwo/ExplainThisRepo/releases/latest). + +For installation details, troubleshooting, and the distribution architecture, see: + +- [docs/INSTALLATION.md](docs/INSTALLATION.md) +- [docs/RELEASE-DISTRIBUTION.md](docs/RELEASE-DISTRIBUTION.md) + #### Run ```bash @@ -84,6 +121,9 @@ explainthisrepo owner/repo explain-this-repo owner/repo etr owner/repo explain owner/repo +ExplainThisRepo owner/repo +explainthis owner/repo +explain-this owner/repo ``` Replace `owner/repo` with the GitHub repository identifier (e.g., `facebook/react`, `torvalds/linux`). diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index bdcacfc..75f7a25 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -113,6 +113,7 @@ ExplainThisRepo can be installed in multiple ways: - `npm` for Node users - `dotnet tool` for .NET users - standalone binaries for direct download +- standalone install CLI All of them run the same core Python engine compiled into native binaries. @@ -124,24 +125,287 @@ Prebuilt standalone binaries are available for macOS, Linux, and Windows. Download the latest release: [ExplainThisRepo latest releases](https://github.com/calchiwo/ExplainThisRepo/releases/latest) -Or install directly: -macOS +## Option 5: Install as a standalone native CLI. + +You do not need Python, pip, Node.js, npm, or any other runtime to use the standalone installation. + +### One-command installation + +ExplainThisRepo provides native CLI binaries for Linux, macOS and Windows across x64 and ARM64 targets. + +The one-command shell installer works on linux, macOS, and Windows through Git Bash or WSL. + +#### Linux, macOS and Windows (with Git Bash/WSL) + +The recommended installation method is: ```bash -curl -L https://github.com/calchiwo/ExplainThisRepo/releases/latest/download/explainthisrepo-darwin-arm64 -o explainthisrepo -chmod +x explainthisrepo +curl -fsSL https://cli.explainthisrepo.com/install.sh | sh +``` + +or without the `https://` scheme: + +```bash +curl -fsSL cli.explainthisrepo.com/install.sh | sh +``` + +The installer automatically: + +1. Detects the operating system. +2. Detects the CPU architecture. +3. Maps the machine to a supported target. +4. Resolves the latest stable ExplainThisRepo release. +5. Downloads the matching CLI installer archive. +6. Downloads the archive SHA256 checksum. +7. Verifies the archive before extracting it. +8. Extracts the executable. +9. Installs it to `/usr/local/bin`. +10. Makes the executable available as `explainthisrepo`. + +After installation: + +```bash +explainthisrepo +``` + +#### Windows with Powershell or Commnd Prompt + +The current `install.sh` is a Unix shell installer and therefore handles Linux, Windows (with Git Bash/WSL) and macOS. + +Powershell/CMD doesn't use the `install.sh` installer, it uses the published [Windows executable or ZIP archive](https://github.com/calchiwo/ExplainThisRepo/releases/latest). + +Windows users can download the appropriate Windows installer archive, for: + +- Windows x64 +- Windows ARM64 + +from the GitHub Release for the version you want, extract `explainthisrepo.exe`, and place it somewhere on your `PATH`. + +### Where the binary is installed + +The default installation directory is: + +`/usr/local/bin` + +The resulting executable is: + +`/usr/local/bin/explainthisrepo` + +If `/usr/local/bin` requires administrator access, the installer uses `sudo`. + +The installer does not ask for sudo before it is necessary. + +### Custom installation directory + +The installer supports an environment variable for controlled installations and testing: + +```bash +EXPLAINTHISREPO_INSTALL_DIR="$HOME/.local/bin" \ +curl -fsSL https://cli.explainthisrepo.com/install.sh | sh +``` + +Make sure the selected directory is in your `PATH`. + +For example: + +```bash +export PATH="$HOME/.local/bin:$PATH" +``` + +The installer cannot modify the parent shell's environment, so a `PATH` change may need to be made separately. + +### What the installer downloads + +Each release contains a platform-specific CLI installer archive. + +`explainthisrepo-v-cli-installer-.` + +For example: + +`explainthisrepo-v0.29.0-cli-installer-linux-x64.tar.gz` + +The archive contains exactly one executable: + +`explainthisrepo` + +There are no nested directories, README files, version files, or other installer files inside the archive. + +The checksum is distributed separately: + +`explainthisrepo-v0.29.0-cli-installer-linux-x64.tar.gz.sha256` + +The installer verifies the archive before extracting it. + +### Manual installation + +If you do not want to use the installer, download the appropriate binary from the GitHub release. + +The release provides human-readable native binaries such as: + +``` +ExplainThisRepo-Linux-x64 +ExplainThisRepo-Linux-ARM64 +ExplainThisRepo-macOS-Apple-Silicon-arm64 +ExplainThisRepo-macOS-Intel-x64 +ExplainThisRepo-Windows-ARM64.exe +ExplainThisRepo-Windows-x64.exe ``` -Linux +For Linux and macOS, make the downloaded binary executable: ```bash -curl -L https://github.com/calchiwo/ExplainThisRepo/releases/latest/download/explainthisrepo-linux-x64 -o explainthisrepo chmod +x explainthisrepo ``` -Windows (PowerShell) +Then place it somewhere in your `PATH`, for example: + +```bash +sudo mv explainthisrepo /usr/local/bin/explainthisrepo +``` + +Verify: -```powershell -curl -L https://github.com/calchiwo/ExplainThisRepo/releases/latest/download/explainthisrepo-win-x64.exe -o explainthisrepo.exe +```bash +explainthisrepo ``` + +### Updating + +The one-command installer always resolves the latest stable GitHub release. + +To update an existing installation, use: + +```bash +curl -fsSL https://cli.explainthisrepo.com/install.sh | sh +``` + +It replaces the existing executable with the binary from the latest stable release. + +### Troubleshooting + +`Unsupported operating system` + +The current shell installer supports: + +- Linux +- macOS +- Windows (with Git Bash/WSL) + +Windows (with Powershell/CMD) is not handled by `install.sh`. + +`Unsupported architecture` + +The current shell installer supports: + +- x64 +- ARM64 + +Other architectures are rejected rather than receiving an incorrect binary. + +`Could not determine the latest ExplainThisRepo release` + +The installer could not retrieve the latest release information from GitHub. + +Check that the machine has network access and try again. + +`Could not download ...` + +The requested installer archive does not appear to be available at the expected release URL. + +This normally indicates a release packaging problem rather than a local installation problem. + +The release must contain an archive matching the installer's naming convention. + +`SHA256 verification failed` + +Do not continue the installation. + +The installer stops before extracting the archive when its SHA256 checksum does not match. + +"sudo is not available" + +The default installation directory requires administrator access and "sudo" is not available. + +Use a user-owned installation directory instead: + +```bash +EXPLAINTHISREPO_INSTALL_DIR="$HOME/.local/bin" \ +curl -fsSL https://cli.explainthisrepo.com/install.sh | sh +``` + +Then ensure that directory is in your "PATH". + +`explainthisrepo: command not found` + +First check whether the binary exists: + +```bash +ls -l /usr/local/bin/explainthisrepo +``` + +Then check whether `/usr/local/bin` is in your `PATH`: + +```bash +echo "$PATH" +``` + +If it is missing, add it to your shell's `PATH`. + +### Installation architecture + +The installation system is intentionally split into two layers. + +The release pipeline creates the native binaries: + +``` +PyInstaller + ↓ +six native binaries +``` + +The release pipeline then creates the installer archives: + +``` +native binary + ↓ +CLI installer archive + ↓ +GitHub Release +``` + +The installer consumes those archives: + +``` +curl | sh + ↓ +detect OS + ↓ +detect architecture + ↓ +target + ↓ +latest release + ↓ +download archive + ↓ +verify SHA256 + ↓ +extract + ↓ +install +``` + +The installer does not build anything. + +It only selects and installs an already-built native executable. + +### Installation endpoint + +The public installation command is: + +`https://cli.explainthisrepo.com/install.sh` + +This URL is a stable distribution interface. + +The implementation behind the URL may change, but the public installation command should remain stable. \ No newline at end of file diff --git a/docs/RELEASE-DISTRIBUTION.md b/docs/RELEASE-DISTRIBUTION.md new file mode 100644 index 0000000..b076a12 --- /dev/null +++ b/docs/RELEASE-DISTRIBUTION.md @@ -0,0 +1,712 @@ +# ExplainThisRepo Release Distribution + +This document describes how ExplainThisRepo moves from a version tag to installable native binaries. + +The distribution system has one primary goal: + +``` +git tag + ↓ +automated build + ↓ +validated native binaries + ↓ +installer archives + ↓ +GitHub Release + ↓ +curl | sh + ↓ +installed CLI +``` + +## 1. Distribution model + +ExplainThisRepo has one CLI and two public release artifact classes. + +Human-readable native binaries + +These are intended for people browsing a GitHub Release: + +``` +ExplainThisRepo-Linux-x64 +ExplainThisRepo-Linux-ARM64 +ExplainThisRepo-macOS-Apple-Silicon-arm64 +ExplainThisRepo-macOS-Intel-x64 +ExplainThisRepo-Windows-ARM64.exe +ExplainThisRepo-Windows-x64.exe +``` + +These names are deliberately human-readable. + +CLI installer archives + +These are intended for automated installation: + +``` +explainthisrepo-v-cli-installer-linux-x64.tar.gz +explainthisrepo-v-cli-installer-linux-arm64.tar.gz +explainthisrepo-v-cli-installer-darwin-arm64.tar.gz +explainthisrepo-v-cli-installer-darwin-x64.tar.gz +explainthisrepo-v-cli-installer-win-x64.zip +explainthisrepo-v-cli-installer-win-arm64.zip +``` + +The `cli-installer` component explicitly identifies the purpose of these artifacts. + +The two artifact classes are not replacements for each other. + +``` +CI target + │ + ├── human-readable binary + │ + └── CLI installer archive +``` + +## 2. Supported targets + +The build matrix currently contains six targets: + +``` +darwin-arm64 +darwin-x64 +linux-arm64 +linux-x64 +win-arm64 +win-x64 +``` + +These target names are machine-oriented identifiers. + +They are used internally by: + +- GitHub Actions +- PyInstaller +- native binary directories +- release packaging +- installer routing + +The target names should remain stable because they are part of the distribution system's internal contract. + +## 3. Native build layer + +The native executable is produced by: + +`scripts/build_pyinstaller.py` + +The build system already knows how to build all six targets. + +This distribution layer does not modify the PyInstaller build logic. + +The build pipeline therefore remains: + +``` +source code + ↓ +PyInstaller + ↓ +native executable +``` + +Examples: + +``` +linux-x64 +└── explainthisrepo + +linux-arm64 +└── explainthisrepo + +darwin-x64 +└── explainthisrepo + +darwin-arm64 +└── explainthisrepo + +win-x64 +└── explainthisrepo.exe + +win-arm64 +└── explainthisrepo.exe +``` + +## 4. Checksums + +Every native binary receives a SHA256 checksum. + +For example: + +``` +explainthisrepo +explainthisrepo.sha256 +``` + +The release pipeline verifies these checksums before continuing. + +The installer archives receive their own checksums. + +For example: + +``` +explainthisrepo-v0.29.0-cli-installer-linux-x64.tar.gz +explainthisrepo-v0.29.0-cli-installer-linux-x64.tar.gz.sha256 +``` + +The archive checksum is the important checksum for installation because the installer downloads the archive. + +The installation integrity flow is: + +``` +download archive + ↓ +download archive checksum + ↓ +verify archive + ↓ +extract + ↓ +install +``` + +## 5. Installer archive contract + +Every CLI installer archive has a strict content contract. + +Linux and macOS archives contain: + +`explainthisrepo` + +Windows archives contain: + +`explainthisrepo.exe` + +There are no: + +- nested directories +- README files +- version files +- metadata files +- checksums inside the archive + +The checksum is a separate release asset. + +This makes extraction deterministic. + +The installer does not need to search the extracted directory for the executable. + +6. Release artifact layout + +A release such as `v0.29.0` contains both public artifact classes. + +``` +GitHub Release v0.29.0 +│ +├── Human-readable binaries +│ ├── ExplainThisRepo-Linux-x64 +│ ├── ExplainThisRepo-Linux-x64.sha256 +│ ├── ExplainThisRepo-Linux-ARM64 +│ ├── ExplainThisRepo-Linux-ARM64.sha256 +│ ├── ExplainThisRepo-macOS-Apple-Silicon-arm64 +│ ├── ExplainThisRepo-macOS-Apple-Silicon-arm64.sha256 +│ ├── ExplainThisRepo-macOS-Intel-x64 +│ ├── ExplainThisRepo-macOS-Intel-x64.sha256 +│ ├── ExplainThisRepo-Windows-ARM64.exe +│ ├── ExplainThisRepo-Windows-ARM64.exe.sha256 +│ ├── ExplainThisRepo-Windows-x64.exe +│ └── ExplainThisRepo-Windows-x64.exe.sha256 +│ +└── CLI installer archives + ├── explainthisrepo-v0.29.0-cli-installer-linux-x64.tar.gz + ├── explainthisrepo-v0.29.0-cli-installer-linux-x64.tar.gz.sha256 + ├── explainthisrepo-v0.29.0-cli-installer-linux-arm64.tar.gz + ├── explainthisrepo-v0.29.0-cli-installer-linux-arm64.tar.gz.sha256 + ├── explainthisrepo-v0.29.0-cli-installer-darwin-arm64.tar.gz + ├── explainthisrepo-v0.29.0-cli-installer-darwin-arm64.tar.gz.sha256 + ├── explainthisrepo-v0.29.0-cli-installer-darwin-x64.tar.gz + ├── explainthisrepo-v0.29.0-cli-installer-darwin-x64.tar.gz.sha256 + ├── explainthisrepo-v0.29.0-cli-installer-win-x64.zip + ├── explainthisrepo-v0.29.0-cli-installer-win-x64.zip.sha256 + ├── explainthisrepo-v0.29.0-cli-installer-win-arm64.zip + └── explainthisrepo-v0.29.0-cli-installer-win-arm64.zip.sha256 +``` + +## 7. GitHub Actions pipeline + +A release starts with a semantic version tag: + +``` +git tag v0.29.0 +git push origin v0.29.0 +``` + +The workflow is triggered by the tag. + +``` +v0.29.0 + ↓ +GitHub Actions +``` + +### Build stage + +The six matrix jobs run independently: + +``` +darwin-arm64 +darwin-x64 +linux-arm64 +linux-x64 +win-arm64 +win-x64 +``` + +Each job: + +1. Checks out the repository. +2. Installs Python. +3. Validates the tag against the canonical project version. +4. Installs build dependencies. +5. Runs `scripts/build_pyinstaller.py`. +6. Generates a binary SHA256 checksum. +7. Verifies the checksum. +8. Uploads the native binary and checksum as a workflow artifact. + +If one target fails, the other targets can finish, but the release job does not run until the required build matrix succeeds. + +## 8. Release stage + +The release job downloads all six build artifacts. + +It then reconstructs: + +``` +node_version/dist/native/ +dotnet_version/native/ +``` + +This is important because the native binaries are also consumed by the npm and .NET distributions. + +The release job then creates the human-readable release assets. + +After that it creates the CLI installer archives. + +The installer archives are generated from the already-validated native binaries. + +The pipeline therefore does not build a second copy of the executable. + +``` +validated binary + │ + ├── npm package + │ + ├── NuGet package + │ + ├── human-readable release asset + │ + └── CLI installer archive +``` + +## 9. Archive packaging + +Unix targets use: + +`.tar.gz` + +Windows targets use: + +`.zip` + +Examples: + +``` +explainthisrepo-v0.29.0-cli-installer-linux-x64.tar.gz +explainthisrepo-v0.29.0-cli-installer-linux-arm64.tar.gz +explainthisrepo-v0.29.0-cli-installer-darwin-x64.tar.gz +explainthisrepo-v0.29.0-cli-installer-darwin-arm64.tar.gz +explainthisrepo-v0.29.0-cli-installer-win-x64.zip +explainthisrepo-v0.29.0-cli-installer-win-arm64.zip +``` + +The archive is created from a temporary staging directory so unrelated files cannot accidentally enter the archive. + +The pipeline then inspects the archive contents. + +An archive containing unexpected files causes the release job to fail. + +## 10. Installer + +The stable installation entry point is: + +```bash +curl -fsSL https://cli.explainthisrepo.com/install.sh | sh +``` + +The installer owns platform selection. + +The release pipeline's responsibility is to publish correctly named artifacts. + +The installer performs: + +``` +uname + ↓ +operating system + ↓ +CPU architecture + ↓ +target + ↓ +latest stable version + ↓ +archive filename + ↓ +download + ↓ +SHA256 verification + ↓ +extraction + ↓ +installation +``` + +For example: + +``` +macOS Apple Silicon + ↓ +darwin-arm64 + ↓ +v0.29.0 + ↓ +explainthisrepo-v0.29.0-cli-installer-darwin-arm64.tar.gz +``` + +## 11. Why the installer owns platform selection + +The installer runs on the user's machine. + +It knows: + +``` +uname -s +uname -m +``` + +Therefore it is the correct place to determine: + +``` +OS +architecture +target +``` + +The GitHub Release does not need to understand the user's machine. + +It only needs to expose the deterministic artifacts. + +This keeps the system boundary clean: + +``` +Release system + │ + │ produces artifacts + ▼ +GitHub Release + │ + │ serves artifacts + ▼ +Installer + │ + │ selects artifact + ▼ +User machine +``` + +## 12. Stable installer URL + +The public installer URL is: + +``` +https://cli.explainthisrepo.com/install.sh +``` + +The URL should be treated as a stable API. + +Users should not need to know: + +- the GitHub repository name +- the latest version +- the release tag +- the archive filename +- the architecture mapping +- the download URL + +The installer abstracts those details. + +## 13. Version resolution + +The installer queries GitHub's latest release endpoint. + +The endpoint returns the latest published full release rather than a draft or prerelease. + +The installer extracts the release tag, for example: + +`v0.29.0` + +It then constructs the expected artifact name: + +`explainthisrepo-v0.29.0-cli-installer-linux-x64.tar.gz` + +The version is therefore part of the artifact URL but does not need to be hardcoded into "install.sh". + +## 14. Failure behavior + +The distribution system is designed to fail closed. + +Examples: + +``` +unsupported OS + → stop + +unsupported architecture + → stop + +GitHub unavailable + → stop + +latest release cannot be determined + → stop + +archive unavailable + → stop + +checksum unavailable + → stop + +checksum mismatch + → stop + +invalid archive contents + → stop + +installation permission failure + → stop +``` + +The installer must never silently install a different platform's binary. + +## 15. Important system invariants + +These properties must remain true. + +### Invariant 1: Target naming + +The supported internal target names are: + +``` +darwin-arm64 +darwin-x64 +linux-arm64 +linux-x64 +win-arm64 +win-x64 +``` + +### Invariant 2: Archive naming + +The naming contract is: + +`explainthisrepo-v-cli-installer-.` + +where: + +``` +Linux/macOS → tar.gz +Windows → zip +``` + +### Invariant 3: Archive contents + +Unix: + +`explainthisrepo` + +Windows: + +`explainthisrepo.exe` + +Nothing else. + +### Invariant 4: Checksums + +Every installer archive has a separate SHA256 checksum. + +### Invariant 5: Human-readable release assets + +The existing human-readable binary names remain unchanged. + +### Invariant 6: Native build source + +`scripts/build_pyinstaller.py` remains the source of the native executable build. + +### Invariant 7: Installer independence + +The standalone installer must not require: + +``` +Python +pip +Node.js +npm +``` + +## 16. Adding another platform + +Adding a new platform requires changes to the distribution contract. + +At minimum: + +1. Add the target to the GitHub Actions matrix. +2. Ensure `scripts/build_pyinstaller.py` supports it. +3. Define its human-readable release asset name. +4. Define its installer archive format. +5. Add archive packaging. +6. Add checksum generation. +7. Add installer OS/architecture mapping. +8. Add release-layout validation. +9. Add the platform to the supported-platform documentation. +10. Test installation on a clean machine. + +The target should not be considered supported until the complete path works: + +``` +build + ↓ +checksum + ↓ +archive + ↓ +release + ↓ +installer selection + ↓ +download + ↓ +verification + ↓ +installation + ↓ +execution +``` + +## 17. Release testing + +Before treating a new distribution change as complete, test the actual user path. + +Linux: + +```bash +curl -fsSL https://cli.explainthisrepo.com/install.sh | sh +``` + +Widows (with Git Bash/WSL) + +```bash +curl -fsSL https://cli.explainthisrepo.com/install.sh | sh +``` + +macOS: + +```bash +curl -fsSL https://cli.explainthisrepo.com/install.sh | sh +``` + +Then: + +```bash +explainthisrepo +``` + +The test environment should be as close as possible to a machine that has never installed ExplainThisRepo before. + +The important test is not: + +`Did PyInstaller succeed?` + +The important test is: + +`Can a new machine install and execute the correct binary?` + +## 18. Distribution system + +The complete system is: + +``` + git tag v0.29.0 + │ + ▼ + GitHub Actions + │ + ┌────────────┴────────────┐ + │ │ + ▼ ▼ + Build six targets Validate checksums + │ │ + └────────────┬────────────┘ + ▼ + Native binaries + │ + ┌────────────┼────────────┐ + │ │ │ + ▼ ▼ ▼ + npm NuGet Release assets + │ + ┌───────┴────────┐ + │ │ + ▼ ▼ + Human binary CLI installer + archive + │ + ▼ + GitHub Release + │ + ▼ + cli.explainthisrepo.com + │ + ▼ + install.sh + │ + ┌──────────────┴──────────────┐ + │ │ + ▼ ▼ + OS + arch latest version + │ │ + └──────────────┬──────────────┘ + ▼ + archive URL + │ + ▼ + download + │ + ▼ + SHA256 verify + │ + ▼ + extract + │ + ▼ + /usr/local/bin/explainthisrepo + │ + ▼ + explainthisrepo +``` + +The CLI is the payload. + +The release pipeline is the distribution mechanism. + +The installer is the machine-facing interface to that distribution mechanism. + +> The release pipeline described here is implemented by [RELEASE-ARCHITECTURE.md](RELEASE-ARCHITECTURE.md) \ No newline at end of file diff --git a/docs/release-architecture.md b/docs/release-architecture.md index e30b9d5..d37966d 100644 --- a/docs/release-architecture.md +++ b/docs/release-architecture.md @@ -2,7 +2,18 @@ This document describes the full release pipeline. -It is a deterministic, multi-language build, release and publishing system with build-time materialization, artifact fan-out, multi-registry publishing, integrity verification and more. +It is a deterministic, multi-language build, release and publishing system with: + +- one canonical version source +- build-time version materialization +- six-target native builds +- artifact fan-out +- artifact rehydration +- multi-registry publishing +- native binary integrity verification +- installer archive generation +- installer archive integrity verification +- GitHub Release publication ## Core Principle @@ -14,7 +25,15 @@ The system enforces: - One canonical version extracted per release - One materialization phase before any build tool runs - One consistent version propagated across all ecosystems -- pyproject.toml is the ONLY human-edited version source; So humans (contributors) should only touch pyproject.toml +- One native binary build per target +- One validated native binary reused across distribution formats +- One release job responsible for aggregation and publication + +`pyproject.toml` is the ONLY human-edited version source + +Contributors should only change the version in `pyproject.toml`. + +CI materializes that version into the files required by the npm and .NET packaging systems. ## System Overview @@ -24,87 +43,244 @@ The pipeline is split into three phases: 2. Materialization phase (version + manifest normalization) 3. Release phase (publishing + verification) + +The build stage runs six native builds in parallel. + +The materialization stage runs once after all six builds succeed, normalizing versions and manifests before assembling the release artifacts. + +The release stage runs once after all six builds succeed. + +The overall system is: + +``` +pyproject.toml + ↓ +Git tag + ↓ +GitHub Actions + ↓ +┌──────────────────────────────────────────────┐ +│ Build matrix │ +│ │ +│ darwin-arm64 darwin-x64 │ +│ linux-arm64 linux-x64 │ +│ win-arm64 win-x64 │ +│ │ +└──────────────────────────────────────────────┘ + ↓ +validated native binaries + ↓ +artifact rehydration + ↓ +┌──────────────┬──────────────┬────────────────┐ +│ npm package │ NuGet tool │ GitHub Release │ +└──────────────┴──────────────┴────────────────┘ + ↓ + release distribution + ↓ + human-readable binaries + + + CLI installer archives +``` + ## 1. Build Phase (Matrix Execution) -Runs per target: +The build phase runs once for each supported target. + +The current build matrix contains six targets: - darwin-arm64 -- linux-x64 +- darwin-x64 - linux-arm64 +- linux-x64 +- win-arm64 - win-x64 -Steps: +Each target has its own GitHub Actions runner. + +Current runner mapping: -- Checkout repository -- Setup Python environment -- Install dependencies -- Build PyInstaller binary -- Generate native checksums -- Upload artifacts per platform +``` +darwin-arm64 → macos-latest +darwin-x64 → macos-15-intel -Output: +linux-x64 → ubuntu-latest +linux-arm64 → ubuntu-24.04-arm -- Platform-specific native binaries -- SHA256 checksum files +win-arm64 → windows-11-arm +win-x64 → windows-latest +``` -Important constraint: +The matrix uses: + +``` +fail-fast: false +``` -No version rewriting happens here. -This phase is intentionally isolated from release logic. +This allows the individual matrix jobs to finish independently when another target fails. -## 2. Release Phase (Canonical Pipeline) -Runs once after all build jobs complete. +However, the `release` job depends on the complete `build` job, so a failed required target prevents the release stage from running. -### Step 1: Canonical Version Extraction +### Build steps -```text -pyproject.toml → CI extracts version → .ci/version.txt +Each build job: + +1. Checks out the repository. +2. Sets up Python 3.12. +3. Validates the Git tag against the canonical project version. +4. Installs Python dependencies. +5. Runs `scripts/build_pyinstaller.py`. +6. Generates a SHA256 checksum for the native binary. +7. Uploads the native binary and checksum as a workflow artifact. + +The output for each target is: + +- native binary +- native binary SHA256 checksum + +The native build output is placed under: + +``` +node_version/dist/native// ``` -This value becomes immutable for the rest of the pipeline. +Examples: -No other file is a source of truth. +``` +node_version/dist/native/linux-x64/ +└── explainthisrepo + +node_version/dist/native/linux-arm64/ +└── explainthisrepo + +node_version/dist/native/darwin-x64/ +└── explainthisrepo + +node_version/dist/native/darwin-arm64/ +└── explainthisrepo + +node_version/dist/native/win-x64/ +└── explainthisrepo.exe + +node_version/dist/native/win-arm64/ +└── explainthisrepo.exe +``` + +### Build isolation + +The build phase does not rewrite the repository's version-dependent manifests. + +Its responsibility is native artifact generation. + +Version materialization for npm, .NET, and runtime version files happens in the release job. + + +## 2. Canonical Version Extraction +The release job extracts the canonical version from: -### Step 2: Tag Validation Gate +``` +pyproject.toml + ↓ +scripts/get_version.py + ↓ +.ci/version.txt +``` -Ensures release is intentional and consistent: +The resulting value becomes the canonical release version for the remainder of the release job. -- Git tag must exist +For example: -- Tag version must match canonical version +``` +pyproject.toml + ↓ +0.29.0 + ↓ +.ci/version.txt +``` +No other project file becomes a version source of truth. -If mismatch occurs: -- pipeline fails immediately +## 3. Tag Validation Gate -### Step 3: Manifest Materialization (Critical Step) +The workflow requires a Git tag. -CI rewrites all version-dependent artifacts BEFORE any tool executes. +The tag is expected to use the form: -Rewritten files: +``` +v +``` -- node_version/package.json +For example: -- node_version/package-lock.json +``` +v0.29.0 +``` -- dotnet_version/ExplainThisRepo.csproj +The workflow removes the leading `v` and compares the result with the canonical version extracted from `pyproject.toml`. -- runtime version files (_version.py variants) +``` +Git tag: v0.29.0 + ↓ + 0.29.0 +pyproject.toml: 0.29.0 + + ↓ + MATCH +``` + +If they do not match, the pipeline stops. + +This prevents a release tag from publishing packages whose versions disagree with the canonical project version. + + +## 4. Manifest Materialization + +After canonical version extraction and tag validation, CI rewrites version-dependent files. + +The current materialized files are: + +- `node_version/package.json` +- `node_version/package-lock.json` +- `dotnet_version/ExplainThisRepo.csproj` + +- runtime version files: + - `_version.py + - `explain_this_repo/_version.py` + - `node_version/_version.py` + +The materialization flow is: + +``` +.ci/version.txt + ↓ +┌──────────────────────────────────────┐ +│ version-dependent project artifacts │ +└──────────────────────────────────────┘ + ↓ +npm metadata +.NET metadata +runtime version files +``` + +The purpose is to ensure that every publishing ecosystem receives the same release version. Rules: +- `pyproject.toml` remains the source of truth. - No tool sees unmaterialized state - - No dependency install occurs before rewrite - - All ecosystems receive identical version +- CI performs the version propagation. +- npm does not determine the release version. +- NuGet does not determine the release version. +- Runtime version files are generated from the canonical version. -### Step 4: Dependency Restoration +## 5. Dependency Restoration After materialization: @@ -119,144 +295,594 @@ At this point: All tools operate on a fully normalized filesystem state. -### Step 5: Artifact Rehydration -Downloaded build outputs are merged: +## 6. Native Artifact Rehydration + +The release job downloads the six build artifacts. + +They are then rehydrated into the repository's distribution trees: + +- `node_version/dist/native//` +- `dotnet_version/native//` + +A third staging location is created: + +- `release/` + +The native binaries are therefore reused by multiple distribution systems. + +The release job does not rebuild them. + +This creates a unified artifact tree.: + +``` +GitHub Actions build artifacts + ↓ + artifact download + ↓ + artifact rehydration + ↓ +┌────────────┼─────────────┐ +│ │ │ +▼ ▼ ▼ +npm .NET GitHub Release +``` + + +## 7. Native Binary Integrity Verification + +Before publishing, the release job verifies the SHA256 checksums generated during the build phase. + +The verification operates against the rehydrated native binaries. + +The flow is: + +``` +native binary + + +native binary.sha256 + ↓ +SHA256 verification + ↓ +continue only if valid +``` + +If verification fails, the release job stops. + +This creates an integrity gate between native artifact generation and publication. + + +## 8. Human-Readable GitHub Release Assets + +Each validated native binary is also copied into the `release/` staging directory using a human-readable filename. + +The current names are: + +``` +ExplainThisRepo-Linux-x64 +ExplainThisRepo-Linux-x64.sha256 + +ExplainThisRepo-Linux-ARM64 +ExplainThisRepo-Linux-ARM64.sha256 + +ExplainThisRepo-macOS-Apple-Silicon-arm64 +ExplainThisRepo-macOS-Apple-Silicon-arm64.sha256 + +ExplainThisRepo-macOS-Intel-x64 +ExplainThisRepo-macOS-Intel-x64.sha256 + +ExplainThisRepo-Windows-ARM64.exe +ExplainThisRepo-Windows-ARM64.exe.sha256 + +ExplainThisRepo-Windows-x64.exe +ExplainThisRepo-Windows-x64.exe.sha256 +``` + +These assets are intended for people browsing a GitHub Release and wanting the native executable directly. + +Their names are deliberately human-readable. -- node_version/dist/native/ +They are separate from the machine-oriented target identifiers used internally by CI. -- dotnet_version/native/ -- release/ staging folder +## 9. CLI Installer Archives -This creates a unified artifact tree. +The release pipeline also packages the validated native binaries into deterministic CLI installer archives. -### Step 6: Integrity Verification +The archive naming convention is: -Hard validation gates: +``` +explainthisrepo-v-cli-installer-. +``` + +Current archives: + +``` +explainthisrepo-v0.29.0-cli-installer-linux-x64.tar.gz +explainthisrepo-v0.29.0-cli-installer-linux-arm64.tar.gz + +explainthisrepo-v0.29.0-cli-installer-darwin-arm64.tar.gz +explainthisrepo-v0.29.0-cli-installer-darwin-x64.tar.gz + +explainthisrepo-v0.29.0-cli-installer-win-x64.zip +explainthisrepo-v0.29.0-cli-installer-win-arm64.zip +``` + +Unix targets use: + +`.tar.gz` + +Windows targets use: -- SHA256 checksum verification +`.zip` -- File presence validation +The installer archive is created from the already-built native binary. -- Native binary existence checks +There is no second PyInstaller build. +The same validated native executable fans out into multiple distribution formats: + +``` + validated native binary + │ + ┌──────────────┼───────────────┐ + │ │ │ + ▼ ▼ ▼ + npm package NuGet GitHub Release + │ + ┌───────┴────────┐ + │ │ + ▼ ▼ + raw executable installer archive +``` -If any check fails: release is aborted -### Step 7: Node.js Packaging Pipeline +## 10. Installer Archive Contract -Steps: +Each installer archive has a strict content contract. -- version already injected from CI materialization +Linux and macOS archives contain exactly: -- npm ci installs dependencies +`explainthisrepo` -- metadata sync step runs +Windows archives contain exactly: -- npm pack generates tarball +`explainthisrepo.exe` -- tarball validated for native binaries +The archive contains no: -- npm publish executes +- nested directories +- README files +- version files +- metadata files +- checksum files +The checksum is published as a separate GitHub Release asset. + +This makes extraction deterministic. + +The installer knows the executable name in advance and does not need to search the extracted directory. + + +# 11. Installer Archive Integrity + +Each installer archive receives its own SHA256 checksum. + +For example: + +``` +explainthisrepo-v0.29.0-cli-installer-linux-x64.tar.gz +explainthisrepo-v0.29.0-cli-installer-linux-x64.tar.gz.sha256 +``` + +The archive checksum is separate from the checksum of the raw native binary. + +The installation integrity flow is: + +``` +download archive + ↓ +download archive checksum + ↓ +verify archive SHA256 + ↓ +extract + ↓ +install executable +``` + +The archive itself is therefore the integrity boundary used by the standalone installer. + + +## 12. npm Distribution + +The rehydrated native binaries are included in the npm package. + +The npm release pipeline: + +1. Uses the CI-materialized version. +2. Checks whether the version already exists. +3. Runs `npm ci`. +4. Runs `npm run sync-meta`. +5. Runs `npm pack`. +6. Verifies that the package contains native binaries. +7. Runs the package check. +8. Publishes to npm. + +The npm package does not build the native executables. + +It consumes the binaries produced by the native build matrix. Invariant: -npm never determines version itself. It only consumes CI-materialized state. +``` +PyInstaller build + ↓ +validated native binaries + ↓ +npm package +``` -### Step 8: .NET Packaging Pipeline -Steps: +## 13. .NET Distribution -- csproj already rewritten by CI +The rehydrated native binaries are also included in the .NET Global Tool package. -- dotnet pack generates NuGet package +The .NET release pipeline: -- version existence check against NuGet registry +1. Uses the CI-materialized `.csproj` version. +2. Checks whether the version already exists on NuGet. +3. Runs `dotnet pack`. +4. Publishes the generated package to NuGet. -- dotnet nuget push publishes package +The .NET package does not build a separate copy of the native executable. +It consumes the native binaries produced by the build matrix. Invariant: -NuGet package version is CI-derived, not project-derived at runtime. +``` +PyInstaller build + ↓ +validated native binaries + ↓ +NuGet package +``` + -### Step 9: GitHub Release Publication +## 14. GitHub Release Publication -Final step: +The final release stage publishes everything staged under: -- Upload all artifacts in `release/` +`release/` -- Generate release notes +The GitHub Release contains: -- Publish GitHub release +``` +Human-readable native binaries ++ +native binary checksums ++ +CLI installer archives ++ +installer archive checksums +``` +GitHub Releases therefore act as the public artifact distribution layer. -This is the user-facing artifact layer. +A release such as `v0.29.0` contains both artifact classes: + +``` +GitHub Release v0.29.0 +│ +├── Human-readable native binaries +│ ├── ExplainThisRepo-Linux-x64 +│ ├── ExplainThisRepo-Linux-x64.sha256 +│ ├── ExplainThisRepo-Linux-ARM64 +│ ├── ExplainThisRepo-Linux-ARM64.sha256 +│ ├── ExplainThisRepo-macOS-Apple-Silicon-arm64 +│ ├── ExplainThisRepo-macOS-Apple-Silicon-arm64.sha256 +│ ├── ExplainThisRepo-macOS-Intel-x64 +│ ├── ExplainThisRepo-macOS-Intel-x64.sha256 +│ ├── ExplainThisRepo-Windows-ARM64.exe +│ ├── ExplainThisRepo-Windows-ARM64.exe.sha256 +│ ├── ExplainThisRepo-Windows-x64.exe +│ └── ExplainThisRepo-Windows-x64.exe.sha256 +│ +└── CLI installer archives + ├── explainthisrepo-v0.29.0-cli-installer-linux-x64.tar.gz + ├── explainthisrepo-v0.29.0-cli-installer-linux-x64.tar.gz.sha256 + ├── explainthisrepo-v0.29.0-cli-installer-linux-arm64.tar.gz + ├── explainthisrepo-v0.29.0-cli-installer-linux-arm64.tar.gz.sha256 + ├── explainthisrepo-v0.29.0-cli-installer-darwin-arm64.tar.gz + ├── explainthisrepo-v0.29.0-cli-installer-darwin-arm64.tar.gz.sha256 + ├── explainthisrepo-v0.29.0-cli-installer-darwin-x64.tar.gz + ├── explainthisrepo-v0.29.0-cli-installer-darwin-x64.tar.gz.sha256 + ├── explainthisrepo-v0.29.0-cli-installer-win-x64.zip + ├── explainthisrepo-v0.29.0-cli-installer-win-x64.zip.sha256 + ├── explainthisrepo-v0.29.0-cli-installer-win-arm64.zip + └── explainthisrepo-v0.29.0-cli-installer-win-arm64.zip.sha256 +``` -## System Model +## 15. System Model -This system behaves like a compiler pipeline: +The release system behaves like a compiler pipeline. ``` pyproject.toml - ↓ -version extraction (compiler frontend) - ↓ -manifest materialization (IR generation) - ↓ -build tools (execution stage) - ↓ + ↓ +canonical version extraction + ↓ +Git tag validation + ↓ +version materialization + ↓ +native build matrix + ↓ artifact aggregation - ↓ -publishing targets (npm, NuGet, GitHub) + ↓ +integrity verification + ↓ +distribution fan-out + ↓ +┌──────────────┬──────────────┬──────────────────┐ +│ npm │ NuGet │ GitHub Release │ +│ │ │ │ +│ native │ native │ raw binaries │ +│ binaries │ binaries │ installer │ +│ │ │ archives │ +└──────────────┴──────────────┴──────────────────┘ ``` -## Critical Invariant +The important distinction is that native compilation happens once per target. -At no point may a build tool execute against raw repository state. +Distribution packaging happens afterward. -Materialization MUST always occur before: -- npm ci +## 16. Critical Invariants -- dotnet restore / pack +#### Invariant 1: Canonical version -- npm pack +`pyproject.toml` is the only human-edited version source. -- nuget publish +#### Invariant 2: Tag consistency -- PyInstaller +The Git tag must match the canonical project version. -- any version-sensitive operation +#### Invariant 3: Six supported native targets -## Failure Modes Prevented +The current target contract is: -This architecture eliminates: +``` +darwin-arm64 +darwin-x64 +linux-arm64 +linux-x64 +win-arm64 +win-x64 +``` + +#### Invariant 4: One native build per target + +The native executable is built once by: + +`scripts/build_pyinstaller.py` + +All later distribution formats consume that built executable. + +#### Invariant 5: Native artifact integrity + +Native binary checksums must verify before publication continues. + +#### Invariant 6: Installer archive naming + +The archive naming contract is: + +`explainthisrepo-v-cli-installer-.` + +#### Invariant 7: Installer archive contents + +Unix archives contain: + +``` +explainthisrepo +``` + +Windows archives contain: + +``` +explainthisrepo.exe +``` + +Nothing else. + +#### Invariant 8: Installer archive integrity + +Every installer archive has a separate SHA256 checksum. + +#### Invariant 9: Human-readable release assets + +The existing human-readable native binary names remain stable. + +#### Invariant 10: Distribution independence + +The standalone installer does not require: -- cross-language version drift +Python +pip +Node.js +npm -- tag/package mismatch errors -- manual sync errors between ecosystems +## 17. Failure Modes Prevented -- inconsistent build outputs +The architecture prevents or detects: -- “works locally but fails in CI” version bugs +- canonical version drift +- Git tag/package version mismatch +- cross-language version mismatch +- missing native binaries +- corrupted native artifacts +- missing release targets +- incorrectly named installer archives +- invalid installer archive contents +- missing installer checksums +- checksum mismatches +- accidental publication of unvalidated native artifacts +- rebuilding different native binaries for different distribution formats -## Design Summary +The release job stops when a required validation fails. -This is not a CI script. -It is a deterministic release compiler that: +## 18. Adding Another Platform -- extracts a canonical version +Adding another platform requires changes across the complete distribution path. + +At minimum: + +1. Add the target to the GitHub Actions matrix. +2. Ensure `scripts/build_pyinstaller.py` supports the target. +3. Define the runner. +4. Define the human-readable release asset name. +5. Define the executable filename. +6. Define the installer archive format. +7. Add release packaging for the target. +8. Add checksum generation and verification. +9. Add installer target mapping. +10. Add release-layout validation. +11. Update supported-platform documentation. +12. Test installation on a clean machine. + +A platform is not complete when its binary builds. + +The complete path must work: + +``` +build + ↓ +checksum + ↓ +artifact upload + ↓ +artifact rehydration + ↓ +archive packaging + ↓ +release publication + ↓ +installer selection + ↓ +download + ↓ +verification + ↓ +installation + ↓ +execution +``` + + +## 19. Release Testing + +The important test is the complete user path, not only whether PyInstaller succeeds. + +For Unix-like environments, test: + + +```bash +curl -fsSL https://cli.explainthisrepo.com/install.sh | sh +``` + +Then: + +```bash +explainthisrepo +``` + +Testing should cover the supported native targets and should be performed against environments that are as close as possible to fresh machines. + +The key question is: + +`Can a new machine receive the correct native executable from the published release and execute it successfully?` + + +## 20. Complete Distribution System + +The complete release system is: + +``` + pyproject.toml + │ + ▼ + Git tag v0.29.0 + │ + ▼ + GitHub Actions + │ + ┌──────────┴──────────┐ + │ │ + ▼ ▼ + Build six targets Tag validation + │ + ▼ + Native binaries + │ + ▼ + Native checksums + │ + ▼ + Artifact upload + │ + ▼ + Release job + │ + ┌─────────┴─────────┐ + │ │ + ▼ ▼ + Version materialization Artifact rehydration + │ │ + └─────────┬─────────┘ + ▼ + Integrity checks + │ + ▼ + Distribution fan-out + │ + ┌────────────┼───────────────────┐ + │ │ │ + ▼ ▼ ▼ + npm NuGet GitHub Release + │ + ┌─────────────┴─────────────┐ + │ │ + ▼ ▼ + Human-readable CLI installer + binaries archives + │ │ + │ SHA256 checksums + │ │ + └─────────────┬─────────────┘ + ▼ + GitHub Release + │ + ▼ + cli.explainthisrepo.com + │ + ▼ + install.sh + │ + ┌─────────┴─────────┐ + │ │ + ▼ ▼ + OS + arch latest release + │ │ + └─────────┬─────────┘ + ▼ +``` -- materializes a consistent build state -- executes all tooling on normalized inputs +## Release Distribution -- produces reproducible multi-ecosystem releases \ No newline at end of file +For the artifact packaging, distribution model, installer archives, and installation flow, see [RELEASE-DISTRIBUTION.md](RELEASE-DISTRIBUTION.md). \ No newline at end of file diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..32dda8d --- /dev/null +++ b/install.sh @@ -0,0 +1,320 @@ +#!/usr/bin/env sh + +set -eu + +REPO="calchiwo/ExplainThisRepo" +BINARY_NAME="explainthisrepo" +API_URL="https://api.github.com/repos/${REPO}/releases/latest" +RELEASE_BASE_URL="https://github.com/${REPO}/releases/download" + +INSTALL_DIR="${EXPLAINTHISREPO_INSTALL_DIR:-/usr/local/bin}" + +TMP_DIR="" + +cleanup() { + if [ -n "${TMP_DIR}" ] && [ -d "${TMP_DIR}" ]; then + rm -rf "${TMP_DIR}" + fi +} + +trap cleanup EXIT INT TERM + +fail() { + echo "Error: $*" >&2 + exit 1 +} + +info() { + echo "==> $*" +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || fail "Required command not found: $1" +} + +# Prerequisites + +require_command uname +require_command curl +require_command tar +require_command mktemp +require_command chmod +require_command mv +require_command mkdir +require_command rm + +# sha256sum exists on Linux. +# shasum is normally available on macOS. +if command -v sha256sum >/dev/null 2>&1; then + SHA256_TOOL="sha256sum" +elif command -v shasum >/dev/null 2>&1; then + SHA256_TOOL="shasum" +else + fail "Neither sha256sum nor shasum is available" +fi + +# Detect operating system + +OS="$(uname -s)" + +case "${OS}" in + Linux) + OS_TARGET="linux" + ;; + + Darwin) + OS_TARGET="darwin" + ;; + + *) + fail "Unsupported operating system: ${OS}. Supported systems are Linux and macOS." + ;; +esac + +# Detect architecture + +MACHINE="$(uname -m)" + +case "${MACHINE}" in + x86_64|amd64) + ARCH="x64" + ;; + + arm64|aarch64) + ARCH="arm64" + ;; + + *) + fail "Unsupported architecture: ${MACHINE}. Supported architectures are x64 and arm64." + ;; +esac + +TARGET="${OS_TARGET}-${ARCH}" + +# Resolve latest release + +info "Detecting latest ExplainThisRepo release..." + +RELEASE_JSON="$( + curl \ + --fail \ + --silent \ + --show-error \ + --location \ + --retry 3 \ + --retry-delay 1 \ + --header "Accept: application/vnd.github+json" \ + --header "X-GitHub-Api-Version: 2026-03-10" \ + "${API_URL}" +)" || fail "Could not contact GitHub to determine the latest release." + +VERSION="$( + printf '%s\n' "${RELEASE_JSON}" | + sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | + head -n 1 +)" + +[ -n "${VERSION}" ] || + fail "Could not determine the latest ExplainThisRepo release." + +case "${VERSION}" in + v[0-9]*) + ;; + *) + fail "GitHub returned an invalid release tag: ${VERSION}" + ;; +esac + +# Construct artifact names + +ARCHIVE_NAME="${BINARY_NAME}-${VERSION}-cli-installer-${TARGET}.tar.gz" +CHECKSUM_NAME="${ARCHIVE_NAME}.sha256" + +ARCHIVE_URL="${RELEASE_BASE_URL}/${VERSION}/${ARCHIVE_NAME}" +CHECKSUM_URL="${RELEASE_BASE_URL}/${VERSION}/${CHECKSUM_NAME}" + +info "Release: ${VERSION}" +info "Platform: ${TARGET}" +info "Archive: ${ARCHIVE_NAME}" + +# Temporary workspace + +TMP_DIR="$(mktemp -d 2>/dev/null || mktemp -d -t explainthisrepo)" + +ARCHIVE_PATH="${TMP_DIR}/${ARCHIVE_NAME}" +CHECKSUM_PATH="${TMP_DIR}/${CHECKSUM_NAME}" +EXTRACT_DIR="${TMP_DIR}/extracted" + +mkdir -p "${EXTRACT_DIR}" + +# Download archive + +info "Downloading installer archive..." + +curl \ + --fail \ + --silent \ + --show-error \ + --location \ + --retry 3 \ + --retry-delay 1 \ + --output "${ARCHIVE_PATH}" \ + "${ARCHIVE_URL}" || + fail "Could not download ${ARCHIVE_NAME}" + +# Download checksum + +info "Downloading SHA256 checksum..." + +curl \ + --fail \ + --silent \ + --show-error \ + --location \ + --retry 3 \ + --retry-delay 1 \ + --output "${CHECKSUM_PATH}" \ + "${CHECKSUM_URL}" || + fail "Could not download ${CHECKSUM_NAME}" + +# Verify archive integrity + +info "Verifying archive integrity..." + +case "${SHA256_TOOL}" in + sha256sum) + ( + cd "${TMP_DIR}" + sha256sum -c "${CHECKSUM_NAME}" + ) || fail "SHA256 verification failed." + ;; + + shasum) + EXPECTED_HASH="$( + sed -n 's/^\([0-9a-fA-F]\{64\}\).*/\1/p' "${CHECKSUM_PATH}" | + head -n 1 + )" + + [ -n "${EXPECTED_HASH}" ] || + fail "Invalid SHA256 checksum file." + + ACTUAL_HASH="$( + shasum -a 256 "${ARCHIVE_PATH}" | + awk '{print $1}' + )" + + if [ "${EXPECTED_HASH}" != "${ACTUAL_HASH}" ]; then + fail "SHA256 verification failed." + fi + ;; +esac + +info "SHA256 verification passed." + +# Extract archive + +info "Extracting archive..." + +tar \ + --extract \ + --gzip \ + --file "${ARCHIVE_PATH}" \ + --directory "${EXTRACT_DIR}" || + fail "Could not extract ${ARCHIVE_NAME}" + +# Validate archive contents + +EXPECTED_BINARY="${BINARY_NAME}" + +FILE_COUNT="$( + find "${EXTRACT_DIR}" -type f -print | + wc -l | + tr -d ' ' +)" + +[ "${FILE_COUNT}" = "1" ] || + fail "Installer archive is invalid: expected exactly one file." + +EXTRACTED_BINARY="${EXTRACT_DIR}/${EXPECTED_BINARY}" + +[ -f "${EXTRACTED_BINARY}" ] || + fail "Installer archive is invalid: expected ${EXPECTED_BINARY}." + +# Reject nested directories or unexpected paths. +DIRECTORY_COUNT="$( + find "${EXTRACT_DIR}" -type d -mindepth 1 -print | + wc -l | + tr -d ' ' +)" + +[ "${DIRECTORY_COUNT}" = "0" ] || + fail "Installer archive is invalid: it contains nested directories." + +# Prepare installation directory + +if [ ! -d "${INSTALL_DIR}" ]; then + info "Creating ${INSTALL_DIR}..." + + if mkdir -p "${INSTALL_DIR}" 2>/dev/null; then + : + elif command -v sudo >/dev/null 2>&1; then + sudo mkdir -p "${INSTALL_DIR}" + else + fail "Cannot create ${INSTALL_DIR} and sudo is not available." + fi +fi + +# Install binary + +chmod 0755 "${EXTRACTED_BINARY}" || + fail "Could not make ${BINARY_NAME} executable." + +DESTINATION="${INSTALL_DIR}/${BINARY_NAME}" + +if [ -w "${INSTALL_DIR}" ]; then + mv "${EXTRACTED_BINARY}" "${DESTINATION}" || + fail "Could not install ${BINARY_NAME} to ${INSTALL_DIR}." +else + if command -v sudo >/dev/null 2>&1; then + info "Administrator permission is required to install to ${INSTALL_DIR}." + sudo mv "${EXTRACTED_BINARY}" "${DESTINATION}" || + fail "Could not install ${BINARY_NAME} to ${INSTALL_DIR}." + else + fail "Cannot write to ${INSTALL_DIR} and sudo is not available." + fi +fi + +# Ensure the final installed file is executable. +if [ ! -x "${DESTINATION}" ]; then + if command -v sudo >/dev/null 2>&1 && [ ! -w "${INSTALL_DIR}" ]; then + sudo chmod 0755 "${DESTINATION}" + else + chmod 0755 "${DESTINATION}" + fi +fi + +# Verify installation + +if [ ! -x "${DESTINATION}" ]; then + fail "Installation completed but ${DESTINATION} is not executable." +fi + +info "Installed ExplainThisRepo ${VERSION}." +info "Location: ${DESTINATION}" + +# /usr/local/bin is normally already in PATH. +# A shell process cannot modify the PATH of its parent shell, so +# explicitly explain the only remaining user-side issue if needed. + +case ":${PATH}:" in + *":${INSTALL_DIR}:"*) + info "Run: ${BINARY_NAME}" + ;; + + *) + echo + echo "The binary was installed successfully, but ${INSTALL_DIR} is not in your PATH." + echo "Add ${INSTALL_DIR} to your PATH, then run:" + echo " ${BINARY_NAME}" + ;; +esac \ No newline at end of file