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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,20 @@ Link a GitHub repository to automatically publish packages from semver tags.

Git tags are mapped to package versions: `v1.0.0` → `1.0.0`. Tags that don't match semver (e.g., `nightly`, `latest`) are skipped.

### Sema Version Compatibility

Packages may declare a SemVer requirement in `sema.toml`:

```toml
[package]
sema_version_req = ">=1.34.0"
```

The registry validates and normalizes this field for both CLI uploads and
GitHub-linked releases. Clients use it to skip incompatible versions during
unversioned installs and to reject incompatible explicit or locked installs
before replacing local package files.

### Source Locking

A package is either **CLI-uploaded** or **GitHub-linked**, never both. Once a package is linked to a repo, it cannot be published via `sema publish`, and vice versa.
Expand Down
3 changes: 3 additions & 0 deletions src/api/packages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,9 @@ pub async fn publish(
)));
}
}
metadata.sema_version_req =
crate::github_sync::validate_sema_version_req(metadata.sema_version_req.as_deref())
.map_err(ApiError::bad_request)?;

// Ownership / source checks (reads; the writes below run in one transaction)
let existing = dal::packages::find_by_name(&state.db, &name)
Expand Down
78 changes: 73 additions & 5 deletions src/github_sync.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
use crate::{crypto, db::Db};

pub fn validate_sema_version_req(requirement: Option<&str>) -> Result<Option<String>, String> {
let Some(requirement) = requirement.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
if requirement.len() > 128 {
return Err("sema_version_req must be at most 128 characters".to_string());
}
semver::VersionReq::parse(requirement)
.map_err(|error| format!("Invalid sema_version_req {requirement:?}: {error}"))?;
Ok(Some(requirement.to_string()))
}

/// Fetch the decrypted GitHub access token for a user.
pub async fn get_github_token(db: &Db, user_id: i64, token_key: &str) -> Option<String> {
let row = crate::dal::oauth::find_active(db, user_id).await.ok()??;
Expand Down Expand Up @@ -89,10 +101,13 @@ fn parse_manifest(content: &str) -> Result<RepoManifest, String> {
.get("repository")
.and_then(toml::Value::as_str)
.map(str::to_string);
let sema_version_req = pkg
.get("sema_version_req")
.and_then(toml::Value::as_str)
.map(str::to_string);
let sema_version_req = match pkg.get("sema_version_req") {
None => None,
Some(value) => {
let requirement = value.as_str().ok_or("sema_version_req must be a string")?;
validate_sema_version_req(Some(requirement))?
}
};
Ok(RepoManifest {
name: name.to_string(),
description,
Expand Down Expand Up @@ -166,6 +181,7 @@ pub async fn sync_tag(
sema_version_req: Option<&str>,
) -> Result<bool, String> {
let version_str = version.to_string();
let sema_version_req = validate_sema_version_req(sema_version_req)?;

// Check if version already exists
let exists = crate::dal::versions::exists(db, package_id, &version_str)
Expand All @@ -183,7 +199,7 @@ pub async fn sync_tag(
package_id,
&version_str,
tarball_url,
sema_version_req.map(String::from),
sema_version_req,
)
.await
.map_err(|e| format!("Failed to insert version: {e}"))?;
Expand Down Expand Up @@ -448,6 +464,58 @@ mod readme_tests {
}
}

#[cfg(test)]
mod manifest_tests {
use super::*;

#[test]
fn manifest_rejects_invalid_sema_version_requirement() {
let error = parse_manifest(
r#"
[package]
name = "policies"
sema_version_req = "not a requirement"
"#,
)
.unwrap_err();
assert!(error.contains("Invalid sema_version_req"));
}

#[test]
fn manifest_normalizes_valid_sema_version_requirement() {
let manifest = parse_manifest(
r#"
[package]
name = "policies"
sema_version_req = " >=1.34.0 "
"#,
)
.unwrap();
assert_eq!(manifest.sema_version_req.as_deref(), Some(">=1.34.0"));
}

#[test]
fn manifest_rejects_non_string_sema_version_requirement() {
let error = parse_manifest(
r#"
[package]
name = "policies"
sema_version_req = 34
"#,
)
.unwrap_err();
assert_eq!(error, "sema_version_req must be a string");
}

#[test]
fn validator_treats_blank_as_absent_and_limits_length() {
assert_eq!(validate_sema_version_req(Some(" ")).unwrap(), None);
let requirement = format!(">={}", "1".repeat(128));
let error = validate_sema_version_req(Some(&requirement)).unwrap_err();
assert!(error.contains("at most 128 characters"));
}
}

#[cfg(test)]
mod theme_probe {
use super::*;
Expand Down