diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..73cad4e --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,9 @@ +# Link the MSVC C runtime statically so ink.exe runs on a clean Windows install +# without the Visual C++ Redistributable (VCRUNTIME140.dll). Kept here rather +# than in release.yml so local Windows builds produce the same binary. +# Note: a RUSTFLAGS environment variable replaces these flags entirely. +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] + +[target.aarch64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa26019..da3fcbc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,16 +59,26 @@ jobs: - name: Build deb and rpm for both arches run: | set -euo pipefail - printf '#!/bin/sh\necho stub\n' > ink + # Stub binary that answers `man` and `completions ` like the + # real one, so packaging/stage.sh runs exactly as in release.yml. + cat > ink <<'STUB' + #!/bin/sh + case "$1" in + man) printf '.TH INK 1\n.SH NAME\nink \\- stub\n' ;; + completions) echo "# stub $2 completions for ink" ;; + *) echo stub ;; + esac + STUB chmod +x ink + packaging/stage.sh ./ink dist for arch in amd64 arm64; do for fmt in deb rpm; do VERSION=0.0.0 ARCH="$arch" nfpm package -f packaging/nfpm.yaml -p "$fmt" done done rm -f ink - count=$(ls ink-md*.deb ink-md*.rpm | wc -l) - [ "$count" -eq 4 ] || { echo "expected 4 packages, got $count"; exit 1; } + set -- ink-md*.deb ink-md*.rpm + [ "$#" -eq 4 ] || { echo "expected 4 packages, got $#: $*"; exit 1; } - name: Install and remove the deb run: | @@ -76,8 +86,42 @@ jobs: sudo dpkg -i ink-md_0.0.0_amd64.deb command -v ink dpkg -L ink-md + for f in /usr/share/man/man1/ink.1.gz /usr/share/bash-completion/completions/ink \ + /usr/share/zsh/vendor-completions/_ink /usr/share/fish/vendor_completions.d/ink.fish; do + dpkg -L ink-md | grep -qx "$f" || { echo "::error::deb is missing $f"; exit 1; } + done sudo dpkg -r ink-md + - name: Check the rpm layout + run: | + set -euo pipefail + sudo apt-get install -y -qq rpm >/dev/null + set -- ink-md-0.0.0*.x86_64.rpm + files=$(rpm -qlp "$1") + echo "$files" + for f in /usr/bin/ink /usr/share/man/man1/ink.1.gz /usr/share/zsh/site-functions/_ink; do + printf '%s\n' "$files" | grep -qx "$f" || { echo "::error::rpm is missing $f"; exit 1; } + done + + # Keep the declared minimum Rust version honest: build with exactly the + # `rust-version` from Cargo.toml so a dependency bump that needs a newer + # compiler fails here instead of for users on that toolchain. + msrv: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Read rust-version + id: msrv + run: | + v=$(sed -n 's/^rust-version = "\([^"]*\)".*/\1/p' Cargo.toml) + [ -n "$v" ] || { echo "::error::no rust-version in Cargo.toml"; exit 1; } + echo "MSRV: $v" + echo "version=$v" >> "$GITHUB_OUTPUT" + - uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ steps.msrv.outputs.version }} + - run: cargo check --locked + audit: runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 03788dd..3257e6e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,40 +1,111 @@ name: Release +# Tag pushes (v*) build, package and publish. Pull requests that touch the +# release pipeline, and manual runs, execute the same build/package/smoke-test +# jobs but never publish: every publishing job is gated on a tag push. That way +# a broken matrix entry or packaging step shows up on the PR, not at tag time. on: push: tags: - 'v*' + pull_request: + paths: + - '.github/workflows/release.yml' + - 'packaging/**' + - 'install.sh' + - 'install.ps1' + - 'Cargo.toml' + - '.cargo/**' + workflow_dispatch: +# Read-only by default; only the tag-gated `release` job gets write access. permissions: - contents: write + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + CARGO_TERM_COLOR: always jobs: build: + name: build ${{ matrix.artifact }} runs-on: ${{ matrix.os }} strategy: + # Let every target finish so one failure doesn't hide another. + fail-fast: false matrix: - # Linux builders are pinned to ubuntu-22.04 on purpose: the binary links - # against the runner's glibc, so this sets the minimum glibc (2.35) for - # every Linux user — raw binary, .deb/.rpm and Homebrew on Linux alike - # (issue #6: an ubuntu-latest/24.04 build needed GLIBC_2.39 and broke - # Ubuntu 22.04 LTS). Bumping this runner raises that floor for everyone; - # the "glibc baseline check" step below fails the release if it moves. + # glibc Linux builders are pinned to ubuntu-22.04(-arm) on purpose: the + # binary links against the runner's glibc, so this sets the minimum + # glibc (2.35) for every glibc Linux user — raw binary, .deb/.rpm and + # Homebrew on Linux alike (issue #6: an ubuntu-latest/24.04 build + # needed GLIBC_2.39 and broke Ubuntu 22.04 LTS). Bumping this runner + # raises that floor for everyone; the "glibc baseline check" step below + # fails the release if it moves. Older-glibc and musl systems use the + # static musl builds instead. + # + # `smoke` says how the built binary is executed on the runner: + # native, qemu- (qemu-user; fine for static binaries) or rosetta. include: - os: ubuntu-22.04 target: x86_64-unknown-linux-gnu artifact: ink-linux-amd64 - - os: ubuntu-22.04 + smoke: native + # Native arm64 runner (free for public repos): no cross toolchain, + # and the binary can be smoke-tested directly. + - os: ubuntu-22.04-arm target: aarch64-unknown-linux-gnu artifact: ink-linux-arm64 + smoke: native + # Static musl builds use `cross` (Docker images with a musl C + # toolchain). The only C code left in the tree is `ring` (rustls), + # which needs a musl-targeting C compiler for each arch; cross ships + # exactly that, is the long-proven route for Rust musl releases, and + # Docker is preinstalled on the Ubuntu runners. cargo-zigbuild would + # also work but adds a zig toolchain and gives no runner for the + # foreign-arch binaries; here qemu-user runs them directly since + # they are fully static. + - os: ubuntu-22.04 + target: x86_64-unknown-linux-musl + artifact: ink-linux-amd64-musl + tool: cross + smoke: native + - os: ubuntu-22.04 + target: aarch64-unknown-linux-musl + artifact: ink-linux-arm64-musl + tool: cross + smoke: qemu-aarch64 + - os: ubuntu-22.04 + target: armv7-unknown-linux-musleabihf + artifact: ink-linux-armv7-musl + tool: cross + smoke: qemu-arm + # macos-latest is Apple Silicon; the x86_64 binary is cross-built and + # smoke-tested under Rosetta when the image has it. - os: macos-latest target: x86_64-apple-darwin artifact: ink-macos-amd64 + smoke: rosetta - os: macos-latest target: aarch64-apple-darwin artifact: ink-macos-arm64 + smoke: native + # Windows builds link the CRT statically (.cargo/config.toml) so they + # run without the VC++ redistributable. - os: windows-latest target: x86_64-pc-windows-msvc artifact: ink-windows-amd64.exe + smoke: native + - os: windows-11-arm + target: aarch64-pc-windows-msvc + artifact: ink-windows-arm64.exe + smoke: native + + defaults: + run: + shell: bash steps: - uses: actions/checkout@v4 @@ -42,32 +113,54 @@ jobs: with: targets: ${{ matrix.target }} - - name: Install cross-compilation tools - if: matrix.target == 'aarch64-unknown-linux-gnu' + - name: Install cross + if: matrix.tool == 'cross' + uses: taiki-e/install-action@v2 + with: + tool: cross + + # ring compiles its aarch64 Windows assembly with clang. The image ships + # LLVM; make sure clang is on PATH and fail early with a clear message + # if it is not. + - name: Ensure clang for ring (Windows ARM64) + if: matrix.target == 'aarch64-pc-windows-msvc' run: | - sudo apt-get update - sudo apt-get install -y gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu + if ! command -v clang >/dev/null 2>&1; then + printf '%s\n' 'C:\Program Files\LLVM\bin' >> "$GITHUB_PATH" + export PATH="/c/Program Files/LLVM/bin:$PATH" + fi + clang --version || { echo "::error::clang not found; ring needs it to build for aarch64-pc-windows-msvc"; exit 1; } - name: Build - run: cargo build --release --target ${{ matrix.target }} - env: - CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc + run: | + set -euxo pipefail + if [ "${{ matrix.tool }}" = "cross" ]; then + cross build --release --locked --target ${{ matrix.target }} + else + cargo build --release --locked --target ${{ matrix.target }} + fi + + - name: Locate binary + id: bin + run: | + if [ "${{ runner.os }}" = "Windows" ]; then + echo "path=target/${{ matrix.target }}/release/ink.exe" >> "$GITHUB_OUTPUT" + else + echo "path=target/${{ matrix.target }}/release/ink" >> "$GITHUB_OUTPUT" + fi - # Guard for issue #6: refuse to ship a Linux binary that needs a newer - # glibc than Ubuntu 22.04 LTS provides (2.35). Catches a silent runner - # image bump before it reaches users. + # Guard for issue #6: refuse to ship a glibc Linux binary that needs a + # newer glibc than Ubuntu 22.04 LTS provides (2.35). Catches a silent + # runner image bump before it reaches users. Both gnu targets build + # natively, so plain objdump reads either binary. - name: glibc baseline check - if: runner.os == 'Linux' + if: endsWith(matrix.target, '-linux-gnu') env: MAX_GLIBC: '2.35' run: | set -euo pipefail - bin="target/${{ matrix.target }}/release/ink" - if [ "${{ matrix.target }}" = "aarch64-unknown-linux-gnu" ]; then - objdump=aarch64-linux-gnu-objdump - else - objdump=objdump - fi + bin="${{ steps.bin.outputs.path }}" + objdump=objdump versions=$("$objdump" -T "$bin" | grep -oE 'GLIBC_[0-9]+\.[0-9]+(\.[0-9]+)?' | sed 's/^GLIBC_//' | sort -u -V || true) if [ -z "$versions" ]; then echo "::error::glibc baseline check: no GLIBC_* symbol versions found in $bin (is $objdump working?)" @@ -85,23 +178,110 @@ jobs: exit 1 fi - - name: Rename binary (unix) - if: runner.os != 'Windows' - run: mv target/${{ matrix.target }}/release/ink ${{ matrix.artifact }} + # The musl builds exist to run anywhere; a dynamic interpreter or + # libc.so dependency would defeat that. + - name: Static linking check (musl) + if: endsWith(matrix.target, '-musl') || endsWith(matrix.target, '-musleabihf') + run: | + set -euo pipefail + bin="${{ steps.bin.outputs.path }}" + desc=$(file -b "$bin") + echo "file: $desc" + case "$desc" in + *"statically linked"*|*"static-pie linked"*) ;; + *) echo "::error::${{ matrix.artifact }} is not statically linked: $desc"; exit 1 ;; + esac + if [ "${{ matrix.smoke }}" = "native" ]; then + # ldd prints "not a dynamic executable" or "statically linked" + # (static-pie) and may exit non-zero for static binaries. + out=$(ldd "$bin" 2>&1 || true) + echo "ldd: $out" + case "$out" in + *"not a dynamic executable"*|*"statically linked"*) ;; + *) echo "::error::ldd reports dynamic dependencies for ${{ matrix.artifact }}"; exit 1 ;; + esac + fi - - name: Rename binary (windows) + # Without crt-static the exe imports VCRUNTIME140.dll and the UCRT + # api-set DLLs, which a clean Windows install may lack. + - name: Static CRT check (Windows) if: runner.os == 'Windows' - run: mv target/${{ matrix.target }}/release/ink.exe ${{ matrix.artifact }} + run: | + set -euo pipefail + bin="${{ steps.bin.outputs.path }}" + if grep -a -i -o -E 'vcruntime[0-9]+\.dll|api-ms-win-crt-[a-z0-9-]+\.dll' "$bin" | sort -u | grep .; then + echo "::error::${{ matrix.artifact }} imports the dynamic MSVC runtime (listed above); is +crt-static set in .cargo/config.toml and RUSTFLAGS unset?" + exit 1 + fi + echo "No dynamic CRT imports found." + + - name: Smoke test + run: | + set -euo pipefail + bin="${{ steps.bin.outputs.path }}" + run="" + case "${{ matrix.smoke }}" in + native) ;; + qemu-*) + sudo apt-get update -qq + sudo apt-get install -y -qq qemu-user >/dev/null + run="${{ matrix.smoke }}" + ;; + rosetta) + if arch -x86_64 /usr/bin/true 2>/dev/null; then + run="arch -x86_64" + else + echo "::notice::Rosetta is not available on this runner; skipping the ${{ matrix.artifact }} smoke test." + exit 0 + fi + ;; + *) echo "::error::unknown smoke mode ${{ matrix.smoke }}"; exit 1 ;; + esac + + expected=$(sed -n 's/^version = "\([^"]*\)".*/\1/p' Cargo.toml | head -1) + version_out=$($run "$bin" --version) + echo "--version: $version_out" + if [ "$version_out" != "ink $expected" ]; then + echo "::error::${{ matrix.artifact }} --version printed '$version_out', expected 'ink $expected'" + exit 1 + fi + + cat > smoke.md <<'MD' + # Smoke test + + Hello **world**. + + - item one + + ```rust + fn main() {} + ``` + MD + out=$($run "$bin" --plain --color=never --no-pager - < smoke.md) + rm -f smoke.md + printf '%s\n' "$out" + for want in "Smoke test" "Hello world" "item one" "fn main() {}"; do + if ! printf '%s\n' "$out" | grep -qF "$want"; then + echo "::error::${{ matrix.artifact }} --plain output is missing '$want'" + exit 1 + fi + done + echo "Smoke test passed for ${{ matrix.artifact }}${run:+ (via $run)}." + + - name: Rename binary + run: mv "${{ steps.bin.outputs.path }}" "${{ matrix.artifact }}" - name: Upload artifact uses: actions/upload-artifact@v4 with: name: ${{ matrix.artifact }} path: ${{ matrix.artifact }} + if-no-files-found: error # Package the Linux binaries as .deb/.rpm so apt/dnf users get a real - # install (binary in PATH, clean uninstall) instead of a bare binary. Runs - # before `release` so SHA256SUMS covers the packages too. + # install (binary in PATH, man page, completions, clean uninstall) instead of + # a bare binary, and wrap every binary in an archive with the man page and + # completions. Runs before `release` so SHA256SUMS covers all of it. packages: needs: build runs-on: ubuntu-latest @@ -109,8 +289,27 @@ jobs: - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 with: + path: bin merge-multiple: true + - name: Resolve version + id: version + run: | + set -euo pipefail + cargo_version=$(sed -n 's/^version = "\([^"]*\)".*/\1/p' Cargo.toml | head -1) + if [[ "$GITHUB_REF" == refs/tags/v* ]]; then + VERSION="${GITHUB_REF_NAME#v}" + if [ "$VERSION" != "$cargo_version" ]; then + echo "::error::tag $GITHUB_REF_NAME does not match Cargo.toml version $cargo_version" + exit 1 + fi + else + # PR / manual run: no tag, so package under the Cargo.toml version. + VERSION="$cargo_version" + fi + echo "Packaging version $VERSION" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + - name: Install nfpm env: GH_TOKEN: ${{ github.token }} @@ -119,16 +318,25 @@ jobs: curl -fsSL "https://github.com/goreleaser/nfpm/releases/download/${tag}/nfpm_${tag#v}_amd64.deb" -o /tmp/nfpm.deb sudo dpkg -i /tmp/nfpm.deb + # Man page and completions are arch-independent, so generate them once + # from the native x86_64 binary (artifacts lose the exec bit in transit). + - name: Generate man page and completions + run: | + set -euo pipefail + chmod +x bin/ink-* + packaging/stage.sh bin/ink-linux-amd64 dist + - name: Build deb and rpm packages + env: + VERSION: ${{ steps.version.outputs.version }} run: | set -euo pipefail - VERSION="${GITHUB_REF_NAME#v}" for pair in "amd64:ink-linux-amd64" "arm64:ink-linux-arm64"; do ARCH="${pair%%:*}" BINARY="${pair##*:}" # nfpm does not expand env vars in `contents.src`, so stage the # arch-specific artifact under the fixed name nfpm.yaml expects. - cp "$BINARY" ink + cp "bin/$BINARY" ink chmod +x ink for fmt in deb rpm; do VERSION="$VERSION" ARCH="$ARCH" \ @@ -138,6 +346,58 @@ jobs: rm -f ink ls -l ink-md* + - name: Install the amd64 deb + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + sudo dpkg -i "ink-md_${VERSION}_amd64.deb" + dpkg -L ink-md + ink --version + for f in /usr/share/man/man1/ink.1.gz /usr/share/bash-completion/completions/ink \ + /usr/share/zsh/vendor-completions/_ink /usr/share/fish/vendor_completions.d/ink.fish; do + dpkg -L ink-md | grep -qx "$f" || { echo "::error::deb is missing $f"; exit 1; } + done + sudo dpkg -r ink-md + + # Additional assets: ink---.tar.gz / .zip with the + # binary, LICENSE, README, man page and completions under one top-level + # directory. The raw binaries stay published unchanged alongside. + - name: Build archives + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + # Archives are written to the top level (next to the packages) so the + # uploaded artifact stays flat and `ink-*` in the release job sees them. + for bin in bin/ink-*; do + asset=$(basename "$bin") + platform="${asset#ink-}" + platform="${platform%.exe}" + name="ink-${VERSION}-${platform}" + stage="staging/$name" + mkdir -p "$stage/completions" + cp LICENSE README.md "$stage/" + case "$asset" in + *.exe) + cp "$bin" "$stage/ink.exe" + cp dist/completions/_ink.ps1 "$stage/completions/" + (cd staging && zip -qr "../$name.zip" "$name") + ;; + *) + cp "$bin" "$stage/ink" + chmod 755 "$stage/ink" + cp dist/ink.1 "$stage/" + cp dist/completions/ink.bash dist/completions/_ink dist/completions/ink.fish "$stage/completions/" + tar -C staging --owner=0 --group=0 -czf "$name.tar.gz" "$name" + ;; + esac + done + rm -rf staging + ls -l ink-"$VERSION"-* + for a in ink-"$VERSION"-*.tar.gz; do tar -tzf "$a" | sed "s|^| $a: |"; done + for a in ink-"$VERSION"-*.zip; do unzip -l "$a"; done + - name: Upload package artifacts uses: actions/upload-artifact@v4 with: @@ -145,17 +405,43 @@ jobs: path: | ink-md*.deb ink-md*.rpm + ink-*.tar.gz + ink-*.zip + if-no-files-found: error release: needs: [build, packages] + # Publishing happens only for a pushed v* tag — never for PRs or manual + # runs, even a manual run started from a tag ref. + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + attestations: write steps: - uses: actions/download-artifact@v4 with: merge-multiple: true + # The glob matches only regular files at the top level: raw binaries, + # .deb/.rpm and the archives (uploaded flat). Fail on anything else + # rather than write a bogus manifest. - name: Generate checksums - run: shasum -a 256 ink-* > SHA256SUMS + run: | + set -euo pipefail + for f in ink-*; do + [ -f "$f" ] || { echo "::error::unexpected non-file release asset: $f"; exit 1; } + done + shasum -a 256 ink-* > SHA256SUMS + cat SHA256SUMS + + # Signed SLSA build provenance for every asset listed in SHA256SUMS. + # Verify with: gh attestation verify --repo borghei/ink + - name: Attest build provenance + uses: actions/attest-build-provenance@v4 + with: + subject-checksums: SHA256SUMS - name: Create release uses: softprops/action-gh-release@v2 @@ -171,6 +457,7 @@ jobs: # absent; never fails the release — the binaries have already published. crates: needs: release + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -198,6 +485,7 @@ jobs: # tolerance lives on the step instead.) homebrew: needs: release + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') uses: ./.github/workflows/homebrew.yml with: tag: ${{ github.ref_name }} diff --git a/Cargo.lock b/Cargo.lock index 2526505..41a5789 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -536,7 +536,6 @@ dependencies = [ "entities", "memchr", "slug", - "syntect", "typed-arena", "unicode_categories", ] @@ -2108,28 +2107,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "onig" -version = "6.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" -dependencies = [ - "bitflags 2.13.1", - "libc", - "once_cell", - "onig_sys", -] - -[[package]] -name = "onig_sys" -version = "69.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" -dependencies = [ - "cc", - "pkg-config", -] - [[package]] name = "open" version = "5.4.0" @@ -2342,12 +2319,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - [[package]] name = "plist" version = "1.10.0" @@ -3553,7 +3524,6 @@ dependencies = [ "flate2", "fnv", "once_cell", - "onig", "plist", "regex-syntax", "serde", diff --git a/Cargo.toml b/Cargo.toml index b3bccf0..24e8d0b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,9 @@ name = "ink-md" version = "0.8.0" edition = "2021" +# Highest rust-version declared by any resolved dependency (quantette, via +# ratatui-image). CI's msrv job builds with exactly this toolchain. +rust-version = "1.90" authors = ["Amin Borghei "] description = "The most advanced terminal markdown reader" license-file = "LICENSE" @@ -19,7 +22,7 @@ path = "src/main.rs" [dependencies] # Markdown parsing (GFM-compliant) -comrak = { version = "0.36", default-features = false, features = ["syntect", "shortcodes"] } +comrak = { version = "0.36", default-features = false, features = ["shortcodes"] } # TUI framework ratatui = { version = "0.30", features = ["crossterm"] } @@ -113,3 +116,16 @@ pkg-url = "{ repo }/releases/download/v{ version }/ink-macos-arm64" [package.metadata.binstall.overrides.x86_64-pc-windows-msvc] pkg-url = "{ repo }/releases/download/v{ version }/ink-windows-amd64.exe" + +# Static musl builds and Windows ARM64 (first published after v0.8.0). +[package.metadata.binstall.overrides.x86_64-unknown-linux-musl] +pkg-url = "{ repo }/releases/download/v{ version }/ink-linux-amd64-musl" + +[package.metadata.binstall.overrides.aarch64-unknown-linux-musl] +pkg-url = "{ repo }/releases/download/v{ version }/ink-linux-arm64-musl" + +[package.metadata.binstall.overrides.armv7-unknown-linux-musleabihf] +pkg-url = "{ repo }/releases/download/v{ version }/ink-linux-armv7-musl" + +[package.metadata.binstall.overrides.aarch64-pc-windows-msvc] +pkg-url = "{ repo }/releases/download/v{ version }/ink-windows-arm64.exe" diff --git a/README.md b/README.md index 70d01cd..99fed6b 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,20 @@ ink renders markdown in your terminal with syntax highlighting, inline images, m curl -fsSL https://raw.githubusercontent.com/borghei/ink/main/install.sh | sh ``` +The installer picks the right binary for your OS, CPU and libc (static musl build on Alpine, Raspberry Pi and older-glibc systems), checks it against `SHA256SUMS` and installs to `/usr/local/bin` (using `sudo` only if it has to). Pin a version with `INK_VERSION=v0.8.0` or change the target with `INK_INSTALL_DIR="$HOME/.local/bin"`: + +```bash +curl -fsSL https://raw.githubusercontent.com/borghei/ink/main/install.sh | INK_VERSION=v0.8.0 INK_INSTALL_DIR="$HOME/.local/bin" sh +``` + +### Quick install (Windows PowerShell) + +```powershell +irm https://raw.githubusercontent.com/borghei/ink/main/install.ps1 | iex +``` + +Installs `ink.exe` (x64 or ARM64) to `%LOCALAPPDATA%\Programs\ink` and adds it to your user `PATH`. Set `$env:INK_VERSION = "v0.8.0"` first to pin a version. + ### Homebrew (macOS / Linux) ```bash @@ -76,7 +90,24 @@ scoop install ink ### Pre-built binaries -Grab the latest binary for your platform from the [releases page](https://github.com/borghei/ink/releases). Available for Linux (amd64, arm64), macOS (amd64, arm64), and Windows (amd64); `SHA256SUMS` is published alongside. The Linux binaries are built against glibc 2.35, so they run on Ubuntu 22.04 LTS, Debian 12 and newer. +Grab the latest binary for your platform from the [releases page](https://github.com/borghei/ink/releases): + +| Platform | Asset | +|---|---| +| Linux x86_64 / arm64 (glibc 2.35+: Ubuntu 22.04, Debian 12 and newer) | `ink-linux-amd64`, `ink-linux-arm64` | +| Linux x86_64 / arm64 / armv7, static (any distro: Alpine, Debian 11, Ubuntu 20.04, RHEL 8, NixOS, containers, Raspberry Pi) | `ink-linux-amd64-musl`, `ink-linux-arm64-musl`, `ink-linux-armv7-musl` | +| macOS Intel / Apple Silicon | `ink-macos-amd64`, `ink-macos-arm64` | +| Windows x64 / ARM64 | `ink-windows-amd64.exe`, `ink-windows-arm64.exe` | + +The glibc builds need glibc 2.35 or newer; older and musl-based systems use the static builds, and the install script picks for you. The Windows builds link the C runtime statically, so no Visual C++ redistributable is needed. Each binary also comes as an archive (`ink---.tar.gz`, `.zip` on Windows) bundling the man page and bash/zsh/fish completions, and the `.deb`/`.rpm` packages install those too. + +`SHA256SUMS` is published alongside, and every asset carries a signed build provenance attestation you can check with the GitHub CLI: + +```bash +gh attestation verify ink-linux-amd64 --repo borghei/ink +``` + +The static musl builds and Windows ARM64 binaries are new after v0.8.0; earlier releases only have the glibc, macOS and Windows x64 binaries. ### From source diff --git a/docs/llms-full.txt b/docs/llms-full.txt index d0ecd80..00edaee 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -96,7 +96,7 @@ ink is a feature-rich terminal markdown reader built in Rust. It renders markdow - Link destinations are scheme-validated (http/https/mailto/relative only); javascript:, file:, data: and control-byte URLs are dropped - Remote images off by default; SSRF protections when enabled (see Images) - Relative file/image paths are canonicalized and contained to the document's directory; absolute paths rejected -- install.sh verifies a published SHA256SUMS manifest before installing; CI runs cargo audit +- install.sh / install.ps1 verify a published SHA256SUMS manifest before installing; release assets carry signed build provenance (`gh attestation verify --repo borghei/ink`); CI runs cargo audit ### Keybindings - j/k/↑/↓: scroll up/down @@ -156,4 +156,7 @@ preset = "emacs" # default | vim | emacs - Repository: https://github.com/borghei/ink - Website: https://borghei.github.io/ink/ - Author: Amin Borghei -- Crates.io: https://crates.io/crates/ink-md (install with `cargo install ink-md`) +- Crates.io: https://crates.io/crates/ink-md (install with `cargo install ink-md`, Rust 1.90+) +- Install script (macOS/Linux): `curl -fsSL https://raw.githubusercontent.com/borghei/ink/main/install.sh | sh` (picks the glibc or static musl build; pin with INK_VERSION=v0.8.0, target dir INK_INSTALL_DIR) +- Install script (Windows): `irm https://raw.githubusercontent.com/borghei/ink/main/install.ps1 | iex` (x64/ARM64, installs to %LOCALAPPDATA%\Programs\ink) +- Prebuilt binaries: Linux amd64/arm64 (glibc 2.35+), static musl Linux amd64/arm64/armv7 (Alpine, older glibc, Raspberry Pi), macOS amd64/arm64, Windows amd64/arm64; .deb/.rpm and tar.gz/zip archives with man page and completions diff --git a/docs/llms.txt b/docs/llms.txt index ab839b5..1ef5e21 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -31,11 +31,16 @@ ink is a command-line tool that renders markdown files in the terminal with rich - Sanitizes terminal control/escape bytes out of untrusted markdown before rendering (both TUI and --plain), preventing ANSI/OSC escape-sequence and hyperlink injection - Remote (http/https) images are not fetched by default; --remote-images opts in, and private/loopback/cloud-metadata addresses stay blocked - URL fetches are timeout- and size-capped; relative file/link paths are contained to the document's directory -- install.sh verifies a published SHA256SUMS manifest before installing +- install.sh / install.ps1 verify a published SHA256SUMS manifest before installing; release assets carry signed build provenance (gh attestation verify) ## Install -cargo install ink-md +curl -fsSL https://raw.githubusercontent.com/borghei/ink/main/install.sh | sh # macOS / Linux (picks glibc or static musl build) +irm https://raw.githubusercontent.com/borghei/ink/main/install.ps1 | iex # Windows PowerShell (x64 / ARM64) +cargo install ink-md # from source (Rust 1.90+) +INK_VERSION=v0.8.0 sh install.sh # pin a version + +Prebuilt: Linux amd64/arm64 (glibc 2.35+), static musl Linux amd64/arm64/armv7 (any distro), macOS amd64/arm64, Windows amd64/arm64; .deb/.rpm and tar.gz/zip archives include the man page and completions. Verify with SHA256SUMS or `gh attestation verify --repo borghei/ink`. ## Usage diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..54d54c4 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,154 @@ +# ink installer for Windows - https://github.com/borghei/ink +# +# Usage (PowerShell 5.1 or 7+): +# irm https://raw.githubusercontent.com/borghei/ink/main/install.ps1 | iex +# +# Environment: +# $env:INK_VERSION = "v0.8.0" install a specific release (default: latest) +# $env:INK_INSTALL_DIR = "C:\tools" install somewhere else +# (default: %LOCALAPPDATA%\Programs\ink) +# +# Everything runs inside one script block, so a truncated download fails to +# parse instead of running half an install. Errors are reported without +# `exit`, which would close the terminal when run through `iex`. + +& { + $ErrorActionPreference = 'Stop' + # Invoke-WebRequest is dramatically slower with the progress bar on 5.1. + $ProgressPreference = 'SilentlyContinue' + # Windows PowerShell 5.1 may default to TLS 1.0, which GitHub refuses. + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + + $repo = 'borghei/ink' + + function Get-InkArch { + # Win32_Processor sees the real CPU even from an emulated x64 shell + # on ARM64 (Architecture 9 = x64, 12 = ARM64). + try { + $cpu = Get-CimInstance -ClassName Win32_Processor -ErrorAction Stop | Select-Object -First 1 + if ($cpu.Architecture -eq 12) { return 'arm64' } + if ($cpu.Architecture -eq 9) { return 'amd64' } + } catch { } + $arch = $null + try { + $arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + } catch { } + if (-not $arch) { + $arch = $env:PROCESSOR_ARCHITEW6432 + if (-not $arch) { $arch = $env:PROCESSOR_ARCHITECTURE } + } + switch -Regex ($arch) { + '^(X64|AMD64)$' { return 'amd64' } + '^(Arm64|ARM64)$' { return 'arm64' } + default { + throw "unsupported architecture '$arch'. Prebuilt binaries exist for Windows x64 and ARM64; elsewhere use: cargo install ink-md" + } + } + } + + function Get-File([string]$Url, [string]$OutFile) { + Invoke-WebRequest -Uri $Url -OutFile $OutFile -UseBasicParsing + } + + $tmp = Join-Path ([IO.Path]::GetTempPath()) ("ink-install-" + [guid]::NewGuid().ToString('N')) + try { + $arch = Get-InkArch + + $version = $env:INK_VERSION + if ($version) { + if (-not $version.StartsWith('v')) { $version = "v$version" } + $base = "https://github.com/$repo/releases/download/$version" + $label = $version + } else { + # The /latest/download redirect avoids the rate-limited GitHub API. + $base = "https://github.com/$repo/releases/latest/download" + $label = 'latest' + } + + $installDir = $env:INK_INSTALL_DIR + if (-not $installDir) { $installDir = Join-Path $env:LOCALAPPDATA 'Programs\ink' } + + New-Item -ItemType Directory -Force -Path $tmp | Out-Null + + # Checksums first: they double as the list of assets in the release. + $sumsFile = Join-Path $tmp 'SHA256SUMS' + try { + Get-File "$base/SHA256SUMS" $sumsFile + } catch { + $hint = '' + if ($env:INK_VERSION) { $hint = " Does release $version exist? See https://github.com/$repo/releases" } + throw "could not download $base/SHA256SUMS - $($_.Exception.Message)$hint" + } + $sums = @{} + foreach ($line in Get-Content -LiteralPath $sumsFile) { + if ($line -match '^([0-9a-fA-F]{64})\s+\*?(.+?)\s*$') { $sums[$Matches[2]] = $Matches[1] } + } + + $asset = "ink-windows-$arch.exe" + if (-not $sums.ContainsKey($asset) -and $arch -eq 'arm64') { + # Releases up to v0.8.0 have no native ARM64 build; Windows on ARM + # runs the x64 one under emulation. + Write-Host "Release $label has no native ARM64 build; installing the x64 build (runs under emulation)." + $asset = 'ink-windows-amd64.exe' + } + if (-not $sums.ContainsKey($asset)) { + throw "release $label has no $asset. Build from source instead: cargo install ink-md" + } + + Write-Host "Installing ink ($label): $asset" + $exeTmp = Join-Path $tmp 'ink.exe' + Get-File "$base/$asset" $exeTmp + + Write-Host 'Verifying checksum...' + $actual = (Get-FileHash -LiteralPath $exeTmp -Algorithm SHA256).Hash + if ($actual -ine $sums[$asset]) { + throw "checksum mismatch for $asset`n expected: $($sums[$asset])`n actual: $actual" + } + Write-Host 'Checksum OK.' + + New-Item -ItemType Directory -Force -Path $installDir | Out-Null + $exe = Join-Path $installDir 'ink.exe' + try { + Copy-Item -LiteralPath $exeTmp -Destination $exe -Force + } catch { + throw "could not write $exe ($($_.Exception.Message)). If ink is running, close it and try again." + } + + # Add the install dir to the *user* PATH. Read and write the raw + # registry value so %VARIABLE% entries in it stay unexpanded. + $onWindows = ($PSVersionTable.PSEdition -eq 'Desktop') -or $IsWindows + $pathNote = $null + if ($onWindows) { + $key = Get-Item -Path 'HKCU:\Environment' + $userPath = $key.GetValue('Path', '', 'DoNotExpandEnvironmentNames') + $entries = @($userPath -split ';' | Where-Object { $_ }) + $present = $entries | Where-Object { $_.TrimEnd('\') -ieq $installDir.TrimEnd('\') } + if (-not $present) { + $newPath = (@($entries) + $installDir) -join ';' + Set-ItemProperty -Path 'HKCU:\Environment' -Name 'Path' -Value $newPath -Type ExpandString + # Setting any user variable through .NET broadcasts + # WM_SETTINGCHANGE, so newly opened terminals see the new PATH. + [Environment]::SetEnvironmentVariable('INK_INSTALLER_REFRESH', '1', 'User') + [Environment]::SetEnvironmentVariable('INK_INSTALLER_REFRESH', $null, 'User') + $pathNote = "Added $installDir to your user PATH. Open a new terminal to use 'ink'." + } + if (-not (($env:Path -split ';') | Where-Object { $_.TrimEnd('\') -ieq $installDir.TrimEnd('\') })) { + $env:Path = "$env:Path;$installDir" + } + } + + $installed = $null + try { $installed = (& $exe --version) 2>$null } catch { } + if ($installed) { + Write-Host "$installed installed to $exe" + } else { + Write-Host "ink installed to $exe" + } + if ($pathNote) { Write-Host $pathNote } + Write-Host "Run 'ink --help' to get started." + } catch { + Write-Host "error: $($_.Exception.Message)" -ForegroundColor Red + } finally { + if (Test-Path -LiteralPath $tmp) { Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue } + } +} diff --git a/install.sh b/install.sh index 06dc3f4..f93fc8f 100755 --- a/install.sh +++ b/install.sh @@ -1,83 +1,343 @@ #!/bin/sh # ink installer — https://github.com/borghei/ink -# Usage: curl -fsSL https://raw.githubusercontent.com/borghei/ink/main/install.sh | sh +# +# Usage: +# curl -fsSL https://raw.githubusercontent.com/borghei/ink/main/install.sh | sh +# +# Environment: +# INK_VERSION=v0.8.0 install a specific release (default: latest) +# INK_INSTALL_DIR="$HOME/.local/bin" install somewhere else (default: /usr/local/bin) +# +# Options: +# --print-asset print the release asset this machine would get, then exit +# +# Detection can be overridden for testing (used with --print-asset): +# INK_TEST_UNAME_S, INK_TEST_UNAME_M, INK_TEST_LIBC (glibc|musl|termux), +# INK_TEST_GLIBC_VERSION, INK_TEST_LONG_BIT +# +# The whole script lives in functions and only `main` on the last line runs +# anything, so a truncated download cannot execute half an install. -set -e +set -eu REPO="borghei/ink" -# Override with e.g. INK_INSTALL_DIR="$HOME/.local/bin" to skip the sudo step. -INSTALL_DIR="${INK_INSTALL_DIR:-/usr/local/bin}" - -# Detect OS and architecture -OS="$(uname -s)" -ARCH="$(uname -m)" - -case "$OS" in - Linux) os="linux" ;; - Darwin) os="macos" ;; - *) echo "Unsupported OS: $OS"; exit 1 ;; -esac - -case "$ARCH" in - x86_64|amd64) arch="amd64" ;; - arm64|aarch64) arch="arm64" ;; - *) echo "Unsupported architecture: $ARCH"; exit 1 ;; -esac - -BINARY="ink-${os}-${arch}" - -# Get latest release tag -LATEST=$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" | grep '"tag_name"' | head -1 | cut -d'"' -f4) - -if [ -z "$LATEST" ]; then - echo "Could not determine latest release. Check https://github.com/$REPO/releases" - exit 1 -fi - -URL="https://github.com/$REPO/releases/download/$LATEST/$BINARY" -SUMS_URL="https://github.com/$REPO/releases/download/$LATEST/SHA256SUMS" - -echo "Downloading ink $LATEST for $os/$arch..." -TMPFILE=$(mktemp) -curl -fsSL "$URL" -o "$TMPFILE" - -# Verify the download against the published checksum manifest before trusting it. -echo "Verifying checksum..." -EXPECTED=$(curl -fsSL "$SUMS_URL" | grep " $BINARY\$" | cut -d' ' -f1) -if [ -z "$EXPECTED" ]; then - echo "Could not fetch checksum for $BINARY from $SUMS_URL — aborting." - rm -f "$TMPFILE" - exit 1 -fi - -if command -v sha256sum >/dev/null 2>&1; then - ACTUAL=$(sha256sum "$TMPFILE" | cut -d' ' -f1) -elif command -v shasum >/dev/null 2>&1; then - ACTUAL=$(shasum -a 256 "$TMPFILE" | cut -d' ' -f1) -else - echo "Neither sha256sum nor shasum found — cannot verify download. Aborting." - rm -f "$TMPFILE" - exit 1 -fi - -if [ "$EXPECTED" != "$ACTUAL" ]; then - echo "Checksum mismatch for $BINARY!" - echo " expected: $EXPECTED" - echo " actual: $ACTUAL" - rm -f "$TMPFILE" - exit 1 -fi -echo "Checksum OK." - -chmod +x "$TMPFILE" - -# Install -if [ -w "$INSTALL_DIR" ]; then - mv "$TMPFILE" "$INSTALL_DIR/ink" -else - echo "Installing to $INSTALL_DIR (requires sudo)..." - sudo mv "$TMPFILE" "$INSTALL_DIR/ink" -fi - -echo "ink $LATEST installed to $INSTALL_DIR/ink" -echo "Run 'ink --help' to get started." +# Oldest glibc the ink-linux-{amd64,arm64} binaries support (built on Ubuntu +# 22.04). Anything older gets the static musl build instead. +MIN_GLIBC="2.35" + +say() { + printf '%s\n' "$*" +} + +err() { + printf 'error: %s\n' "$*" >&2 +} + +supported_list() { + cat >&2 <<'EOF' + +Prebuilt binaries exist for: + Linux x86_64, aarch64 (glibc 2.35+ or static musl), armv7 (static musl) + macOS x86_64, arm64 + Windows x64, ARM64 (use install.ps1) + +Anywhere else, build from source with Rust: + cargo install ink-md +EOF +} + +uname_s() { + if [ -n "${INK_TEST_UNAME_S:-}" ]; then printf '%s\n' "$INK_TEST_UNAME_S"; else uname -s; fi +} + +uname_m() { + if [ -n "${INK_TEST_UNAME_M:-}" ]; then printf '%s\n' "$INK_TEST_UNAME_M"; else uname -m; fi +} + +# Prints glibc, musl or termux. "glibc" only means "not musl/termux"; the +# glibc version check decides whether the glibc build will actually run. +detect_libc() { + if [ -n "${INK_TEST_LIBC:-}" ]; then + printf '%s\n' "$INK_TEST_LIBC" + return + fi + case "${PREFIX:-}" in + *com.termux*) echo termux; return ;; + esac + if [ -n "${TERMUX_VERSION:-}" ]; then + echo termux + return + fi + if [ -f /etc/alpine-release ]; then + echo musl + return + fi + for f in /lib/ld-musl-*; do + if [ -e "$f" ]; then + echo musl + return + fi + done + if command -v ldd >/dev/null 2>&1 && ldd --version 2>&1 | grep -qi musl; then + echo musl + return + fi + echo glibc +} + +# Prints the glibc version as MAJOR.MINOR, or nothing if it cannot be read. +glibc_version() { + if [ -n "${INK_TEST_GLIBC_VERSION+x}" ]; then + printf '%s\n' "$INK_TEST_GLIBC_VERSION" + return + fi + v="" + if command -v getconf >/dev/null 2>&1; then + v=$(getconf GNU_LIBC_VERSION 2>/dev/null | sed -n 's/^glibc \([0-9][0-9]*\.[0-9][0-9]*\).*/\1/p') + fi + if [ -z "$v" ] && command -v ldd >/dev/null 2>&1; then + v=$(ldd --version 2>&1 | head -n 1 | sed -n 's/.*[^0-9.]\([0-9][0-9]*\.[0-9][0-9]*\)[^0-9]*$/\1/p') + fi + printf '%s\n' "$v" +} + +# version_ge A B: true when MAJOR.MINOR A >= B. +version_ge() { + a_major=${1%%.*} + a_minor=${1#*.} + a_minor=${a_minor%%.*} + b_major=${2%%.*} + b_minor=${2#*.} + b_minor=${b_minor%%.*} + [ "$a_major" -gt "$b_major" ] || { [ "$a_major" -eq "$b_major" ] && [ "$a_minor" -ge "$b_minor" ]; } +} + +long_bit() { + if [ -n "${INK_TEST_LONG_BIT:-}" ]; then + printf '%s\n' "$INK_TEST_LONG_BIT" + else + getconf LONG_BIT 2>/dev/null || echo 64 + fi +} + +# Linux: pick the glibc build when it will run, otherwise the static one. +# $1 = amd64 | arm64 +linux_asset() { + arch=$1 + libc=$(detect_libc) + case "$libc" in + termux) + err "Termux/Android is not supported by the prebuilt binaries yet." + printf '%s\n' "Build it from source instead: pkg install rust && cargo install ink-md" >&2 + return 1 + ;; + musl) + printf 'ink-linux-%s-musl\n' "$arch" + return + ;; + esac + # A 64-bit ARM kernel with a 32-bit userland (e.g. Raspberry Pi OS 32-bit) + # reports aarch64 but has no 64-bit glibc; the static build still runs. + if [ "$arch" = arm64 ] && [ "$(long_bit)" = 32 ]; then + printf 'ink-linux-arm64-musl\n' + return + fi + gv=$(glibc_version) + if [ -n "$gv" ] && version_ge "$gv" "$MIN_GLIBC"; then + printf 'ink-linux-%s\n' "$arch" + else + # Old or unreadable glibc: the static build runs regardless. + printf 'ink-linux-%s-musl\n' "$arch" + fi +} + +select_asset() { + os=$(uname_s) + machine=$(uname_m) + case "$os" in + Linux) + case "$machine" in + x86_64 | amd64) linux_asset amd64 ;; + aarch64 | arm64) linux_asset arm64 ;; + armv7l | armv7 | armhf | armv8l) + if [ "$(detect_libc)" = termux ]; then + linux_asset arm64 # prints the Termux message and fails + else + echo ink-linux-armv7-musl + fi + ;; + *) + err "unsupported Linux architecture: $machine" + supported_list + return 1 + ;; + esac + ;; + Darwin) + case "$machine" in + arm64 | aarch64) echo ink-macos-arm64 ;; + x86_64) + # An x86_64 shell under Rosetta on Apple Silicon: get the native build. + if [ -z "${INK_TEST_UNAME_M:-}" ] && [ "$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)" = 1 ]; then + echo ink-macos-arm64 + else + echo ink-macos-amd64 + fi + ;; + *) + err "unsupported macOS architecture: $machine" + supported_list + return 1 + ;; + esac + ;; + MINGW* | MSYS* | CYGWIN* | Windows_NT) + err "on Windows, use the PowerShell installer:" + printf '%s\n' " irm https://raw.githubusercontent.com/$REPO/main/install.ps1 | iex" >&2 + return 1 + ;; + *) + err "unsupported operating system: $os" + supported_list + return 1 + ;; + esac +} + +# download URL FILE: curl, or wget as a fallback. Returns non-zero on any +# HTTP error (404 included). +download() { + if command -v curl >/dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -fsSL --retry 3 "$1" -o "$2" + elif command -v wget >/dev/null 2>&1; then + wget -q --https-only -O "$2" "$1" + else + err "neither curl nor wget is installed" + return 1 + fi +} + +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | cut -d' ' -f1 + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | cut -d' ' -f1 + elif command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 -r "$1" | cut -d' ' -f1 + else + return 1 + fi +} + +main() { + if [ "${1:-}" = "--print-asset" ]; then + select_asset + return + fi + if [ "$#" -gt 0 ]; then + err "unknown argument: $1 (the only option is --print-asset)" + return 2 + fi + + install_dir="${INK_INSTALL_DIR:-/usr/local/bin}" + asset=$(select_asset) + + version="${INK_VERSION:-}" + if [ -n "$version" ]; then + case "$version" in + v*) ;; + *) version="v$version" ;; + esac + base="https://github.com/$REPO/releases/download/$version" + label="$version" + else + # The /latest/download redirect avoids the rate-limited GitHub API. + base="https://github.com/$REPO/releases/latest/download" + label="latest" + fi + + tmpdir=$(mktemp -d 2>/dev/null || mktemp -d -t ink-install) + trap 'rm -rf "$tmpdir"' EXIT + trap 'rm -rf "$tmpdir"; exit 130' INT TERM + + say "Installing ink ($label) for $(uname_s)/$(uname_m): $asset" + + # Checksums first: it doubles as the list of what this release ships, so a + # missing asset gets a real explanation instead of a bare 404. + if ! download "$base/SHA256SUMS" "$tmpdir/SHA256SUMS"; then + err "could not download $base/SHA256SUMS" + if [ -n "${INK_VERSION:-}" ]; then + err "does release $version exist? See https://github.com/$REPO/releases" + fi + return 1 + fi + expected=$(awk -v f="$asset" '$2 == f || $2 == "*" f { print $1; exit }' "$tmpdir/SHA256SUMS") + if [ -z "$expected" ]; then + err "release $label has no $asset." + case "$asset" in + *-musl) + if [ -n "${INK_VERSION:-}" ]; then + err "Static musl builds first shipped in the release after v0.8.0; $version predates them." + err "Unset INK_VERSION (or pick a newer one) to get it." + else + err "Static musl builds first ship in the release after v0.8.0, which is not out yet." + fi + ;; + esac + err "You can always build from source with: cargo install ink-md" + return 1 + fi + + if ! download "$base/$asset" "$tmpdir/ink"; then + err "could not download $base/$asset" + return 1 + fi + + say "Verifying checksum..." + if ! actual=$(sha256_of "$tmpdir/ink"); then + err "no sha256sum, shasum or openssl found; cannot verify the download. Aborting." + return 1 + fi + if [ "$expected" != "$actual" ]; then + err "checksum mismatch for $asset" + err " expected: $expected" + err " actual: $actual" + return 1 + fi + say "Checksum OK." + chmod 755 "$tmpdir/ink" + + sudo="" + if [ -d "$install_dir" ]; then + [ -w "$install_dir" ] || sudo=sudo + elif ! mkdir -p "$install_dir" 2>/dev/null; then + sudo=sudo + fi + if [ -n "$sudo" ] && ! command -v sudo >/dev/null 2>&1; then + err "$install_dir is not writable and sudo is not available." + err "Install somewhere you own instead, e.g.:" + printf '%s\n' " curl -fsSL https://raw.githubusercontent.com/$REPO/main/install.sh | INK_INSTALL_DIR=\"\$HOME/.local/bin\" sh" >&2 + return 1 + fi + if [ -n "$sudo" ]; then + say "Installing to $install_dir (requires sudo)..." + sudo mkdir -p "$install_dir" + sudo mv "$tmpdir/ink" "$install_dir/ink" + else + mv "$tmpdir/ink" "$install_dir/ink" + fi + + if installed=$("$install_dir/ink" --version 2>/dev/null); then + say "$installed installed to $install_dir/ink" + else + say "ink installed to $install_dir/ink, but it did not run here." + say "Please report this at https://github.com/$REPO/issues with the output of: uname -a" + fi + case ":$PATH:" in + *":$install_dir:"*) ;; + *) say "Note: $install_dir is not on your PATH. Add it, e.g.: export PATH=\"$install_dir:\$PATH\"" ;; + esac + say "Run 'ink --help' to get started." +} + +main "$@" diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml index 17d01ee..5da5109 100644 --- a/packaging/nfpm.yaml +++ b/packaging/nfpm.yaml @@ -29,3 +29,29 @@ contents: dst: /usr/share/doc/ink-md/LICENSE - src: ./README.md dst: /usr/share/doc/ink-md/README.md + # Man page and shell completions, generated by packaging/stage.sh into + # ./dist before nfpm runs (release.yml from the real binary, ci.yml from a + # stub). Debian and Fedora disagree on the zsh directory, so that entry is + # split per packager. + - src: ./dist/ink.1.gz + dst: /usr/share/man/man1/ink.1.gz + file_info: + mode: 0644 + - src: ./dist/completions/ink.bash + dst: /usr/share/bash-completion/completions/ink + file_info: + mode: 0644 + - src: ./dist/completions/_ink + dst: /usr/share/zsh/vendor-completions/_ink + packager: deb + file_info: + mode: 0644 + - src: ./dist/completions/_ink + dst: /usr/share/zsh/site-functions/_ink + packager: rpm + file_info: + mode: 0644 + - src: ./dist/completions/ink.fish + dst: /usr/share/fish/vendor_completions.d/ink.fish + file_info: + mode: 0644 diff --git a/packaging/stage.sh b/packaging/stage.sh new file mode 100755 index 0000000..4697a76 --- /dev/null +++ b/packaging/stage.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# Generate the man page and shell completions that ship in the .deb/.rpm +# packages and the release archives. +# +# packaging/stage.sh +# +# Writes /ink.1, /ink.1.gz and +# /completions/{ink.bash,_ink,ink.fish,_ink.ps1,ink.elv}. +# Used by release.yml (with the real x86_64 Linux binary) and by the packaging +# job in ci.yml (with a stub), so nfpm.yaml's paths are exercised on every PR. +set -eu + +if [ "$#" -ne 2 ]; then + echo "usage: $0 " >&2 + exit 2 +fi +bin=$1 +out=$2 + +mkdir -p "$out/completions" +"$bin" man > "$out/ink.1" +# -n: no name/timestamp in the gzip header, so the .gz is reproducible. +gzip -n -9 -c "$out/ink.1" > "$out/ink.1.gz" +"$bin" completions bash > "$out/completions/ink.bash" +"$bin" completions zsh > "$out/completions/_ink" +"$bin" completions fish > "$out/completions/ink.fish" +"$bin" completions powershell > "$out/completions/_ink.ps1" +"$bin" completions elvish > "$out/completions/ink.elv" + +# An empty file here means the binary printed nothing; fail loudly rather +# than ship a blank man page. +for f in "$out/ink.1" "$out"/completions/*; do + if [ ! -s "$f" ]; then + echo "::error::stage.sh: $f is empty" >&2 + exit 1 + fi +done +ls -l "$out" "$out/completions"