diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..e523b21 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,79 @@ +name: Build +# Release binaries for every supported platform. The release workflow calls +# this and attaches the archives to the GitHub release; pull requests that +# change the build or the dependencies run it too, so a platform that stops +# building is caught before a tag. +on: + workflow_call: + pull_request: + paths: [".github/workflows/build.yml", "Cargo.toml", "Cargo.lock"] +permissions: + contents: read +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-musl + os: ubuntu-latest + - target: aarch64-unknown-linux-musl + os: ubuntu-24.04-arm + - target: aarch64-apple-darwin + os: macos-latest + - target: x86_64-apple-darwin + os: macos-latest + # Cross-compiled on Apple silicon; runs only under Rosetta. + cross: true + - target: x86_64-pc-windows-msvc + os: windows-latest + runs-on: ${{ matrix.os }} + env: + TARGET: ${{ matrix.target }} + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install Rust + run: rustup toolchain install stable --profile minimal --target "$TARGET" + - name: Install musl + if: contains(matrix.target, 'musl') + run: sudo apt-get update && sudo apt-get install -y musl-tools + - run: cargo +stable build --release --locked --target "$TARGET" + - name: Package + run: | + version=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version') + name="jevgate-$version-$TARGET" + exe=jevgate + [[ "$TARGET" == *windows* ]] && exe=jevgate.exe + mkdir -p "dist/$name" + cp "target/$TARGET/release/$exe" README.md CHANGELOG.md LICENSE-APACHE LICENSE-MIT NOTICE "dist/$name/" + cd dist + if [[ "$TARGET" == *windows* ]]; then + archive="$name.zip" + 7z a -tzip "$archive" "$name" > /dev/null + else + archive="$name.tar.gz" + tar -czf "$archive" "$name" + fi + if command -v sha256sum > /dev/null; then + sha256sum "$archive" > "$archive.sha256" + else + shasum -a 256 "$archive" > "$archive.sha256" + fi + echo "BINARY=dist/$name/$exe" >> "$GITHUB_ENV" + - name: Smoke test + if: ${{ !matrix.cross }} + run: | + "$BINARY" --version + "$BINARY" rules > /dev/null + "$BINARY" check --dry-run src/main.rs > /dev/null + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dist-${{ matrix.target }} + path: | + dist/*.tar.gz + dist/*.zip + dist/*.sha256 + if-no-files-found: error diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b351753..2364788 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,15 +14,29 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install Rust run: rustup toolchain install stable --profile minimal --component rustfmt,clippy + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - run: cargo +stable fmt --check - run: cargo +stable clippy --locked --all-targets -- -D warnings - run: cargo +stable test --locked - run: cargo +stable package --locked + test: + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install Rust + run: rustup toolchain install stable --profile minimal + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - run: cargo +stable test --locked msrv: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - run: rustup toolchain install 1.90.0 --profile minimal + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - run: cargo +1.90.0 check --locked deny: runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index db44110..aa55b83 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,8 @@ name: Release # Pushing an annotated tag `vX.Y.Z` on main publishes that version: the CI -# checks run first, then the crate goes to crates.io and the tag's message -# body becomes the GitHub release notes. Every step can be rerun safely. +# checks run and the binaries build first, then the crate goes to crates.io and +# the tag's message body becomes the notes of a GitHub release that carries the +# binaries, their checksums and build provenance. Every step can be rerun safely. on: push: tags: ["v*"] @@ -13,14 +14,17 @@ concurrency: jobs: ci: uses: ./.github/workflows/ci.yml + build: + uses: ./.github/workflows/build.yml publish: - needs: ci + needs: [ci, build] runs-on: ubuntu-latest # crates.io trusts only this workflow in this environment (Trusted Publishing). environment: crates-io permissions: contents: write id-token: write + attestations: write env: TAG: ${{ github.ref_name }} GH_TOKEN: ${{ github.token }} @@ -60,16 +64,35 @@ jobs: run: cargo +stable publish --locked env: CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: dist-* + path: dist + merge-multiple: true + - name: Collect the checksums + run: cat dist/*.sha256 > dist/SHA256SUMS + - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: | + dist/*.tar.gz + dist/*.zip - name: Create the GitHub release run: | if gh release view "$TAG" > /dev/null 2>&1; then - echo "Release $TAG already exists" + # A rerun attaches only the archives a failed run left out. + present=$(gh release view "$TAG" --json assets -q '.assets[].name') + for file in dist/*; do + grep -qxF "$(basename "$file")" <<< "$present" || gh release upload "$TAG" "$file" + done exit 0 fi - cat - "$RUNNER_TEMP/notes.md" > "$RUNNER_TEMP/release.md" <<'EOF' - ```sh - cargo install jevgate --locked - ``` + cat - "$RUNNER_TEMP/notes.md" > "$RUNNER_TEMP/release.md" < --repo $GITHUB_REPOSITORY\`. EOF - gh release create "$TAG" --verify-tag --latest --title "JevGate $VERSION" --notes-file "$RUNNER_TEMP/release.md" + gh release create "$TAG" dist/* --verify-tag --latest --title "JevGate $VERSION" --notes-file "$RUNNER_TEMP/release.md" diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d0f6c0..2810cb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ Notable changes to JevGate. Versions follow [Semantic Versioning](https://semver ## [Unreleased] +- Release binaries for Linux (x86_64 and arm64, static), macOS (Apple silicon and Intel) and Windows (x86_64), with SHA-256 checksums and build provenance (`gh attestation verify`). `install.sh` installs a checked binary on Linux and macOS, and `cargo binstall jevgate` finds the archives on every platform. +- Windows: files are named with forward slashes, as on other platforms, so path rules (Next.js routes, Django modules, documentation roles) match there, and reports and baselines name a file the same way everywhere. Requests on Linux and macOS are unchanged, so cached answers stay valid. + ## [0.16.0] - 2026-09-25 Checked against 40 open-source projects in every supported stack (Rust, Python, JavaScript/TypeScript, Go, C#, Java, PHP, Ruby, Svelte, Astro, Vue, Supabase SQL, GitHub Actions), with every review and consider labeled by hand. On the first 17, reviews went from 140 to 107 and considers from 935 to 293, mostly false positives and repeated findings removed; undecided units stayed near 2%. diff --git a/Cargo.toml b/Cargo.toml index 36e38d0..caca02d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,15 @@ categories = ["command-line-utilities", "development-tools"] include = ["/src/**", "/tests/**", "/Cargo.toml", "/Cargo.lock", "/README.md", "/CHANGELOG.md", "/LICENSE-*", "/NOTICE"] license = "MIT OR Apache-2.0" +# `cargo binstall jevgate` downloads the archives the release workflow attaches. +[package.metadata.binstall] +pkg-url = "{ repo }/releases/download/v{ version }/{ name }-{ version }-{ target }{ archive-suffix }" +bin-dir = "{ name }-{ version }-{ target }/{ bin }{ binary-ext }" +pkg-fmt = "tgz" + +[package.metadata.binstall.overrides.x86_64-pc-windows-msvc] +pkg-fmt = "zip" + [dependencies] anyhow = "=1.0.104" clap = { version = "=4.6.6", features = ["derive"] } diff --git a/README.md b/README.md index 2592201..f6297c7 100644 --- a/README.md +++ b/README.md @@ -127,10 +127,14 @@ Other files, such as Kotlin, are listed as skipped with the reason and never fai ## Install ```sh -cargo install jevgate --locked +curl -fsSL https://raw.githubusercontent.com/Tech-Byte-Frontier/jevgate/main/install.sh | sh # Linux and macOS +cargo binstall jevgate # any platform, with cargo-binstall +cargo install jevgate --locked # build from source; needs Rust 1.90 or later ``` -JevGate needs Rust 1.90 or later to build, and a [TypeSafe API key](https://console.typesafe.ai/settings/keys) to review. Git is needed only for `--base` and the staleness rule. +Each [release](https://github.com/Tech-Byte-Frontier/jevgate/releases) has binaries for Linux (x86_64 and arm64, static), macOS (Apple silicon and Intel) and Windows (x86_64), with SHA-256 checksums and build provenance: `gh attestation verify --repo Tech-Byte-Frontier/jevgate`. The install script checks the checksum and installs to `~/.local/bin`; set `JEVGATE_VERSION` or `JEVGATE_INSTALL_DIR` to change the version or place. + +Reviewing needs a [TypeSafe API key](https://console.typesafe.ai/settings/keys). Git is needed only for `--base` and the staleness rule. ## Quick start diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..62e2d73 --- /dev/null +++ b/install.sh @@ -0,0 +1,88 @@ +#!/bin/sh +# Install a JevGate release binary on Linux or macOS. +# +# curl -fsSL https://raw.githubusercontent.com/Tech-Byte-Frontier/jevgate/main/install.sh | sh +# +# JEVGATE_VERSION version to install, such as 0.17.0 (default: the latest release) +# JEVGATE_INSTALL_DIR directory to install into (default: ~/.local/bin) +# +# The archive is checked against its published SHA-256 before anything is +# installed. On Windows, use `cargo binstall jevgate` or download the zip from +# the release page. +set -eu + +repo="Tech-Byte-Frontier/jevgate" +install_dir="${JEVGATE_INSTALL_DIR:-$HOME/.local/bin}" + +fail() { + echo "jevgate install: $*" >&2 + exit 1 +} + +fetch() { + if command -v curl > /dev/null 2>&1; then + curl -fsSL --proto '=https' --tlsv1.2 -o "$2" "$1" + elif command -v wget > /dev/null 2>&1; then + wget -q --https-only -O "$2" "$1" + else + fail "curl or wget is needed" + fi +} + +sha256() { + if command -v sha256sum > /dev/null 2>&1; then + sha256sum "$1" | cut -d ' ' -f 1 + elif command -v shasum > /dev/null 2>&1; then + shasum -a 256 "$1" | cut -d ' ' -f 1 + else + fail "sha256sum or shasum is needed to check the download" + fi +} + +case "$(uname -s)-$(uname -m)" in + Linux-x86_64 | Linux-amd64) target=x86_64-unknown-linux-musl ;; + Linux-aarch64 | Linux-arm64) target=aarch64-unknown-linux-musl ;; + Darwin-x86_64) target=x86_64-apple-darwin ;; + Darwin-arm64) target=aarch64-apple-darwin ;; + *) fail "no release binary for $(uname -s) $(uname -m); build it with: cargo install jevgate --locked" ;; +esac + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +version="${JEVGATE_VERSION:-}" +if [ -z "$version" ]; then + # github.com/…/releases/latest redirects to …/releases/tag/vX.Y.Z; unlike + # the API, it has no rate limit for runners sharing an address. + if command -v curl > /dev/null 2>&1; then + latest=$(curl -fsSLI --proto '=https' -o /dev/null -w '%{url_effective}' "https://github.com/$repo/releases/latest") || latest="" + else + latest=$(wget -q --https-only -S --spider "https://github.com/$repo/releases/latest" 2>&1 | sed -n 's/^ *[Ll]ocation: *//p' | tail -n 1) + fi + version="${latest##*/tag/}" + case "$latest" in */tag/v*) ;; *) version="" ;; esac + [ -n "$version" ] || fail "could not find the latest release; set JEVGATE_VERSION" +fi +version="${version#v}" + +archive="jevgate-$version-$target.tar.gz" +url="https://github.com/$repo/releases/download/v$version/$archive" +echo "Downloading JevGate $version for $target" +fetch "$url" "$tmp/$archive" || fail "could not download $url" +fetch "$url.sha256" "$tmp/$archive.sha256" || fail "could not download $url.sha256" + +expected=$(cut -d ' ' -f 1 < "$tmp/$archive.sha256") +actual=$(sha256 "$tmp/$archive") +[ "$expected" = "$actual" ] || fail "checksum mismatch for $archive (expected $expected, got $actual)" + +tar -xzf "$tmp/$archive" -C "$tmp" +mkdir -p "$install_dir" +cp "$tmp/jevgate-$version-$target/jevgate" "$install_dir/jevgate.tmp" +chmod 755 "$install_dir/jevgate.tmp" +mv -f "$install_dir/jevgate.tmp" "$install_dir/jevgate" + +echo "Installed $("$install_dir/jevgate" --version) to $install_dir/jevgate" +case ":$PATH:" in + *":$install_dir:"*) ;; + *) echo "Add $install_dir to your PATH to run jevgate" ;; +esac diff --git a/src/auth/file.rs b/src/auth/file.rs index e8790b5..b50b48b 100644 --- a/src/auth/file.rs +++ b/src/auth/file.rs @@ -3,7 +3,7 @@ use super::secret::{MAX_KEY_BYTES, Secret}; use anyhow::{Context, Result, ensure}; use std::{ fs, - io::{Read, Write}, + io::Read, path::{Path, PathBuf}, }; @@ -116,6 +116,7 @@ pub fn save(path: &Path, secret: &Secret) -> Result<()> { } #[cfg(unix)] { + use std::io::Write; use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt}; let parent = path.parent().context("Missing credential directory")?; fs::DirBuilder::new() diff --git a/src/context.rs b/src/context.rs index e0abf53..834f42a 100644 --- a/src/context.rs +++ b/src/context.rs @@ -43,8 +43,7 @@ pub fn collect(args: &CheckArgs, context: &ConfigContext) -> Result Result Result { + let relative = path.strip_prefix(root)?; + Ok(if cfg!(windows) { + PathBuf::from(relative.to_string_lossy().replace('\\', "/")) + } else { + relative.to_path_buf() + }) +} pub const SKIPPED_DIRS: &[&str] = &[ "node_modules", diff --git a/src/docs/discover.rs b/src/docs/discover.rs index 68c2328..25d9c27 100644 --- a/src/docs/discover.rs +++ b/src/docs/discover.rs @@ -147,7 +147,7 @@ pub fn discover(root: &Path) -> Result { .build(); for entry in visible { let entry = entry.context("Failed while discovering documentation")?; - let relative = entry.path().strip_prefix(root)?.to_path_buf(); + let relative = crate::discovery::relative(entry.path(), root)?; if entry.file_type().is_some_and(|t| t.is_dir()) { found.directories.insert(relative); } else if agent_file(&relative) { @@ -202,7 +202,7 @@ fn walk_doc_dir(root: &Path, dir: &Path, found: &mut Found) -> Result<()> { .build(); for entry in walk { let entry = entry.context("Failed while discovering documentation")?; - let relative = entry.path().strip_prefix(root)?.to_path_buf(); + let relative = crate::discovery::relative(entry.path(), root)?; if entry.file_type().is_some_and(|t| t.is_file()) && project_doc(&relative) { found.project.insert(relative); } @@ -221,7 +221,7 @@ fn walk_agent_dir(root: &Path, dir: &Path, found: &mut Found) -> Result<()> { .build() { let entry = entry.context("Failed while discovering agent instructions")?; - let relative = entry.path().strip_prefix(root)?.to_path_buf(); + let relative = crate::discovery::relative(entry.path(), root)?; if !entry.file_type().is_some_and(|t| t.is_dir()) && agent_file(&relative) { add_agent(root, relative, found); } @@ -241,7 +241,7 @@ fn add_agent(root: &Path, relative: PathBuf, found: &mut Found) { let target = path .canonicalize() .ok() - .and_then(|t| t.strip_prefix(root).ok().map(Path::to_path_buf)); + .and_then(|t| crate::discovery::relative(&t, root).ok()); found.links.push((relative, target)); } else if metadata.is_file() { found.agent.insert(relative); diff --git a/src/docs/project.rs b/src/docs/project.rs index 19ac891..c3a8cf1 100644 --- a/src/docs/project.rs +++ b/src/docs/project.rs @@ -58,7 +58,10 @@ pub fn read(root: &Path, visited: &BTreeSet) -> Project { fn manifests(root: &Path, base: &Path, linters: &mut BTreeSet) -> Vec { let dir = root.join(base); let read = |name: &str| std::fs::read_to_string(dir.join(name)).ok(); - let path = |name: &str| base.join(name).to_string_lossy().into_owned(); + let path = |name: &str| match base.to_string_lossy() { + base if base.is_empty() => name.to_string(), + base => format!("{base}/{name}"), + }; for (file, tool) in CONFIG_FILES { if dir.join(file).is_file() { linters.insert((*tool).into()); diff --git a/src/init.rs b/src/init.rs index a4a5c7d..ca2f71a 100644 --- a/src/init.rs +++ b/src/init.rs @@ -37,7 +37,7 @@ fn source_patterns(root: &Path) -> Result> { if !entry.file_type().is_some_and(|t| t.is_file()) { continue; } - let relative = entry.path().strip_prefix(root)?; + let relative = &crate::discovery::relative(entry.path(), root)?; if !syntax::supported(relative) || !matches!(classifier.role(relative), "source" | "test") { continue; } diff --git a/src/inventory/django.rs b/src/inventory/django.rs index af51bcb..ba9ff28 100644 --- a/src/inventory/django.rs +++ b/src/inventory/django.rs @@ -32,7 +32,7 @@ pub(super) fn select_settings(context: &ConfigContext, boundary: &Boundary, inpu .filter(|e| e.file_type().is_some_and(|t| t.is_file())) .map(|e| e.path().to_path_buf()); for path in walked.chain(workflows) { - let Ok(relative) = path.strip_prefix(&context.root) else { + let Ok(relative) = &crate::discovery::relative(&path, &context.root) else { continue; }; if !crate::analysis::django::selection_file(relative) @@ -77,7 +77,7 @@ pub(super) fn unescaped_templates( let mut templates = Vec::new(); for entry in walker(&context.root).flatten() { let path = entry.path(); - let Ok(relative) = path.strip_prefix(&context.root) else { + let Ok(relative) = &crate::discovery::relative(path, &context.root) else { continue; }; let Some(name) = crate::analysis::django::template_name(relative) else { diff --git a/src/inventory/mod.rs b/src/inventory/mod.rs index b9c1301..33a98f3 100644 --- a/src/inventory/mod.rs +++ b/src/inventory/mod.rs @@ -142,7 +142,7 @@ fn source_paths( if !entry.file_type().is_some_and(|t| t.is_file()) { continue; } - let relative = path.strip_prefix(&context.root)?; + let relative = &discovery::relative(path, &context.root)?; if discovery::source(relative, &args.source_extension) && selected(relative) && boundary.permits(relative) @@ -167,7 +167,7 @@ fn configuration_files( let mut files = Vec::new(); if args.enabled(crate::catalog::ACCESS_CONTROL) { for entry in walker(&context.root).flatten() { - let relative = entry.path().strip_prefix(&context.root)?; + let relative = &discovery::relative(entry.path(), &context.root)?; if entry.file_type().is_some_and(|t| t.is_file()) && relative.extension().is_some_and(|e| e == "sql") && in_scope(relative) @@ -186,7 +186,7 @@ fn configuration_files( .flatten() { let path = entry.path(); - let relative = path.strip_prefix(&context.root)?; + let relative = &discovery::relative(&path, &context.root)?; if path.is_file() && path.extension().is_some_and(|e| e == "yml" || e == "yaml") && in_scope(relative) @@ -235,9 +235,7 @@ fn load( context: &ConfigContext, extra: &[super::context::ContextInput], ) -> Result { - let relative = path - .strip_prefix(&context.root) - .context("Source outside root")?; + let relative = &discovery::relative(&path, &context.root).context("Source outside root")?; let mut result = pending_result(relative, &role, args, extra); if !matches!(role.as_str(), "source" | "test") { return Ok(excluded(result, &role, relative)); diff --git a/src/storage/mod.rs b/src/storage/mod.rs index e513e7a..02c1959 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -271,6 +271,7 @@ mod tests { assert!(Store::open(&project.0).is_ok()); } + #[cfg(unix)] #[test] fn storage_rejects_symlinked_state_directory() { let project = crate::tests::Project::new(); diff --git a/tests/cli/main.rs b/tests/cli/main.rs index 54c3e59..b6a95f4 100644 --- a/tests/cli/main.rs +++ b/tests/cli/main.rs @@ -6,12 +6,10 @@ mod preview; mod rules; #[path = "../support/temp_dir.rs"] mod temp_dir; +#[cfg(unix)] mod watch; -use std::{ - process::{Command, Stdio}, - time::{Duration, Instant}, -}; +use std::process::{Command, Stdio}; struct Project(temp_dir::TempDir); impl Project { diff --git a/tests/cli/preview.rs b/tests/cli/preview.rs index fb48e76..eb72389 100644 --- a/tests/cli/preview.rs +++ b/tests/cli/preview.rs @@ -91,6 +91,7 @@ fn default_preview_sends_units_without_automatic_context_or_state() { #[test] fn browser_report_is_local_and_does_not_change_json_or_failure_status() { use std::os::unix::fs::PermissionsExt; + use std::time::{Duration, Instant}; let project = Project::new(); std::fs::write(project.0.join("api.py"), "def value(rows):\n total = 0\n for row in rows:\n total += row\n total *= 2\n return total\n").unwrap(); let bin = project.0.join("bin"); diff --git a/tests/cli/watch.rs b/tests/cli/watch.rs index 246cbb6..b761604 100644 --- a/tests/cli/watch.rs +++ b/tests/cli/watch.rs @@ -1,7 +1,7 @@ //! Watching: debounced updates and the writer lock. use super::*; +use std::time::{Duration, Instant}; -#[cfg(unix)] #[test] fn watcher_debounces_updates_and_releases_lock_after_credential_failure() { let project = Project::new();