From 8f91af79832c1b6b11802c9b5c9dc2c8a2859cee Mon Sep 17 00:00:00 2001 From: Sayak Paul Date: Tue, 7 Jul 2026 04:34:11 +0000 Subject: [PATCH 1/6] feat: implement kernel uploads through PRs. --- docs/source/builder-cli.md | 2 + docs/source/builder/build.md | 22 +++++ docs/source/builder/writing-kernels.md | 5 +- kernel-builder/src/main.rs | 40 ++++++++ kernel-builder/src/upload.rs | 129 ++++++++++++++++++++++--- 5 files changed, 182 insertions(+), 16 deletions(-) diff --git a/docs/source/builder-cli.md b/docs/source/builder-cli.md index a4c80025b..8fdd64e1b 100644 --- a/docs/source/builder-cli.md +++ b/docs/source/builder-cli.md @@ -151,6 +151,7 @@ Build the kernel and upload to Hugging Face Hub Possible values: `model`, `kernel` +* `--create-pr` — Open a pull request with the changes rather than committing them directly (does not require write access to the repository) * `-q`, `--quiet` — Suppress progress output @@ -176,6 +177,7 @@ Upload kernel build artifacts to the Hugging Face Hub Possible values: `model`, `kernel` +* `--create-pr` — Open a pull request with the changes rather than committing them directly (does not require write access to the repository) * `-q`, `--quiet` — Suppress progress output diff --git a/docs/source/builder/build.md b/docs/source/builder/build.md index cc2c033df..d1c75488f 100644 --- a/docs/source/builder/build.md +++ b/docs/source/builder/build.md @@ -212,6 +212,28 @@ repo-id = "kernels-community/flash-attn4" See [Writing Kernels](writing-kernels.md) for more details on the `build.toml` format. +### Uploading through a pull request + +If you do not have write access to the target repository — for example, +when proposing builds for a kernel maintained by another user or org — +you can pass `--create-pr` to the `upload` or `build-and-upload` +subcommands: + +```bash +$ kernel-builder build-and-upload --create-pr +``` + +Instead of committing directly, this opens a pull request on the Hub for +each branch that has changes: one against `main` for the kernel card and +one against the `v` branch for the build artifacts and +benchmarks. The pull request URLs are printed after the upload, so that +the repository maintainers can be pointed to them for review. + +> [!NOTE] +> A pull request can only target an existing branch. If the `v` +> branch does not exist yet and you do not have write access to create +> it, ask a maintainer of the repository to create the branch first. + > [!TIP] > You can automate building, uploading, and testing kernels on Hugging Face > Jobs from CI, see [Building and testing kernels with GitHub Actions](./github-actions.md). diff --git a/docs/source/builder/writing-kernels.md b/docs/source/builder/writing-kernels.md index 82a168232..f3e4dfdf1 100644 --- a/docs/source/builder/writing-kernels.md +++ b/docs/source/builder/writing-kernels.md @@ -211,7 +211,10 @@ The following sections enumerate all supported options for `build.toml`. ### `general.hub` - `repo-id`: the Hub repository to upload the kernel to when the `upload` or - `build-and-upload` subcommands of `kernel-builder` are used. + `build-and-upload` subcommands of `kernel-builder` are used. If you do not + have write access to the repository, both subcommands accept the + `--create-pr` option to propose the upload through a pull request instead + (see [Uploading through a pull request](build.md#uploading-through-a-pull-request)). ### `general.cuda` diff --git a/kernel-builder/src/main.rs b/kernel-builder/src/main.rs index 5e4783a07..f92b3cf66 100644 --- a/kernel-builder/src/main.rs +++ b/kernel-builder/src/main.rs @@ -133,6 +133,11 @@ enum Commands { #[arg(long, value_enum, default_value_t = RepoTypeArg::Kernel)] repo_type: RepoTypeArg, + /// Open a pull request with the changes rather than committing them + /// directly (does not require write access to the repository). + #[arg(long)] + create_pr: bool, + /// Suppress progress output. #[arg(long, short)] quiet: bool, @@ -342,6 +347,7 @@ fn main() -> Result<()> { branch, private, repo_type, + create_pr, quiet, } => { run_build( @@ -357,6 +363,7 @@ fn main() -> Result<()> { branch, private, repo_type, + create_pr, quiet, }) } @@ -427,6 +434,39 @@ fn main() -> Result<()> { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_upload_create_pr_flag() { + let cli = Cli::try_parse_from(["kernel-builder", "upload", "--create-pr"]).unwrap(); + match cli.command { + Commands::Upload(args) => assert!(args.create_pr), + _ => panic!("Expected upload subcommand"), + } + } + + #[test] + fn test_upload_create_pr_defaults_to_false() { + let cli = Cli::try_parse_from(["kernel-builder", "upload"]).unwrap(); + match cli.command { + Commands::Upload(args) => assert!(!args.create_pr), + _ => panic!("Expected upload subcommand"), + } + } + + #[test] + fn test_build_and_upload_create_pr_flag() { + let cli = + Cli::try_parse_from(["kernel-builder", "build-and-upload", "--create-pr"]).unwrap(); + match cli.command { + Commands::BuildAndUpload { create_pr, .. } => assert!(create_pr), + _ => panic!("Expected build-and-upload subcommand"), + } + } +} + fn check_config(kernel_dir: Option) -> Result<()> { let kernel_dir = check_or_infer_kernel_dir(kernel_dir)?; parse_and_validate(kernel_dir)?; diff --git a/kernel-builder/src/upload.rs b/kernel-builder/src/upload.rs index 89c6c59ab..b0a8311d3 100644 --- a/kernel-builder/src/upload.rs +++ b/kernel-builder/src/upload.rs @@ -6,11 +6,11 @@ use std::{ }; use clap::Args; -use eyre::{bail, Context, Result}; +use eyre::{bail, eyre, Context, Result}; use hf_hub::{ progress::{Progress, ProgressEvent, ProgressHandler, UploadEvent}, - repository::{AddSource, CommitOperation}, - HFError, RepoType, RepoTypeKernel, RepoTypeModel, + repository::{AddSource, CommitInfo, CommitOperation}, + HFError, HFRepositorySync, RepoType, RepoTypeKernel, RepoTypeModel, }; use indicatif::{ProgressBar, ProgressStyle}; use kernels_data::metadata::Metadata; @@ -92,6 +92,11 @@ pub struct UploadArgs { #[arg(long, value_enum, default_value_t = RepoTypeArg::Kernel)] pub repo_type: RepoTypeArg, + /// Open a pull request with the changes rather than committing them + /// directly (does not require write access to the repository). + #[arg(long)] + pub create_pr: bool, + /// Suppress progress output. #[arg(long, short)] pub quiet: bool, @@ -160,7 +165,10 @@ fn run_upload_typed(args: UploadArgs) -> Result<()> { .exist_ok(true) .send() { - Ok(url) => url, + Ok(url) => Some(url), + // With --create-pr, write access is not required: the repository only + // needs to exist, so continue and let the Hub open a pull request. + Err(HFError::Forbidden { .. }) if args.create_pr => None, Err(err @ HFError::Forbidden { .. }) => { if hf::whoami_username().is_err() { return Err(err).wrap_err("Cannot create repository"); @@ -171,12 +179,15 @@ fn run_upload_typed(args: UploadArgs) -> Result<()> { }; // Extract repo_id from URL, stripping "kernels/" prefix for kernel repos let repo_id = repo_url - .url - .trim_end_matches('/') - .strip_prefix("https://huggingface.co/") - .map(|s| s.strip_prefix("kernels/").unwrap_or(s)) - .unwrap_or(&repo_id) - .to_owned(); + .as_ref() + .and_then(|repo_url| { + repo_url + .url + .trim_end_matches('/') + .strip_prefix("https://huggingface.co/") + .map(|s| s.strip_prefix("kernels/").unwrap_or(s).to_owned()) + }) + .unwrap_or(repo_id); let repo = repo_handle::(&api, &repo_id); @@ -188,10 +199,21 @@ fn run_upload_typed(args: UploadArgs) -> Result<()> { let exists = refs.branches.iter().any(|r| r.name == *branch); if !exists { - repo.create_branch() + let created = repo + .create_branch() .branch(branch) .send() - .wrap_err_with(|| format!("Cannot create branch `{branch}`"))?; + .wrap_err_with(|| format!("Cannot create branch `{branch}`")); + if args.create_pr { + created.wrap_err_with(|| { + format!( + "Pull requests can only target an existing branch. Ask a \ + maintainer of `{repo_id}` to create the branch `{branch}` first." + ) + })?; + } else { + created?; + } } eprintln!( "Using branch `{branch}`{}", @@ -204,6 +226,7 @@ fn run_upload_typed(args: UploadArgs) -> Result<()> { // README goes to main branch, build artifacts go to version branch. let mut operations_by_branch: BTreeMap> = BTreeMap::new(); + let mut pr_urls: Vec = Vec::new(); collect_readme_commit_ops( &kernel_dir, @@ -271,6 +294,11 @@ fn run_upload_typed(args: UploadArgs) -> Result<()> { Some(Progress::new(IndicatifProgress(pb))) }; + // With --create-pr, the first batch opens a pull request against the + // branch; follow-up batches are pushed to the pull request ref so that + // one upload always results in a single pull request per branch. + let mut pr_revision: Option = None; + for (batch_index, chunk) in operations.chunks(BUILD_COMMIT_BATCH_SIZE).enumerate() { let commit_message = if batch_count > 1 { format!( @@ -281,21 +309,43 @@ fn run_upload_typed(args: UploadArgs) -> Result<()> { "Uploaded using `kernel-builder`.".to_owned() }; - repo.create_commit() + let open_pr = args.create_pr && pr_revision.is_none(); + let revision = pr_revision.clone().unwrap_or_else(|| branch.clone()); + + let commit_info = repo + .create_commit() .operations(chunk.to_vec()) .commit_message(&commit_message) - .revision(branch.clone()) + .revision(revision) + .create_pr(open_pr) .maybe_progress(progress_handler.clone()) .send() .wrap_err_with(|| format!("Cannot create commit on branch `{branch}`"))?; + + if open_pr { + let (pr_ref, pr_url) = + resolve_pr(&repo, &repo_id, &commit_info).wrap_err_with(|| { + format!("Cannot determine the pull request opened for branch `{branch}`") + })?; + pr_revision = Some(pr_ref); + pr_urls.push(pr_url); + } } - progress.finish_with_message(format!("Uploaded to `{branch}`")); + progress.finish_with_message(if args.create_pr { + format!("Opened pull request against `{branch}`") + } else { + format!("Uploaded to `{branch}`") + }); } let total_ops: usize = operations_by_branch.values().map(|v| v.len()).sum(); if total_ops == 0 { eprintln!("No changes to upload."); + } else if args.create_pr { + for url in &pr_urls { + println!("Pull request created: {url}"); + } } else { let type_prefix = T::default().url_prefix(); let tree_path = branch @@ -307,6 +357,55 @@ fn run_upload_typed(args: UploadArgs) -> Result<()> { Ok(()) } +/// Resolve the ref and URL of the pull request opened by a `create_pr` commit. +/// +/// The Hub reports the pull request as `pullRequestUrl` in the commit +/// response, which hf-hub 1.0.0-rc.0 does not deserialize into `CommitInfo` +/// (it expects `prUrl`/`prNum`), so fall back to matching the commit OID +/// against the repository's pull-request refs. +fn resolve_pr( + repo: &HFRepositorySync, + repo_id: &str, + commit_info: &CommitInfo, +) -> Result<(String, String)> { + let pr_url_for = |num: &str| { + format!( + "https://hf.co/{}{repo_id}/discussions/{num}", + T::default().url_prefix() + ) + }; + + if let Some(num) = commit_info.pr_num { + let url = commit_info + .pr_url + .clone() + .unwrap_or_else(|| pr_url_for(&num.to_string())); + return Ok((format!("refs/pr/{num}"), url)); + } + + let oid = commit_info + .commit_oid + .as_deref() + .ok_or_else(|| eyre!("The Hub returned neither a pull request nor a commit OID"))?; + let refs = repo + .list_refs() + .include_pull_requests(true) + .send() + .wrap_err("Cannot list repository refs")?; + let pr = refs + .pull_requests + .iter() + .find(|r| r.target_commit == oid) + .ok_or_else(|| eyre!("No pull-request ref points at the created commit `{oid}`"))?; + let num = pr + .git_ref + .rsplit('/') + .next() + .ok_or_else(|| eyre!("Malformed pull-request ref `{}`", pr.git_ref))?; + + Ok((pr.git_ref.clone(), pr_url_for(num))) +} + /// Collect benchmark file commit operations: add matching files, delete stale ones. fn collect_benchmark_commit_ops( kernel_dir: &Path, From 61af13e50a294166573f84a1cb60443be24cf4b5 Mon Sep 17 00:00:00 2001 From: sayakpaul Date: Tue, 7 Jul 2026 14:28:23 +0530 Subject: [PATCH 2/6] remove superficial tests --- kernel-builder/src/main.rs | 33 --------------------------------- 1 file changed, 33 deletions(-) diff --git a/kernel-builder/src/main.rs b/kernel-builder/src/main.rs index f92b3cf66..ccbd6bdfe 100644 --- a/kernel-builder/src/main.rs +++ b/kernel-builder/src/main.rs @@ -434,39 +434,6 @@ fn main() -> Result<()> { } } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_upload_create_pr_flag() { - let cli = Cli::try_parse_from(["kernel-builder", "upload", "--create-pr"]).unwrap(); - match cli.command { - Commands::Upload(args) => assert!(args.create_pr), - _ => panic!("Expected upload subcommand"), - } - } - - #[test] - fn test_upload_create_pr_defaults_to_false() { - let cli = Cli::try_parse_from(["kernel-builder", "upload"]).unwrap(); - match cli.command { - Commands::Upload(args) => assert!(!args.create_pr), - _ => panic!("Expected upload subcommand"), - } - } - - #[test] - fn test_build_and_upload_create_pr_flag() { - let cli = - Cli::try_parse_from(["kernel-builder", "build-and-upload", "--create-pr"]).unwrap(); - match cli.command { - Commands::BuildAndUpload { create_pr, .. } => assert!(create_pr), - _ => panic!("Expected build-and-upload subcommand"), - } - } -} - fn check_config(kernel_dir: Option) -> Result<()> { let kernel_dir = check_or_infer_kernel_dir(kernel_dir)?; parse_and_validate(kernel_dir)?; From 155573cbb93427ebbee53e8572d11e5f1f112891 Mon Sep 17 00:00:00 2001 From: Sayak Paul Date: Tue, 7 Jul 2026 14:29:04 +0530 Subject: [PATCH 3/6] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Daniël de Kok --- kernel-builder/src/upload.rs | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/kernel-builder/src/upload.rs b/kernel-builder/src/upload.rs index b0a8311d3..a899ab345 100644 --- a/kernel-builder/src/upload.rs +++ b/kernel-builder/src/upload.rs @@ -180,14 +180,13 @@ fn run_upload_typed(args: UploadArgs) -> Result<()> { // Extract repo_id from URL, stripping "kernels/" prefix for kernel repos let repo_id = repo_url .as_ref() - .and_then(|repo_url| { + .map(|repo_url| { repo_url .url .trim_end_matches('/') .strip_prefix("https://huggingface.co/") .map(|s| s.strip_prefix("kernels/").unwrap_or(s).to_owned()) - }) - .unwrap_or(repo_id); + }); let repo = repo_handle::(&api, &repo_id); @@ -203,17 +202,17 @@ fn run_upload_typed(args: UploadArgs) -> Result<()> { .create_branch() .branch(branch) .send() - .wrap_err_with(|| format!("Cannot create branch `{branch}`")); - if args.create_pr { - created.wrap_err_with(|| { - format!( + .wrap_err_with(|| + if args.create_pr { + format!( "Pull requests can only target an existing branch. Ask a \ maintainer of `{repo_id}` to create the branch `{branch}` first." ) - })?; - } else { - created?; - } + } + else { + format!("Cannot create branch `{branch}`")) + } + ); } eprintln!( "Using branch `{branch}`{}", From 37ad70f96d14b9a9f861dc9bbad64dd4b751e21a Mon Sep 17 00:00:00 2001 From: sayakpaul Date: Tue, 7 Jul 2026 14:31:16 +0530 Subject: [PATCH 4/6] remove create-pr related documentation from writing-kernels.md --- docs/source/builder/writing-kernels.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/docs/source/builder/writing-kernels.md b/docs/source/builder/writing-kernels.md index f3e4dfdf1..82a168232 100644 --- a/docs/source/builder/writing-kernels.md +++ b/docs/source/builder/writing-kernels.md @@ -211,10 +211,7 @@ The following sections enumerate all supported options for `build.toml`. ### `general.hub` - `repo-id`: the Hub repository to upload the kernel to when the `upload` or - `build-and-upload` subcommands of `kernel-builder` are used. If you do not - have write access to the repository, both subcommands accept the - `--create-pr` option to propose the upload through a pull request instead - (see [Uploading through a pull request](build.md#uploading-through-a-pull-request)). + `build-and-upload` subcommands of `kernel-builder` are used. ### `general.cuda` From 9a9e5d66324af77755dbc4c13fcd7b974c62042b Mon Sep 17 00:00:00 2001 From: sayakpaul Date: Tue, 7 Jul 2026 15:11:14 +0530 Subject: [PATCH 5/6] fix: repair upload.rs syntax broken by review-suggestion commit The "Apply suggestions from code review" commit left two blocks with mismatched delimiters and a nested Option: - repo_id extraction lost its `and_then` flatten and `unwrap_or(repo_id)` fallback, producing `Option>`. - branch-creation error handling had unbalanced parens/braces and dropped the `?` propagation. Restore both so the crate compiles; fixes Clippy, Rustfmt, Test, check-cli-docs, and build jobs. Co-Authored-By: Claude Opus 4.8 (1M context) --- kernel-builder/src/upload.rs | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/kernel-builder/src/upload.rs b/kernel-builder/src/upload.rs index a899ab345..506dd8972 100644 --- a/kernel-builder/src/upload.rs +++ b/kernel-builder/src/upload.rs @@ -180,13 +180,14 @@ fn run_upload_typed(args: UploadArgs) -> Result<()> { // Extract repo_id from URL, stripping "kernels/" prefix for kernel repos let repo_id = repo_url .as_ref() - .map(|repo_url| { + .and_then(|repo_url| { repo_url .url .trim_end_matches('/') .strip_prefix("https://huggingface.co/") .map(|s| s.strip_prefix("kernels/").unwrap_or(s).to_owned()) - }); + }) + .unwrap_or(repo_id); let repo = repo_handle::(&api, &repo_id); @@ -198,21 +199,19 @@ fn run_upload_typed(args: UploadArgs) -> Result<()> { let exists = refs.branches.iter().any(|r| r.name == *branch); if !exists { - let created = repo - .create_branch() + repo.create_branch() .branch(branch) .send() - .wrap_err_with(|| - if args.create_pr { - format!( - "Pull requests can only target an existing branch. Ask a \ - maintainer of `{repo_id}` to create the branch `{branch}` first." - ) - } - else { - format!("Cannot create branch `{branch}`")) - } - ); + .wrap_err_with(|| { + if args.create_pr { + format!( + "Pull requests can only target an existing branch. Ask a \ + maintainer of `{repo_id}` to create the branch `{branch}` first." + ) + } else { + format!("Cannot create branch `{branch}`") + } + })?; } eprintln!( "Using branch `{branch}`{}", From 9f7313332a1eeafdfa51d2b4579ad76cb4a719d1 Mon Sep 17 00:00:00 2001 From: sayakpaul Date: Thu, 9 Jul 2026 11:35:52 +0530 Subject: [PATCH 6/6] don't swallow the error. --- kernel-builder/src/upload.rs | 59 ++++++++++++++++++------------------ 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/kernel-builder/src/upload.rs b/kernel-builder/src/upload.rs index 506dd8972..6a8032e45 100644 --- a/kernel-builder/src/upload.rs +++ b/kernel-builder/src/upload.rs @@ -157,37 +157,38 @@ fn run_upload_typed(args: UploadArgs) -> Result<()> { let (repo_id, branch) = get_repo_and_branch(&kernel_dir, args.repo_id, args.branch, &variants)?; - let repo_url = match api - .create_repository() - .repo_id(&repo_id) - .repo_type(T::default()) - .private(args.private) - .exist_ok(true) - .send() - { - Ok(url) => Some(url), - // With --create-pr, write access is not required: the repository only - // needs to exist, so continue and let the Hub open a pull request. - Err(HFError::Forbidden { .. }) if args.create_pr => None, - Err(err @ HFError::Forbidden { .. }) => { - if hf::whoami_username().is_err() { - return Err(err).wrap_err("Cannot create repository"); + // With --create-pr, write access is not required and we must not create a + // new repository: the repository only needs to already exist for the Hub to + // open a pull request against it. Skip repository creation entirely and use + // the provided repo id as-is. + let repo_id = if args.create_pr { + repo_id + } else { + let repo_url = match api + .create_repository() + .repo_id(&repo_id) + .repo_type(T::default()) + .private(args.private) + .exist_ok(true) + .send() + { + Ok(url) => url, + Err(err @ HFError::Forbidden { .. }) => { + if hf::whoami_username().is_err() { + return Err(err).wrap_err("Cannot create repository"); + } + bail!(kernel_publishing_guidance(&repo_id)); } - bail!(kernel_publishing_guidance(&repo_id)); - } - Err(err) => return Err(err).wrap_err("Cannot create repository"), + Err(err) => return Err(err).wrap_err("Cannot create repository"), + }; + // Extract repo_id from URL, stripping "kernels/" prefix for kernel repos. + repo_url + .url + .trim_end_matches('/') + .strip_prefix("https://huggingface.co/") + .map(|s| s.strip_prefix("kernels/").unwrap_or(s).to_owned()) + .unwrap_or(repo_id) }; - // Extract repo_id from URL, stripping "kernels/" prefix for kernel repos - let repo_id = repo_url - .as_ref() - .and_then(|repo_url| { - repo_url - .url - .trim_end_matches('/') - .strip_prefix("https://huggingface.co/") - .map(|s| s.strip_prefix("kernels/").unwrap_or(s).to_owned()) - }) - .unwrap_or(repo_id); let repo = repo_handle::(&api, &repo_id);