Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.15.1] - 2026-10-06

### Fixed

- Installing Git on Windows no longer fails with "Failed to download JSON"
when the GitHub API rate limit (60 requests/hour unauthenticated) is used
up. DevKit now falls back to the non-API `releases/latest` redirect to find
the MinGit ZIP.
- GitHub API rate-limit errors now say so, including when the limit resets,
instead of a bare `403`.

### Added

- GitHub API requests send `GITHUB_TOKEN` (or `GH_TOKEN`) when set, raising
the rate limit to 5,000 requests/hour.

## [0.15.0] - 2026-10-06

### Added
Expand Down Expand Up @@ -403,6 +419,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Initial project scaffold from auto-gen-py-project

[0.15.1]: https://github.com/axcel-blade/DevKit/releases/tag/v0.15.1
[0.15.0]: https://github.com/axcel-blade/DevKit/releases/tag/v0.15.0
[0.14.0]: https://github.com/axcel-blade/DevKit/releases/tag/v0.14.0
[0.13.0]: https://github.com/axcel-blade/DevKit/releases/tag/v0.13.0
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "devkit"
version = "0.15.0"
version = "0.15.1"
edition = "2021"
description = "DevKit application — install developer SDKs and configure environment variables"
license = "MIT"
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# DevKit

[![Version](https://img.shields.io/badge/version-0.15.0-blue.svg)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-0.15.1-blue.svg)](CHANGELOG.md)
[![CI](https://github.com/axcel-blade/DevKit/actions/workflows/ci.yml/badge.svg)](https://github.com/axcel-blade/DevKit/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE.md)
[![Rust](https://img.shields.io/badge/rust-2021_edition-orange.svg?logo=rust)](https://www.rust-lang.org/)
Expand Down Expand Up @@ -31,7 +31,7 @@ Checking for available versions ...

DevKit
OS: Windows
Version: 0.15.0
Version: 0.15.1
# ID NAME STATUS INSTALLED AVAILABLE
--------------------------------------------------------------------------------------------------------
1 anaconda Anaconda not_installed - 2026.07-1
Expand Down
2 changes: 1 addition & 1 deletion SUPPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

## What to include in a bug report

- DevKit version (`cargo run --release -- --version`) — current is **0.15.0**
- DevKit version (`cargo run --release -- --version`) — current is **0.15.1**
- OS and architecture
- Command you ran
- Full error output
Expand Down
4 changes: 2 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Architecture

DevKit **0.15.0** is a CLI application with a plugin pipeline.
DevKit **0.15.1** is a CLI application with a plugin pipeline.

```text
target/release/devkit (binary) / devkit.bat / devkit.sh
Expand Down Expand Up @@ -66,7 +66,7 @@ Checking for available versions ...

DevKit
OS: Windows
Version: 0.15.0
Version: 0.15.1
# ID NAME STATUS INSTALLED AVAILABLE
--------------------------------------------------------------------------------------------------------
1 anaconda Anaconda not_installed - 2026.07-1
Expand Down
10 changes: 9 additions & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Checking for available versions ...

DevKit
OS: Windows
Version: 0.15.0
Version: 0.15.1
# ID NAME STATUS INSTALLED AVAILABLE
--------------------------------------------------------------------------------------------------------
1 anaconda Anaconda not_installed - 2026.07-1
Expand Down Expand Up @@ -179,3 +179,11 @@ cargo run --release -- uninstall go node # several at once
```

Removes files under the `dev` folder and reverses PATH/env entries DevKit added.

## Troubleshooting: GitHub rate limit

Many plugins look up releases through the GitHub API, which allows 60
unauthenticated requests per hour. If you see "GitHub API rate limit
exceeded", wait for the reset or set `GITHUB_TOKEN` (or `GH_TOKEN`) to a
personal access token to raise the limit to 5,000/hour. Git on Windows falls
back to a non-API lookup automatically.
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Documentation

DevKit **0.15.0** documentation index.
DevKit **0.15.1** documentation index.

| Page | Description |
|------|-------------|
Expand Down
4 changes: 2 additions & 2 deletions docs/plugins.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Plugins

DevKit **0.15.0** ships these built-in plugins. The OS column is where the menu lists the plugin and where `devkit install` accepts it.
DevKit **0.15.1** ships these built-in plugins. The OS column is where the menu lists the plugin and where `devkit install` accepts it.

| ID | OS | Notes |
|----|----|-------|
Expand Down Expand Up @@ -107,7 +107,7 @@ Checking for available versions ...

DevKit
OS: Windows
Version: 0.15.0
Version: 0.15.1
# ID NAME STATUS INSTALLED AVAILABLE
--------------------------------------------------------------------------------------------------------
1 anaconda Anaconda not_installed - 2026.07-1
Expand Down
65 changes: 60 additions & 5 deletions src/download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,68 @@ pub fn ensure_internet_access() -> Result<()> {
)
}

/// Download a JSON document and return the parsed value (object or array).
pub fn download_json_value(url: &str) -> Result<serde_json::Value> {
/// Return a GitHub token from `GITHUB_TOKEN` or `GH_TOKEN`, if set. Sending
/// one raises the GitHub API limit from 60 to 5,000 requests per hour.
fn github_token() -> Option<String> {
["GITHUB_TOKEN", "GH_TOKEN"]
.iter()
.filter_map(|k| std::env::var(k).ok())
.map(|v| v.trim().to_string())
.find(|v| !v.is_empty())
}

/// GET a GitHub REST API URL. Adds the API `Accept` header and an optional
/// token, and turns rate-limit responses into an actionable error instead of
/// a bare "403 Forbidden".
pub fn github_api_get(url: &str) -> Result<ureq::Response> {
ensure_internet_access()?;
let resp = ureq::get(url)
let mut req = ureq::get(url)
.set("User-Agent", "DevKit")
.call()
.with_context(|| format!("Failed to download JSON {url}"))?;
.set("Accept", "application/vnd.github+json");
if let Some(token) = github_token() {
req = req.set("Authorization", &format!("Bearer {token}"));
}
match req.call() {
Ok(resp) => Ok(resp),
Err(ureq::Error::Status(code, resp))
if (code == 403 || code == 429)
&& resp.header("x-ratelimit-remaining") == Some("0") =>
{
// `x-ratelimit-reset` is a Unix timestamp; show minutes until reset.
let wait = resp
.header("x-ratelimit-reset")
.and_then(|v| v.parse::<u64>().ok())
.and_then(|reset| {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_secs();
Some(reset.saturating_sub(now).div_ceil(60))
})
.map(|m| format!(" Resets in about {m} minute(s)."))
.unwrap_or_default();
bail!(
"GitHub API rate limit exceeded ({url}).{wait} \
Set GITHUB_TOKEN (or GH_TOKEN) to a GitHub personal access \
token to raise the limit."
)
}
Err(e) => Err(e).with_context(|| format!("GitHub API request failed: {url}")),
}
}

/// Download a JSON document and return the parsed value (object or array).
pub fn download_json_value(url: &str) -> Result<serde_json::Value> {
// GitHub API calls go through the helper for token + rate-limit handling.
let resp = if url.starts_with("https://api.github.com/") {
github_api_get(url).with_context(|| format!("Failed to download JSON {url}"))?
} else {
ensure_internet_access()?;
ureq::get(url)
.set("User-Agent", "DevKit")
.call()
.with_context(|| format!("Failed to download JSON {url}"))?
};
let value: serde_json::Value = resp
.into_json()
.with_context(|| format!("Failed to parse JSON from {url}"))?;
Expand Down
12 changes: 2 additions & 10 deletions src/plugin_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,8 @@ use std::path::{Path, PathBuf};

/// Fetch the latest GitHub release JSON for `owner/repo`.
pub fn github_latest_release(owner: &str, repo: &str) -> Result<Value> {
crate::download::ensure_internet_access()?;
let url = format!("https://api.github.com/repos/{owner}/{repo}/releases/latest");
let resp = ureq::get(&url)
.set("User-Agent", "DevKit")
.set("Accept", "application/vnd.github+json")
.call()
let resp = crate::download::github_api_get(&url)
.with_context(|| format!("Failed to query GitHub release {owner}/{repo}"))?;
let value: Value = resp
.into_json()
Expand All @@ -25,13 +21,9 @@ pub fn github_latest_release(owner: &str, repo: &str) -> Result<Value> {

/// Fetch a GitHub release by tag (tag slashes are percent-encoded).
pub fn github_release_by_tag(owner: &str, repo: &str, tag: &str) -> Result<Value> {
crate::download::ensure_internet_access()?;
let encoded = tag.replace('/', "%2F");
let url = format!("https://api.github.com/repos/{owner}/{repo}/releases/tags/{encoded}");
let resp = ureq::get(&url)
.set("User-Agent", "DevKit")
.set("Accept", "application/vnd.github+json")
.call()
let resp = crate::download::github_api_get(&url)
.with_context(|| format!("Failed to query GitHub release {owner}/{repo} tag {tag}"))?;
let value: Value = resp
.into_json()
Expand Down
63 changes: 63 additions & 0 deletions src/plugins/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ use std::path::Path;
use std::path::PathBuf;

const GFW_LATEST: &str = "https://api.github.com/repos/git-for-windows/git/releases/latest";
/// Non-API page that redirects to the latest release tag (fallback lookup).
const GFW_LATEST_PAGE: &str = "https://github.com/git-for-windows/git/releases/latest";
const MARKER: &str = ".devkit-git";

fn git_binary(ctx: &InstallContext) -> Option<PathBuf> {
Expand All @@ -38,7 +40,61 @@ fn path_dirs(ctx: &InstallContext) -> Vec<PathBuf> {
}

/// Pick the latest MinGit ZIP URL for this Windows CPU arch. Returns `(url, version_label)`.
///
/// Tries the GitHub API first; if that fails (e.g. the unauthenticated rate
/// limit is exhausted), falls back to the non-API `releases/latest` redirect.
fn resolve_mingit_url() -> Result<(String, String)> {
match resolve_mingit_url_api() {
Ok(found) => Ok(found),
Err(api_err) => resolve_mingit_url_redirect().map_err(|fallback_err| {
anyhow::anyhow!("{api_err:#}\nFallback lookup also failed: {fallback_err:#}")
}),
}
}

/// MinGit asset suffix for this CPU arch (`64-bit`, `arm64`, or `32-bit`).
fn mingit_arch_suffix() -> &'static str {
match cpu_arch().as_str() {
"aarch64" => "arm64",
"x64" => "64-bit",
_ => "32-bit",
}
}

/// Map a git-for-windows tag to the version used in MinGit asset names:
/// `v2.47.0.windows.1` -> `2.47.0`, `v2.47.0.windows.2` -> `2.47.0.2`.
fn mingit_version_from_tag(tag: &str) -> Option<String> {
let rest = tag.strip_prefix('v')?;
let (base, build) = rest.split_once(".windows.")?;
match build {
"1" => Some(base.to_string()),
n if n.chars().all(|c| c.is_ascii_digit()) => Some(format!("{base}.{n}")),
_ => None,
}
}

/// Resolve the latest tag by following github.com's `releases/latest`
/// redirect (not rate-limited like the API), then build the asset URL.
fn resolve_mingit_url_redirect() -> Result<(String, String)> {
crate::download::ensure_internet_access()?;
let resp = ureq::get(GFW_LATEST_PAGE)
.set("User-Agent", "DevKit")
.call()?;
// Final URL looks like .../releases/tag/v2.47.0.windows.1
let tag = resp
.get_url()
.rsplit_once("/tag/")
.map(|(_, t)| t.to_string())
.ok_or_else(|| anyhow::anyhow!("Could not read latest Git for Windows tag"))?;
let version = mingit_version_from_tag(&tag)
.ok_or_else(|| anyhow::anyhow!("Unexpected Git for Windows tag format: {tag}"))?;
let name = format!("MinGit-{version}-{}.zip", mingit_arch_suffix());
let url = format!("https://github.com/git-for-windows/git/releases/download/{tag}/{name}");
Ok((url, tag))
}

/// Resolve the MinGit asset via the GitHub releases API.
fn resolve_mingit_url_api() -> Result<(String, String)> {
let arch = cpu_arch();
let want = match arch.as_str() {
"aarch64" => regex::Regex::new(r"(?i)^MinGit-.*-arm64\.zip$").unwrap(),
Expand Down Expand Up @@ -241,4 +297,11 @@ mod tests {
};
assert!(git_binary(&ctx).is_none());
}

#[test]
fn mingit_version_from_tag_handles_builds() {
assert_eq!(mingit_version_from_tag("v2.47.0.windows.1").as_deref(), Some("2.47.0"));
assert_eq!(mingit_version_from_tag("v2.47.0.windows.2").as_deref(), Some("2.47.0.2"));
assert_eq!(mingit_version_from_tag("2.47.0"), None);
}
}
Loading