diff --git a/src/commands/publish.rs b/src/commands/publish.rs index fe022a2..fd52efe 100644 --- a/src/commands/publish.rs +++ b/src/commands/publish.rs @@ -131,6 +131,9 @@ pub async fn run(_args: Args, config: &RootConfig) -> miette::Result<()> { name: name.clone(), scope: scope.clone(), published_date, + // OCI-standard creation time so registries (zot) can order tags and + // resolve the newest version; mirrors the image.created annotation. + created: oci::created_timestamp(published_date), repository_url: Some(repository_url.clone()), description: config.protocol.description.clone(), version: Some(version.clone()), diff --git a/src/interfaces/oci.rs b/src/interfaces/oci.rs index a121e1b..1cc0f09 100644 --- a/src/interfaces/oci.rs +++ b/src/interfaces/oci.rs @@ -29,6 +29,12 @@ pub struct ImageMetadata { pub name: String, pub scope: String, pub published_date: i64, + /// OCI-standard creation time (RFC3339), derived from `published_date` via + /// [`created_timestamp`]. Registries such as zot order a repo's tags by the + /// config blob's `created` field to pick the "newest" image; without it they + /// fall back to zero time and the first-pushed tag stays newest forever. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created: Option, /// GitHub `https://github.com//` URL. Mirrors the /// `org.opencontainers.image.source` annotation. pub repository_url: Option, @@ -51,6 +57,14 @@ pub struct ImageMetadata { pub commit_sha: Option, } +/// OCI-standard `created` timestamp (RFC3339) for an image config, derived from +/// a Unix-seconds `published_date`. This is the field registries read to order a +/// repo's tags; see [`ImageMetadata::created`]. Returns `None` only if the +/// timestamp is out of representable range. +pub fn created_timestamp(published_date: i64) -> Option { + chrono::DateTime::from_timestamp(published_date, 0).map(|dt| dt.to_rfc3339()) +} + pub fn client_for(registry_url: &str) -> oci_client::Client { let registry_protocol = registry_url.split("://").next().unwrap_or("https"); let client_config = oci_client::client::ClientConfig { @@ -185,6 +199,50 @@ pub async fn pull( mod tests { use super::*; + #[test] + fn config_blob_carries_oci_created_for_version_ordering() { + // Regression guard. Registries like zot pick a repo's "newest" tag from + // the config blob's OCI `created` field (falling back to `history`, else + // zero time). With no `created`, every version reads as zero-time and the + // FIRST-pushed tag stays "newest" forever — so a GraphQL query for the + // latest version of a protocol never advances past the first publish. + // The fix: derive an OCI-standard `created` from `published_date` and + // serialize it into the image config. + let published_date = 1_782_145_366; // 2026-06-22T16:22:46+00:00 + + let created = created_timestamp(published_date); + assert_eq!(created.as_deref(), Some("2026-06-22T16:22:46+00:00")); + + let meta = ImageMetadata { + name: "prueba_zot".into(), + scope: "local".into(), + published_date, + created: created.clone(), + repository_url: None, + description: None, + version: Some("0.2.0".into()), + repository: None, + commit_sha: None, + }; + let json = serde_json::to_value(&meta).unwrap(); + assert_eq!(json["created"], "2026-06-22T16:22:46+00:00"); + } + + #[test] + fn config_without_created_still_deserializes() { + // Artifacts published before this fix have no `created` field; pulling + // them must keep working with `created` defaulting to None. + let legacy = r#"{ + "name": "widget", + "scope": "acme", + "published_date": 0, + "version": "0.1.0" + }"#; + let meta: ImageMetadata = serde_json::from_str(legacy).unwrap(); + assert_eq!(meta.created, None); + assert_eq!(meta.version.as_deref(), Some("0.1.0")); + } + #[test] fn reference_lowercases_scope_and_name_for_oci_compliance() { // `scope` mirrors the GitHub owner, which may carry capitals (e.g.