[M8] cargoxx publish: open a recipe PR via tea / Gitea API
This commit is contained in:
35
CHANGELOG.md
35
CHANGELOG.md
@@ -528,3 +528,38 @@ All notable changes to cargoxx will be documented in this file.
|
||||
Codegen unchanged — the synthesized recipe (find_package +
|
||||
`<name>::<name>` target) is identical to the path-dep case, just
|
||||
with a different `linkdb_source` discriminator.
|
||||
- M8 `cargoxx publish` (Phase 2c). Publishes the project's HEAD as a
|
||||
new version recipe in a Gitea-hosted cargoxx registry. Steps:
|
||||
1. Validate `Cargoxx.toml` has `name`, `version`, `license`.
|
||||
2. Verify the working tree is clean and `Cargoxx.lock` exists.
|
||||
3. Reject path-dep deps (registry can't reference local trees).
|
||||
4. Read `git remote get-url origin` + `git rev-parse HEAD`,
|
||||
normalize SSH URLs to https.
|
||||
5. Compute the source SRI hash via `nix flake prefetch
|
||||
git+<url>?rev=<commit> --json`.
|
||||
6. Build the recipe TOML (mirrors `Cargoxx.toml`'s deps + `[meta]`).
|
||||
7. POST `/repos/<registry>/contents` with `{branch: "master",
|
||||
new_branch: "publish/<name>-<version>", files: [...]}` — a
|
||||
single atomic commit creating both `recipes/<name>/versions/
|
||||
<v>.toml` and (for new packages) `recipes/<name>/maintainers.txt`
|
||||
pre-populated with the publisher's Gitea username (`tea api
|
||||
/user`).
|
||||
8. POST `/repos/<registry>/pulls` to open the PR.
|
||||
|
||||
`--dry-run` prints the recipe TOML and exits without any network
|
||||
ops. `--registry <owner>/<repo>` overrides the default
|
||||
(`$CARGOXX_REGISTRY`, falling back to `mozart/cargoxx-pkgs`).
|
||||
Authentication comes from `tea login` — no separate token plumbing.
|
||||
|
||||
Manifest schema gains three optional `[package]` fields used in the
|
||||
recipe's `[meta]` block: `description`, `repository`, `homepage`.
|
||||
|
||||
The cargoxx wrapper (`flake.nix`'s `cargoxxRuntimePath`) now bundles
|
||||
`pkgs.tea` so the binary can shell out to it on non-Nix hosts.
|
||||
|
||||
Verified end-to-end against
|
||||
`https://git.amadey.xyz/mozart/cargoxx-pkgs`: from a
|
||||
`mozart/greeter` checkout, `cargoxx publish` opens PR #3 with
|
||||
branch `publish/greeter-0.1.0` containing a fresh
|
||||
`recipes/greeter/versions/0.1.0.toml` and a new
|
||||
`recipes/greeter/maintainers.txt`.
|
||||
|
||||
@@ -4,7 +4,10 @@ set(CMAKE_EXPERIMENTAL_CXX_MODULE_DYNDEP 1)
|
||||
set(CMAKE_EXPERIMENTAL_CXX_IMPORT_STD "d0edc3af-4c50-42ea-a356-e2862fe7a444")
|
||||
set(CMAKE_CXX_MODULE_STD ON)
|
||||
|
||||
project(cargoxx LANGUAGES CXX)
|
||||
project(cargoxx VERSION 0.1.0 LANGUAGES CXX)
|
||||
|
||||
include(GNUInstallDirs)
|
||||
include(CMakePackageConfigHelpers)
|
||||
|
||||
# Generated by cargoxx — do not edit.
|
||||
# Source of truth: ../Cargoxx.toml
|
||||
@@ -43,6 +46,7 @@ target_sources(cargoxx
|
||||
../src/cli/cmd_clean.cpp
|
||||
../src/cli/cmd_linkdb_add.cpp
|
||||
../src/cli/cmd_new.cpp
|
||||
../src/cli/cmd_publish.cpp
|
||||
../src/cli/cmd_remove.cpp
|
||||
../src/cli/cmd_run.cpp
|
||||
../src/cli/cmd_test.cpp
|
||||
@@ -75,20 +79,70 @@ target_sources(cargoxx
|
||||
../src/util/levenshtein.cpp
|
||||
../src/util/semver.cpp
|
||||
)
|
||||
target_compile_features(cargoxx PUBLIC cxx_std_23)
|
||||
target_include_directories(cargoxx SYSTEM PRIVATE ../third_party)
|
||||
target_link_libraries(cargoxx PUBLIC
|
||||
reproc
|
||||
SQLite::SQLite3
|
||||
)
|
||||
|
||||
# ----- install + package-config + pkg-config -----
|
||||
install(TARGETS cargoxx
|
||||
EXPORT cargoxxTargets
|
||||
FILE_SET CXX_MODULES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/cargoxx
|
||||
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR})
|
||||
install(EXPORT cargoxxTargets
|
||||
FILE cargoxxTargets.cmake
|
||||
NAMESPACE cargoxx::
|
||||
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/cargoxx)
|
||||
|
||||
file(WRITE ${CMAKE_CURRENT_BINARY_DIR}/cargoxxConfig.cmake.in [[
|
||||
@PACKAGE_INIT@
|
||||
include(CMakeFindDependencyMacro)
|
||||
include("${CMAKE_CURRENT_LIST_DIR}/cargoxxTargets.cmake")
|
||||
check_required_components(cargoxx)
|
||||
]])
|
||||
configure_package_config_file(
|
||||
${CMAKE_CURRENT_BINARY_DIR}/cargoxxConfig.cmake.in
|
||||
${CMAKE_CURRENT_BINARY_DIR}/cargoxxConfig.cmake
|
||||
INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/cargoxx)
|
||||
write_basic_package_version_file(
|
||||
${CMAKE_CURRENT_BINARY_DIR}/cargoxxConfigVersion.cmake
|
||||
VERSION ${PROJECT_VERSION}
|
||||
COMPATIBILITY SameMajorVersion)
|
||||
install(FILES
|
||||
${CMAKE_CURRENT_BINARY_DIR}/cargoxxConfig.cmake
|
||||
${CMAKE_CURRENT_BINARY_DIR}/cargoxxConfigVersion.cmake
|
||||
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/cargoxx)
|
||||
|
||||
file(WRITE ${CMAKE_CURRENT_BINARY_DIR}/cargoxx.pc.in [[
|
||||
prefix=@CMAKE_INSTALL_PREFIX@
|
||||
exec_prefix=${prefix}
|
||||
libdir=${prefix}/${CMAKE_INSTALL_LIBDIR}
|
||||
includedir=${prefix}/${CMAKE_INSTALL_INCLUDEDIR}
|
||||
|
||||
Name: @PROJECT_NAME@
|
||||
Version: @PROJECT_VERSION@
|
||||
Description: @PROJECT_NAME@
|
||||
Cflags: -I${includedir}
|
||||
Libs: -L${libdir} -l@PROJECT_NAME@
|
||||
]])
|
||||
configure_file(${CMAKE_CURRENT_BINARY_DIR}/cargoxx.pc.in
|
||||
${CMAKE_CURRENT_BINARY_DIR}/cargoxx.pc @ONLY)
|
||||
install(FILES ${CMAKE_CURRENT_BINARY_DIR}/cargoxx.pc
|
||||
DESTINATION ${CMAKE_INSTALL_LIBDIR}/pkgconfig)
|
||||
|
||||
# ----- binary target -----
|
||||
add_executable(cargoxx_bin ../src/main.cpp)
|
||||
set_target_properties(cargoxx_bin PROPERTIES OUTPUT_NAME cargoxx)
|
||||
set_target_properties(cargoxx_bin PROPERTIES
|
||||
OUTPUT_NAME cargoxx
|
||||
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin")
|
||||
target_link_libraries(cargoxx_bin PRIVATE
|
||||
cargoxx
|
||||
reproc
|
||||
SQLite::SQLite3
|
||||
)
|
||||
install(TARGETS cargoxx_bin RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
|
||||
|
||||
# ----- tests -----
|
||||
enable_testing()
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
pkgs.ninja
|
||||
pkgs.curl
|
||||
pkgs.git
|
||||
pkgs.tea # used by `cargoxx publish` for Gitea API + auth
|
||||
];
|
||||
|
||||
# Defaults applied to the bundled `nix` so it works on hosts
|
||||
|
||||
@@ -29,6 +29,20 @@ auto cmd_vendor(const std::filesystem::path& project_root,
|
||||
const std::filesystem::path& output)
|
||||
-> util::Result<void>;
|
||||
|
||||
// Publish the project's current HEAD as a new version recipe in the
|
||||
// cargoxx-pkgs registry. Validates manifest + lockfile, computes the
|
||||
// source sha256 via `nix flake prefetch`, writes
|
||||
// `recipes/<name>/versions/<version>.toml` (and `maintainers.txt` for
|
||||
// new packages) into a `publish/<name>-<version>` branch via the
|
||||
// Gitea contents API, opens a PR via `tea api`. With `dry_run=true`,
|
||||
// prints the recipe TOML and skips all network operations.
|
||||
//
|
||||
// `registry_slug` is "<owner>/<repo>" (default: $CARGOXX_REGISTRY or
|
||||
// "mozart/cargoxx-pkgs"). Authentication comes from `tea login`.
|
||||
auto cmd_publish(const std::filesystem::path& project_root, bool dry_run,
|
||||
std::optional<std::string> registry_slug = std::nullopt)
|
||||
-> util::Result<void>;
|
||||
|
||||
// Builds the project, picks a binary target, and execs it with `args`.
|
||||
// `bin_name` is required when the project declares more than one binary.
|
||||
// Returns the binary's exit code, or an Error if selection or build fails.
|
||||
|
||||
461
src/cli/cmd_publish.cpp
Normal file
461
src/cli/cmd_publish.cpp
Normal file
@@ -0,0 +1,461 @@
|
||||
module;
|
||||
|
||||
#include <json.hpp>
|
||||
|
||||
module cargoxx.cli;
|
||||
|
||||
import std;
|
||||
import cargoxx.util;
|
||||
import cargoxx.manifest;
|
||||
import cargoxx.lockfile;
|
||||
import cargoxx.exec;
|
||||
|
||||
namespace cargoxx::cli {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr std::string_view DEFAULT_REGISTRY = "mozart/cargoxx-pkgs";
|
||||
|
||||
auto err(util::ErrorCode code, std::string msg, std::string hint = "")
|
||||
-> util::Error {
|
||||
return util::Error{code, std::move(msg), std::move(hint), std::nullopt,
|
||||
std::nullopt};
|
||||
}
|
||||
|
||||
auto trim(std::string s) -> std::string {
|
||||
auto end = s.find_last_not_of(" \t\r\n");
|
||||
if (end != std::string::npos) {
|
||||
s.erase(end + 1);
|
||||
}
|
||||
auto start = s.find_first_not_of(" \t\r\n");
|
||||
if (start == std::string::npos) {
|
||||
return {};
|
||||
}
|
||||
return s.substr(start);
|
||||
}
|
||||
|
||||
// Run a process, return trimmed stdout on success.
|
||||
auto capture(const std::string& prog, std::vector<std::string> args,
|
||||
const fs::path& cwd) -> util::Result<std::string> {
|
||||
auto r = exec::run(prog, args,
|
||||
exec::ExecOptions{
|
||||
.cwd = cwd,
|
||||
.env_overrides = {},
|
||||
.timeout = std::chrono::seconds{60},
|
||||
.inherit_stdio = false,
|
||||
});
|
||||
if (!r) {
|
||||
return std::unexpected(r.error());
|
||||
}
|
||||
if (r->exit_code != 0) {
|
||||
return std::unexpected(err(
|
||||
util::ErrorCode::ExecCommandFailed,
|
||||
std::format("{} failed (exit {}): {}", prog, r->exit_code,
|
||||
r->stderr_text)));
|
||||
}
|
||||
return trim(r->stdout_text);
|
||||
}
|
||||
|
||||
// Normalize a git remote URL to an https form Nix can fetch.
|
||||
// `git@host:owner/repo.git` → `https://host/owner/repo`
|
||||
// `https://host/owner/repo.git` → `https://host/owner/repo`
|
||||
// `ssh://git@host:port/owner/repo.git` → `https://host/owner/repo`
|
||||
auto normalize_remote(std::string url) -> std::string {
|
||||
if (url.starts_with("git@")) {
|
||||
// git@host:owner/repo[.git]
|
||||
auto colon = url.find(':');
|
||||
if (colon != std::string::npos) {
|
||||
auto host = url.substr(4, colon - 4);
|
||||
auto path = url.substr(colon + 1);
|
||||
url = std::format("https://{}/{}", host, path);
|
||||
}
|
||||
} else if (url.starts_with("ssh://git@")) {
|
||||
url.replace(0, std::string_view{"ssh://git@"}.size(), "https://");
|
||||
// Strip :PORT from ssh form.
|
||||
auto slash = url.find('/', 8);
|
||||
auto colon = url.find(':', 8);
|
||||
if (colon != std::string::npos && colon < slash) {
|
||||
url.erase(colon, slash - colon);
|
||||
}
|
||||
}
|
||||
if (url.ends_with(".git")) {
|
||||
url.erase(url.size() - 4);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
// Encode a string as base64 (RFC 4648, no line wrap). Inline impl avoids
|
||||
// dragging in another library — Gitea's contents-API requires base64
|
||||
// bodies for file content.
|
||||
auto b64encode(std::string_view bytes) -> std::string {
|
||||
static constexpr std::string_view alphabet =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
std::string out;
|
||||
out.reserve((bytes.size() + 2) / 3 * 4);
|
||||
std::size_t i = 0;
|
||||
while (i + 3 <= bytes.size()) {
|
||||
auto a = static_cast<unsigned char>(bytes[i]);
|
||||
auto b = static_cast<unsigned char>(bytes[i + 1]);
|
||||
auto c = static_cast<unsigned char>(bytes[i + 2]);
|
||||
out += alphabet[(a >> 2) & 0x3f];
|
||||
out += alphabet[((a << 4) | (b >> 4)) & 0x3f];
|
||||
out += alphabet[((b << 2) | (c >> 6)) & 0x3f];
|
||||
out += alphabet[c & 0x3f];
|
||||
i += 3;
|
||||
}
|
||||
if (i < bytes.size()) {
|
||||
auto a = static_cast<unsigned char>(bytes[i]);
|
||||
out += alphabet[(a >> 2) & 0x3f];
|
||||
if (i + 1 == bytes.size()) {
|
||||
out += alphabet[(a << 4) & 0x3f];
|
||||
out += "==";
|
||||
} else {
|
||||
auto b = static_cast<unsigned char>(bytes[i + 1]);
|
||||
out += alphabet[((a << 4) | (b >> 4)) & 0x3f];
|
||||
out += alphabet[(b << 2) & 0x3f];
|
||||
out += '=';
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
auto escape_toml(std::string_view s) -> std::string {
|
||||
std::string out;
|
||||
out.reserve(s.size() + 2);
|
||||
out += '"';
|
||||
for (char c : s) {
|
||||
if (c == '\\' || c == '"') {
|
||||
out += '\\';
|
||||
}
|
||||
out += c;
|
||||
}
|
||||
out += '"';
|
||||
return out;
|
||||
}
|
||||
|
||||
auto build_recipe(const manifest::Manifest& m, const lockfile::Lockfile& lock,
|
||||
std::string_view source_url, std::string_view source_commit,
|
||||
std::string_view source_sha256) -> std::string {
|
||||
std::string out;
|
||||
out += "schema = 1\n";
|
||||
out += std::format("name = {}\n", escape_toml(m.package.name));
|
||||
out += std::format("version = {}\n\n", escape_toml(m.package.version));
|
||||
|
||||
out += "[source]\n";
|
||||
out += "type = \"git\"\n";
|
||||
out += std::format("url = {}\n", escape_toml(source_url));
|
||||
out += std::format("commit = {}\n", escape_toml(source_commit));
|
||||
out += std::format("sha256 = {}\n\n", escape_toml(source_sha256));
|
||||
|
||||
if (!m.dependencies.empty()) {
|
||||
out += "[dependencies]\n";
|
||||
for (const auto& d : m.dependencies) {
|
||||
if (d.source == manifest::DepSource::CargoxxPath) {
|
||||
// Path deps can't be published — caller already rejected.
|
||||
continue;
|
||||
}
|
||||
out += std::format("{} = {}\n", d.name, escape_toml(d.version_spec));
|
||||
}
|
||||
out += "\n";
|
||||
}
|
||||
|
||||
out += "[lock]\n";
|
||||
if (lock.nixpkgs_rev_pin) {
|
||||
out += std::format("nixpkgs_rev = {}\n", escape_toml(*lock.nixpkgs_rev_pin));
|
||||
}
|
||||
if (lock.flake_utils_rev_pin) {
|
||||
out += std::format("flake_utils_rev = {}\n",
|
||||
escape_toml(*lock.flake_utils_rev_pin));
|
||||
}
|
||||
out += "\n";
|
||||
|
||||
out += "[meta]\n";
|
||||
if (m.package.description) {
|
||||
out += std::format("description = {}\n", escape_toml(*m.package.description));
|
||||
} else {
|
||||
out += std::format("description = {}\n", escape_toml(m.package.name));
|
||||
}
|
||||
if (m.package.homepage) {
|
||||
out += std::format("homepage = {}\n", escape_toml(*m.package.homepage));
|
||||
}
|
||||
if (m.package.license) {
|
||||
out += std::format("license = {}\n", escape_toml(*m.package.license));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
auto extract_json_string(std::string_view body, std::string_view key)
|
||||
-> std::optional<std::string> {
|
||||
auto needle = std::format("\"{}\":\"", key);
|
||||
auto pos = body.find(needle);
|
||||
if (pos == std::string_view::npos) {
|
||||
return std::nullopt;
|
||||
}
|
||||
pos += needle.size();
|
||||
auto end = body.find('"', pos);
|
||||
if (end == std::string_view::npos) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return std::string{body.substr(pos, end - pos)};
|
||||
}
|
||||
|
||||
auto tea_whoami() -> util::Result<std::string> {
|
||||
auto r = capture("tea", {"api", "/user"}, fs::current_path());
|
||||
if (!r) {
|
||||
return std::unexpected(r.error());
|
||||
}
|
||||
auto login = extract_json_string(*r, "login");
|
||||
if (!login) {
|
||||
return std::unexpected(err(util::ErrorCode::Internal,
|
||||
"tea api /user returned no 'login' field"));
|
||||
}
|
||||
return *login;
|
||||
}
|
||||
|
||||
auto path_exists_remote(const std::string& registry, const std::string& path)
|
||||
-> bool {
|
||||
auto r = exec::run("tea",
|
||||
{"api", std::format("/repos/{}/contents/{}", registry, path)},
|
||||
exec::ExecOptions{
|
||||
.cwd = fs::current_path(),
|
||||
.env_overrides = {},
|
||||
.timeout = std::chrono::seconds{30},
|
||||
.inherit_stdio = false,
|
||||
});
|
||||
if (!r) {
|
||||
return false;
|
||||
}
|
||||
return r->exit_code == 0 && r->stdout_text.find("\"name\":") != std::string::npos;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
auto cmd_publish(const fs::path& project_root, bool dry_run,
|
||||
std::optional<std::string> registry_slug_arg) -> util::Result<void> {
|
||||
// 1. Read manifest + lockfile
|
||||
auto m = manifest::parse(project_root / "Cargoxx.toml");
|
||||
if (!m) {
|
||||
return std::unexpected(m.error());
|
||||
}
|
||||
if (m->package.name.empty() || m->package.version.empty()) {
|
||||
return std::unexpected(err(util::ErrorCode::ManifestInvalidField,
|
||||
"publish requires [package].name and .version"));
|
||||
}
|
||||
if (!m->package.license) {
|
||||
return std::unexpected(err(util::ErrorCode::ManifestInvalidField,
|
||||
"publish requires [package].license"));
|
||||
}
|
||||
|
||||
auto lock_path = project_root / "Cargoxx.lock";
|
||||
if (std::error_code ec; !fs::exists(lock_path, ec)) {
|
||||
return std::unexpected(err(util::ErrorCode::ManifestNotFound,
|
||||
"Cargoxx.lock is missing — run `cargoxx build` first"));
|
||||
}
|
||||
auto lock = lockfile::parse(lock_path);
|
||||
if (!lock) {
|
||||
return std::unexpected(lock.error());
|
||||
}
|
||||
|
||||
// 2. Path deps are disallowed when publishing (they're local-only).
|
||||
for (const auto& d : m->dependencies) {
|
||||
if (d.source == manifest::DepSource::CargoxxPath) {
|
||||
return std::unexpected(err(
|
||||
util::ErrorCode::ManifestInvalidField,
|
||||
std::format("path dep '{}' cannot be published — convert to "
|
||||
"{{ git = ..., rev = ... }} or release the dep "
|
||||
"separately first", d.name)));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Git context.
|
||||
auto remote_url = capture("git", {"remote", "get-url", "origin"}, project_root);
|
||||
if (!remote_url) {
|
||||
return std::unexpected(err(util::ErrorCode::ManifestInvalidField,
|
||||
"no `origin` git remote",
|
||||
"git remote add origin <url>"));
|
||||
}
|
||||
auto source_url = normalize_remote(*remote_url);
|
||||
|
||||
auto head = capture("git", {"rev-parse", "HEAD"}, project_root);
|
||||
if (!head) {
|
||||
return std::unexpected(head.error());
|
||||
}
|
||||
|
||||
// Working tree must be clean — otherwise the published source won't
|
||||
// match what's actually in the repo at HEAD.
|
||||
auto status = capture("git", {"status", "--porcelain"}, project_root);
|
||||
if (!status) {
|
||||
return std::unexpected(status.error());
|
||||
}
|
||||
if (!status->empty()) {
|
||||
return std::unexpected(err(util::ErrorCode::ManifestInvalidField,
|
||||
"git working tree is not clean",
|
||||
"commit or stash changes before publishing"));
|
||||
}
|
||||
|
||||
// 4. Compute source sha256 via nix flake prefetch — the published
|
||||
// recipe pins this; the registry CI re-verifies it.
|
||||
auto flake_ref = std::format("git+{}?rev={}", source_url, *head);
|
||||
auto prefetch = capture("nix",
|
||||
{"--extra-experimental-features",
|
||||
"nix-command flakes", "flake", "prefetch",
|
||||
flake_ref, "--json"},
|
||||
project_root);
|
||||
if (!prefetch) {
|
||||
return std::unexpected(err(util::ErrorCode::ResolutionNetworkError,
|
||||
std::format("nix flake prefetch failed for {}",
|
||||
flake_ref)));
|
||||
}
|
||||
auto source_sha256 = extract_json_string(*prefetch, "hash");
|
||||
if (!source_sha256) {
|
||||
return std::unexpected(err(util::ErrorCode::ResolutionNetworkError,
|
||||
"nix flake prefetch returned no hash"));
|
||||
}
|
||||
|
||||
// 5. Build the recipe TOML.
|
||||
auto recipe = build_recipe(*m, *lock, source_url, *head, *source_sha256);
|
||||
|
||||
if (dry_run) {
|
||||
std::cout << recipe;
|
||||
return {};
|
||||
}
|
||||
|
||||
// 6. Registry slug + auth.
|
||||
auto registry = registry_slug_arg.value_or([]() -> std::string {
|
||||
auto* env = std::getenv("CARGOXX_REGISTRY");
|
||||
return env && *env ? env : std::string{DEFAULT_REGISTRY};
|
||||
}());
|
||||
|
||||
auto publisher = tea_whoami();
|
||||
if (!publisher) {
|
||||
return std::unexpected(publisher.error());
|
||||
}
|
||||
|
||||
// 7. Compose the API payload — one atomic commit creating the
|
||||
// version recipe, plus maintainers.txt for first-time packages.
|
||||
auto branch = std::format("publish/{}-{}", m->package.name, m->package.version);
|
||||
auto version_path = std::format("recipes/{}/versions/{}.toml",
|
||||
m->package.name, m->package.version);
|
||||
auto maintainers_path = std::format("recipes/{}/maintainers.txt", m->package.name);
|
||||
bool is_new_package = !path_exists_remote(registry, maintainers_path);
|
||||
|
||||
nlohmann::json files = nlohmann::json::array();
|
||||
files.push_back({
|
||||
{"operation", "create"},
|
||||
{"path", version_path},
|
||||
{"content", b64encode(recipe)},
|
||||
});
|
||||
if (is_new_package) {
|
||||
std::string maintainers_body = *publisher + "\n";
|
||||
files.push_back({
|
||||
{"operation", "create"},
|
||||
{"path", maintainers_path},
|
||||
{"content", b64encode(maintainers_body)},
|
||||
});
|
||||
}
|
||||
|
||||
nlohmann::json body = {
|
||||
{"branch", "master"},
|
||||
{"new_branch", branch},
|
||||
{"message", std::format("publish: {} {}", m->package.name, m->package.version)},
|
||||
{"files", files},
|
||||
};
|
||||
|
||||
// tea api with `-d @<file>` reads the body from a file — write it
|
||||
// to a temp path. exec::run doesn't pipe stdin, so a tempfile is
|
||||
// the simplest portable plumbing.
|
||||
auto contents_tmp = fs::temp_directory_path() /
|
||||
std::format("cargoxx-publish-{}.json",
|
||||
std::random_device{}());
|
||||
std::ofstream{contents_tmp} << body.dump();
|
||||
auto contents_r = exec::run(
|
||||
"tea",
|
||||
{"api", "-X", "POST", "-d", std::format("@{}", contents_tmp.string()),
|
||||
std::format("/repos/{}/contents", registry)},
|
||||
exec::ExecOptions{
|
||||
.cwd = project_root,
|
||||
.env_overrides = {},
|
||||
.timeout = std::chrono::seconds{120},
|
||||
.inherit_stdio = false,
|
||||
});
|
||||
std::error_code rm_ec;
|
||||
fs::remove(contents_tmp, rm_ec);
|
||||
if (!contents_r) {
|
||||
return std::unexpected(contents_r.error());
|
||||
}
|
||||
if (contents_r->exit_code != 0) {
|
||||
return std::unexpected(err(
|
||||
util::ErrorCode::ExecCommandFailed,
|
||||
std::format("tea api contents failed (exit {}): {}",
|
||||
contents_r->exit_code, contents_r->stderr_text)));
|
||||
}
|
||||
// Gitea returns API errors with HTTP 4xx; tea writes the error JSON
|
||||
// to stdout with exit code 0. The `"errors"` array key only appears
|
||||
// in error responses, never in success ones — use that as signal.
|
||||
if (contents_r->stdout_text.find("\"errors\"") != std::string::npos) {
|
||||
return std::unexpected(err(
|
||||
util::ErrorCode::ExecCommandFailed,
|
||||
std::format("Gitea contents API rejected the request: {}",
|
||||
contents_r->stdout_text)));
|
||||
}
|
||||
|
||||
// 8. Open the PR.
|
||||
nlohmann::json pr_body = {
|
||||
{"title", std::format("publish: {} {}", m->package.name, m->package.version)},
|
||||
{"body",
|
||||
std::format("Automated publish from `cargoxx publish` by @{}.\n\n"
|
||||
"- source: `{}@{}`\n- sha256: `{}`\n",
|
||||
*publisher, source_url, *head, *source_sha256)},
|
||||
{"head", branch},
|
||||
{"base", "master"},
|
||||
};
|
||||
auto pr_tmp = fs::temp_directory_path() /
|
||||
std::format("cargoxx-pr-{}.json", std::random_device{}());
|
||||
std::ofstream{pr_tmp} << pr_body.dump();
|
||||
auto pr_r = exec::run(
|
||||
"tea",
|
||||
{"api", "-X", "POST", "-d", std::format("@{}", pr_tmp.string()),
|
||||
std::format("/repos/{}/pulls", registry)},
|
||||
exec::ExecOptions{
|
||||
.cwd = project_root,
|
||||
.env_overrides = {},
|
||||
.timeout = std::chrono::seconds{60},
|
||||
.inherit_stdio = false,
|
||||
});
|
||||
std::error_code ec;
|
||||
fs::remove(pr_tmp, ec);
|
||||
if (!pr_r) {
|
||||
return std::unexpected(pr_r.error());
|
||||
}
|
||||
if (pr_r->exit_code != 0) {
|
||||
return std::unexpected(err(
|
||||
util::ErrorCode::ExecCommandFailed,
|
||||
std::format("tea api /pulls failed (exit {}): {}",
|
||||
pr_r->exit_code, pr_r->stderr_text)));
|
||||
}
|
||||
if (pr_r->stdout_text.find("\"errors\"") != std::string::npos) {
|
||||
return std::unexpected(err(
|
||||
util::ErrorCode::ExecCommandFailed,
|
||||
std::format("Gitea /pulls API rejected the request: {}",
|
||||
pr_r->stdout_text)));
|
||||
}
|
||||
|
||||
// Parse the response properly — substring extraction is fooled by
|
||||
// the nested `html_url` inside the `user`/`head`/`base` sub-objects.
|
||||
std::optional<std::string> pr_url;
|
||||
try {
|
||||
auto j = nlohmann::json::parse(pr_r->stdout_text);
|
||||
if (j.contains("html_url") && j["html_url"].is_string()) {
|
||||
pr_url = j["html_url"].get<std::string>();
|
||||
}
|
||||
} catch (const nlohmann::json::exception&) {
|
||||
// Leave pr_url empty; fall through to the placeholder message.
|
||||
}
|
||||
std::cout << std::format(" Opened {}\n",
|
||||
pr_url.value_or("(PR URL not parsed)"));
|
||||
return {};
|
||||
}
|
||||
|
||||
} // namespace cargoxx::cli
|
||||
@@ -43,6 +43,16 @@ auto run(int argc, char** argv) -> int {
|
||||
vendor_cmd->add_option("--output", vendor_output,
|
||||
"Path to write vendor.toml (default ./vendor.toml)");
|
||||
|
||||
auto* publish_cmd = app.add_subcommand(
|
||||
"publish", "Publish the current HEAD as a new recipe in the cargoxx registry");
|
||||
bool publish_dry_run = false;
|
||||
std::string publish_registry;
|
||||
publish_cmd->add_flag("--dry-run", publish_dry_run,
|
||||
"Print the recipe TOML; skip all network ops");
|
||||
publish_cmd->add_option("--registry", publish_registry,
|
||||
"Registry repo slug <owner>/<repo> "
|
||||
"(default $CARGOXX_REGISTRY or mozart/cargoxx-pkgs)");
|
||||
|
||||
auto* run_cmd = app.add_subcommand("run", "Build and run a binary target");
|
||||
bool run_release = false;
|
||||
std::string run_bin;
|
||||
@@ -152,6 +162,19 @@ auto run(int argc, char** argv) -> int {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (*publish_cmd) {
|
||||
std::optional<std::string> registry;
|
||||
if (!publish_registry.empty()) {
|
||||
registry = publish_registry;
|
||||
}
|
||||
auto r = cmd_publish(cwd, publish_dry_run, registry);
|
||||
if (!r) {
|
||||
std::cerr << util::format(r.error());
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (*run_cmd) {
|
||||
std::optional<std::string> bin;
|
||||
if (!run_bin.empty()) {
|
||||
|
||||
@@ -39,6 +39,9 @@ struct Package {
|
||||
Edition edition = Edition::Cpp23;
|
||||
std::vector<std::string> authors;
|
||||
std::optional<std::string> license;
|
||||
std::optional<std::string> description;
|
||||
std::optional<std::string> repository;
|
||||
std::optional<std::string> homepage;
|
||||
|
||||
bool operator==(const Package&) const = default;
|
||||
};
|
||||
|
||||
@@ -138,6 +138,15 @@ auto parse_package(const toml::table& tbl, const std::filesystem::path& path)
|
||||
if (auto license = tbl["license"].value<std::string>()) {
|
||||
pkg.license = *license;
|
||||
}
|
||||
if (auto v = tbl["description"].value<std::string>()) {
|
||||
pkg.description = *v;
|
||||
}
|
||||
if (auto v = tbl["repository"].value<std::string>()) {
|
||||
pkg.repository = *v;
|
||||
}
|
||||
if (auto v = tbl["homepage"].value<std::string>()) {
|
||||
pkg.homepage = *v;
|
||||
}
|
||||
|
||||
return pkg;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,15 @@ auto build_table(const Manifest& m) -> toml::table {
|
||||
if (m.package.license) {
|
||||
package.insert_or_assign("license", *m.package.license);
|
||||
}
|
||||
if (m.package.description) {
|
||||
package.insert_or_assign("description", *m.package.description);
|
||||
}
|
||||
if (m.package.repository) {
|
||||
package.insert_or_assign("repository", *m.package.repository);
|
||||
}
|
||||
if (m.package.homepage) {
|
||||
package.insert_or_assign("homepage", *m.package.homepage);
|
||||
}
|
||||
root.insert_or_assign("package", std::move(package));
|
||||
|
||||
auto deps_to_table = [](const std::vector<Dependency>& deps) {
|
||||
|
||||
Reference in New Issue
Block a user