Skip to main content

greentic_dev/
release_cmd.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::str::FromStr;
5
6use anyhow::{Context, Result, anyhow, bail};
7use async_trait::async_trait;
8use oci_distribution::Reference;
9use oci_distribution::client::{Client, ClientConfig, ClientProtocol, Config, ImageLayer};
10use oci_distribution::secrets::RegistryAuth;
11use semver::Version;
12use serde::{Deserialize, Serialize};
13use time::OffsetDateTime;
14use time::format_description::well_known::Rfc3339;
15
16use crate::cli::{
17    ReleaseGenerateArgs, ReleaseLatestArgs, ReleasePromoteArgs, ReleasePublishArgs,
18    ReleaseSnapshotArgs, ReleaseViewArgs,
19};
20use crate::install::block_on_maybe_runtime;
21use crate::passthrough::{ToolchainChannel, delegated_binary_name_for_channel};
22use crate::toolchain_catalogue::{
23    GREENTIC_COMPONENT_PACKAGES, GREENTIC_EXTENSION_PACK_PACKAGES, GREENTIC_TOOLCHAIN_PACKAGES,
24    OciPackageSpec,
25};
26
27const DEFAULT_OAUTH_USER: &str = "oauth2";
28pub const TOOLCHAIN_MANIFEST_SCHEMA: &str = "greentic.toolchain-manifest.v1";
29pub const TOOLCHAIN_NAME: &str = "gtc";
30pub const TOOLCHAIN_LAYER_MEDIA_TYPE: &str = "application/vnd.greentic.toolchain.manifest.v1+json";
31const TOOLCHAIN_CONFIG_MEDIA_TYPE: &str = "application/vnd.greentic.toolchain.config.v1+json";
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
34pub struct ToolchainManifest {
35    pub schema: String,
36    pub toolchain: String,
37    pub version: String,
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub channel: Option<String>,
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub created_at: Option<String>,
42    pub packages: Vec<ToolchainPackage>,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub extension_packs: Option<Vec<ExtensionPackRef>>,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub components: Option<Vec<ComponentRef>>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
50pub struct ToolchainPackage {
51    #[serde(rename = "crate")]
52    pub crate_name: String,
53    pub bins: Vec<String>,
54    pub version: String,
55}
56
57#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
58pub struct ExtensionPackRef {
59    pub id: String,
60    pub version: String,
61}
62
63#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
64pub struct ComponentRef {
65    pub id: String,
66    pub version: String,
67}
68
69pub fn generate(args: ReleaseGenerateArgs) -> Result<()> {
70    let resolver = default_resolver();
71    let artifact_resolver = GhcrArtifactVersionResolver::new(args.token.as_deref())?;
72    let source = block_on_maybe_runtime(load_source_manifest(
73        &args.repo,
74        &args.from,
75        args.token.as_deref(),
76    ))
77    .with_context(|| {
78        format!(
79            "failed to resolve source manifest `{}`",
80            toolchain_ref(&args.repo, &args.from)
81        )
82    })?;
83    let source = match source {
84        Some(source) => Some(source),
85        None => bootstrap_source_manifest_if_needed(
86            &args.repo,
87            &args.from,
88            args.token.as_deref(),
89            args.dry_run,
90            &resolver,
91        )?,
92    };
93    let manifest = generate_manifest_with_artifact_resolver(
94        &args.release,
95        &args.from,
96        source.as_ref(),
97        &resolver,
98        &artifact_resolver,
99        Some(created_at_now()?),
100    )?;
101    if args.dry_run {
102        println!("{}", serde_json::to_string_pretty(&manifest)?);
103        return Ok(());
104    }
105    let path = write_manifest(&args.out, &manifest)?;
106    println!("Wrote {}", path.display());
107    Ok(())
108}
109
110fn bootstrap_source_manifest_if_needed<R: CrateVersionResolver>(
111    repo: &str,
112    tag: &str,
113    token: Option<&str>,
114    dry_run: bool,
115    resolver: &R,
116) -> Result<Option<ToolchainManifest>> {
117    let manifest = bootstrap_source_manifest(tag, resolver, Some(created_at_now()?))?;
118    if dry_run {
119        eprintln!(
120            "Dry run: would bootstrap missing source manifest {}",
121            toolchain_ref(repo, tag)
122        );
123        return Ok(Some(manifest));
124    }
125
126    let auth = match optional_registry_auth(token)? {
127        RegistryAuth::Anonymous => {
128            eprintln!(
129                "Source manifest {} is missing; no GHCR token is available, so only the local release manifest will be generated.",
130                toolchain_ref(repo, tag)
131            );
132            return Ok(Some(manifest));
133        }
134        auth => auth,
135    };
136    block_on_maybe_runtime(async {
137        let client = oci_client();
138        let source_ref = parse_reference(repo, tag)?;
139        push_manifest_layer(&client, &source_ref, &auth, &manifest).await
140    })
141    .with_context(|| format!("failed to bootstrap {}", toolchain_ref(repo, tag)))?;
142    println!("Bootstrapped {}", toolchain_ref(repo, tag));
143    Ok(Some(manifest))
144}
145
146fn bootstrap_source_manifest<R: CrateVersionResolver>(
147    tag: &str,
148    resolver: &R,
149    created_at: Option<String>,
150) -> Result<ToolchainManifest> {
151    generate_manifest(tag, tag, None, resolver, created_at)
152}
153
154pub fn publish(args: ReleasePublishArgs) -> Result<()> {
155    let checker = default_release_checker(args.token.as_deref());
156    publish_with_checker(args, &checker)
157}
158
159fn publish_with_checker(args: ReleasePublishArgs, checker: &dyn ReleaseAssetChecker) -> Result<()> {
160    let (release, manifest, source) = publish_manifest_input(&args)?;
161
162    // Gate before the dry-run return, so `publish --manifest <file> --dry-run`
163    // doubles as the CI check on a pin bump: it answers "is this manifest
164    // publishable?" without pushing anything.
165    verify_manifest_releases(&manifest, args.tag.as_deref(), checker)?;
166
167    if args.dry_run {
168        println!(
169            "Dry run: would publish {}",
170            toolchain_ref(&args.repo, &release)
171        );
172        if let Some(tag) = &args.tag {
173            println!(
174                "Dry run: would tag {} as {}",
175                toolchain_ref(&args.repo, &release),
176                toolchain_ref(&args.repo, tag)
177            );
178        }
179        return Ok(());
180    }
181
182    let auth = registry_auth(args.token.as_deref())?;
183    block_on_maybe_runtime(async {
184        let client = oci_client();
185        let release_ref = parse_reference(&args.repo, &release)?;
186        if !args.force && manifest_exists(&client, &release_ref, &auth).await? {
187            bail!(
188                "release tag `{}` already exists; pass --force to overwrite it",
189                toolchain_ref(&args.repo, &release)
190            );
191        }
192        push_manifest_layer(&client, &release_ref, &auth, &manifest).await?;
193        if let Some(tag) = &args.tag {
194            let tag_ref = parse_reference(&args.repo, tag)?;
195            push_manifest_layer(&client, &tag_ref, &auth, &manifest).await?;
196        }
197        Ok(())
198    })?;
199
200    if let Some(source) = source {
201        match source {
202            PublishManifestSource::Generated(path) => println!("Wrote {}", path.display()),
203            PublishManifestSource::Local(path) => println!("Read {}", path.display()),
204        }
205    }
206    println!("Published {}", toolchain_ref(&args.repo, &release));
207    if let Some(tag) = &args.tag {
208        println!("Updated {}", toolchain_ref(&args.repo, tag));
209    }
210
211    if !args.no_notify_updater {
212        let channel = manifest.channel.as_deref().unwrap_or("");
213        notify_updater_dispatch(&release, channel, args.token.as_deref());
214    }
215
216    Ok(())
217}
218
219#[derive(Debug, Clone, PartialEq, Eq)]
220enum PublishManifestSource {
221    Generated(PathBuf),
222    Local(PathBuf),
223}
224
225fn publish_manifest_input(
226    args: &ReleasePublishArgs,
227) -> Result<(String, ToolchainManifest, Option<PublishManifestSource>)> {
228    if let Some(path) = &args.manifest {
229        let mut manifest = read_manifest_file(path)?;
230        validate_manifest(&manifest)?;
231        let release = if let Some(release) = &args.release {
232            manifest.version = release.clone();
233            release.clone()
234        } else {
235            manifest.version.clone()
236        };
237        return Ok((
238            release,
239            manifest,
240            Some(PublishManifestSource::Local(path.clone())),
241        ));
242    }
243
244    let release = args
245        .release
246        .as_deref()
247        .context("pass --release or --manifest")?;
248    let from = args.from.as_deref().unwrap_or("latest");
249    let resolver = default_resolver();
250    let source = block_on_maybe_runtime(load_source_manifest(
251        &args.repo,
252        from,
253        args.token.as_deref(),
254    ))
255    .with_context(|| {
256        format!(
257            "failed to resolve source manifest `{}`",
258            toolchain_ref(&args.repo, from)
259        )
260    })?;
261    if let Some(source_manifest) = source.as_ref()
262        && source_manifest_has_concrete_pins(source_manifest)
263    {
264        eprintln!(
265            "warning: `release publish --from {from}` reuses the pinned versions in `{}` instead \
266             of querying crates.io. To refresh a channel from the latest crates.io versions, use \
267             `release snapshot --channel <dev|research|stable>`. To copy an existing release tag without \
268             re-resolving, use `release promote`. The conflated `--from` semantics will be \
269             removed in a future release.",
270            toolchain_ref(&args.repo, from),
271        );
272    }
273    let manifest = generate_manifest(
274        release,
275        from,
276        source.as_ref(),
277        &resolver,
278        Some(created_at_now()?),
279    )?;
280    let path = if args.dry_run {
281        println!("{}", serde_json::to_string_pretty(&manifest)?);
282        None
283    } else {
284        Some(PublishManifestSource::Generated(write_manifest(
285            &args.out, &manifest,
286        )?))
287    };
288    Ok((release.to_string(), manifest, path))
289}
290
291/// True when the source manifest has at least one package pinned to a concrete
292/// (non-`"latest"`) version. Used to detect the case where `release publish
293/// --from <X>` would silently copy old pins instead of re-resolving — see the
294/// deprecation warning emitted from `publish_manifest_input`.
295fn source_manifest_has_concrete_pins(manifest: &ToolchainManifest) -> bool {
296    manifest
297        .packages
298        .iter()
299        .any(|package| package.version != "latest")
300}
301
302fn read_manifest_file(path: &Path) -> Result<ToolchainManifest> {
303    let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
304    serde_json::from_slice(&bytes).with_context(|| format!("failed to parse {}", path.display()))
305}
306
307pub fn promote(args: ReleasePromoteArgs) -> Result<()> {
308    if args.dry_run {
309        println!(
310            "Dry run: would promote {} to {}",
311            toolchain_ref(&args.repo, &args.release),
312            toolchain_ref(&args.repo, &args.tag)
313        );
314        return Ok(());
315    }
316
317    // Promote copies the OCI manifest (a tag alias) and never reads the
318    // toolchain layer, so load it up front: the stable-lane gate needs the pins
319    // to refuse moving `:stable` onto binaries that are not downloadable yet,
320    // and the updater dispatch below needs the channel.
321    let source = block_on_maybe_runtime(load_source_manifest(
322        &args.repo,
323        &args.release,
324        args.token.as_deref(),
325    ))
326    .ok()
327    .flatten();
328    match source.as_ref() {
329        Some(manifest) => {
330            let checker = default_release_checker(args.token.as_deref());
331            verify_manifest_releases(manifest, Some(args.tag.as_str()), &checker)?;
332        }
333        // Fail closed: moving the tag `gtc install` resolves without being able
334        // to read its pins would reintroduce the exact hazard the gate exists
335        // for. Other tags stay non-fatal, as before.
336        None if args.tag == "stable" => bail!(
337            "cannot read the toolchain pins of `{}` — refusing to move `:stable` unverified",
338            toolchain_ref(&args.repo, &args.release)
339        ),
340        None => {}
341    }
342
343    let auth = registry_auth(args.token.as_deref())?;
344    block_on_maybe_runtime(async {
345        let client = oci_client();
346        let source_ref = parse_reference(&args.repo, &args.release)?;
347        let target_ref = parse_reference(&args.repo, &args.tag)?;
348        let (manifest, _) = client
349            .pull_manifest(&source_ref, &auth)
350            .await
351            .with_context(|| {
352                format!(
353                    "failed to resolve source release `{}`",
354                    toolchain_ref(&args.repo, &args.release)
355                )
356            })?;
357        client
358            .push_manifest(&target_ref, &manifest)
359            .await
360            .with_context(|| {
361                format!(
362                    "failed to update tag `{}`",
363                    toolchain_ref(&args.repo, &args.tag)
364                )
365            })?;
366        Ok(())
367    })?;
368    println!(
369        "Promoted {} to {}",
370        toolchain_ref(&args.repo, &args.release),
371        toolchain_ref(&args.repo, &args.tag)
372    );
373
374    if !args.no_notify_updater {
375        // Reuses the manifest loaded before the push. If loading failed, skip
376        // the dispatch — the workflow has its own channel guard as a backstop.
377        let channel = source.and_then(|m| m.channel);
378        match channel.as_deref() {
379            Some(ch) => notify_updater_dispatch(&args.release, ch, args.token.as_deref()),
380            None => {
381                eprintln!(
382                    "Skipping updater dispatch: could not determine channel \
383                     for {} (use workflow_dispatch as fallback)",
384                    toolchain_ref(&args.repo, &args.release)
385                );
386            }
387        }
388    }
389
390    Ok(())
391}
392
393/// Snapshot the current crates.io state into a new toolchain manifest.
394///
395/// Unlike `publish --from <X>`, snapshot **never** reads an existing manifest
396/// and **always** queries the resolver. That makes it safe to call repeatedly
397/// to refresh a channel — `:dev` after each nightly publish, `:stable` after
398/// a weekly release — without the promote-vs-snapshot conflation that bit
399/// callers of `publish --from dev`.
400pub fn snapshot(args: ReleaseSnapshotArgs) -> Result<()> {
401    let checker = default_release_checker(args.token.as_deref());
402    snapshot_with_checker(args, &checker)
403}
404
405fn snapshot_with_checker(
406    args: ReleaseSnapshotArgs,
407    checker: &dyn ReleaseAssetChecker,
408) -> Result<()> {
409    let channel = parse_channel(&args.channel)?;
410    let resolver = CratesIoApiVersionResolver::default();
411    let manifest = snapshot_manifest(&args.release, channel, &resolver, Some(created_at_now()?))?;
412
413    // Snapshot resolves pins from crates.io, which does NOT imply a finished
414    // release build. Most repos gate `publish_crates` on `needs: [release]`, but
415    // greentic-pack's `crates-publish.yml` fires independently on `push: tags:
416    // ["v*"]` — so its crates.io version and its GitHub release race, and a
417    // stable snapshot could pin the winner of that race. Same gate as publish.
418    verify_manifest_releases(&manifest, args.tag.as_deref(), checker)?;
419
420    if args.dry_run {
421        println!("{}", serde_json::to_string_pretty(&manifest)?);
422        println!(
423            "Dry run: would publish {}",
424            toolchain_ref(&args.repo, &args.release)
425        );
426        if let Some(tag) = &args.tag {
427            println!(
428                "Dry run: would tag {} as {}",
429                toolchain_ref(&args.repo, &args.release),
430                toolchain_ref(&args.repo, tag)
431            );
432        }
433        return Ok(());
434    }
435
436    let path = write_manifest(&args.out, &manifest)?;
437    println!("Wrote {}", path.display());
438
439    let auth = registry_auth(args.token.as_deref())?;
440    block_on_maybe_runtime(async {
441        let client = oci_client();
442        let release_ref = parse_reference(&args.repo, &args.release)?;
443        if !args.force && manifest_exists(&client, &release_ref, &auth).await? {
444            bail!(
445                "release tag `{}` already exists; pass --force to overwrite it",
446                toolchain_ref(&args.repo, &args.release)
447            );
448        }
449        push_manifest_layer(&client, &release_ref, &auth, &manifest).await?;
450        if let Some(tag) = &args.tag {
451            let tag_ref = parse_reference(&args.repo, tag)?;
452            push_manifest_layer(&client, &tag_ref, &auth, &manifest).await?;
453        }
454        Ok(())
455    })?;
456    println!("Published {}", toolchain_ref(&args.repo, &args.release));
457    if let Some(tag) = &args.tag {
458        println!("Updated {}", toolchain_ref(&args.repo, tag));
459    }
460
461    if !args.no_notify_updater {
462        let ch = channel_tag(channel);
463        notify_updater_dispatch(&args.release, ch, args.token.as_deref());
464    }
465
466    Ok(())
467}
468
469fn parse_channel(channel: &str) -> Result<ToolchainChannel> {
470    match channel {
471        "dev" | "development" => Ok(ToolchainChannel::Development),
472        "rnd" | "research" => Ok(ToolchainChannel::Rnd),
473        "stable" => Ok(ToolchainChannel::Stable),
474        other => bail!(
475            "unknown channel `{other}` (expected `dev`, `research` (alias `rnd`), or `stable`); \
476             pass --channel dev for the dev lane, --channel research for the research lane, or \
477             --channel stable for the stable lane"
478        ),
479    }
480}
481
482fn channel_tag(channel: ToolchainChannel) -> &'static str {
483    match channel {
484        ToolchainChannel::Stable => "stable",
485        ToolchainChannel::Development => "dev",
486        ToolchainChannel::Rnd => "rnd",
487    }
488}
489
490/// Resolve the version for a single toolchain manifest entry on `channel`.
491/// Returns `Ok(None)` when the research channel has no `-rnd` build for the
492/// crate (skip it) so manifest assembly does not abort on the ~10 of 13
493/// toolchain crates that ship no research build.
494fn resolve_manifest_version<R: CrateVersionResolver>(
495    resolver: &R,
496    crate_in_manifest: &str,
497    channel: ToolchainChannel,
498    lane: Option<(u64, u64)>,
499) -> Result<Option<String>> {
500    if channel == ToolchainChannel::Rnd {
501        match resolver
502            .resolve_research_version(crate_in_manifest)
503            .with_context(|| {
504                format!("failed to resolve research version for `{crate_in_manifest}`")
505            })? {
506            ResearchVersion::Pinned(version) => Ok(Some(version)),
507            ResearchVersion::Absent => {
508                eprintln!(
509                    "note: `{crate_in_manifest}` has no research build on crates.io; \
510                     omitting it from the research toolchain manifest"
511                );
512                Ok(None)
513            }
514        }
515    } else if let (ToolchainChannel::Development, Some(lane)) = (channel, lane) {
516        // Stay inside the release's own minor line. Without this the dev
517        // manifest pins whatever sorts highest across ALL lanes, which is how
518        // an abandoned 1.3 research build kept winning over active 1.2 dev
519        // builds and froze the dev channel.
520        resolver
521            .resolve_latest_in_lane(crate_in_manifest, lane)
522            .with_context(|| {
523                format!(
524                    "failed to resolve a {}.{} version for `{crate_in_manifest}`",
525                    lane.0, lane.1
526                )
527            })
528            .map(Some)
529    } else {
530        resolver
531            .resolve_latest_for_channel(crate_in_manifest, channel)
532            .with_context(|| format!("failed to resolve latest version for `{crate_in_manifest}`"))
533            .map(Some)
534    }
535}
536
537pub fn snapshot_manifest<R: CrateVersionResolver>(
538    release: &str,
539    channel: ToolchainChannel,
540    resolver: &R,
541    created_at: Option<String>,
542) -> Result<ToolchainManifest> {
543    let from = channel_tag(channel);
544    let mut packages = Vec::new();
545    for package in GREENTIC_TOOLCHAIN_PACKAGES {
546        let crate_in_manifest = manifest_crate_name_for_source(from, package.crate_name);
547        let Some(version) =
548            resolve_manifest_version(resolver, &crate_in_manifest, channel, lane_of(release))?
549        else {
550            continue;
551        };
552        packages.push(ToolchainPackage {
553            crate_name: crate_in_manifest,
554            bins: manifest_bins_for_source(from, package.bins),
555            version,
556        });
557    }
558    Ok(ToolchainManifest {
559        schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
560        toolchain: TOOLCHAIN_NAME.to_string(),
561        version: release.to_string(),
562        channel: Some(from.to_string()),
563        created_at,
564        packages,
565        extension_packs: None,
566        components: None,
567    })
568}
569
570pub fn view(args: ReleaseViewArgs) -> Result<()> {
571    let tag = release_view_tag(&args)?;
572    let manifest = block_on_maybe_runtime(load_source_manifest(
573        &args.repo,
574        &tag,
575        args.token.as_deref(),
576    ))
577    .with_context(|| {
578        format!(
579            "failed to resolve manifest `{}`",
580            toolchain_ref(&args.repo, &tag)
581        )
582    })?
583    .with_context(|| {
584        format!(
585            "manifest `{}` was not found or is not authorized for this token",
586            toolchain_ref(&args.repo, &tag)
587        )
588    })?;
589    println!("{}", serde_json::to_string_pretty(&manifest)?);
590    Ok(())
591}
592
593pub fn latest(args: ReleaseLatestArgs) -> Result<()> {
594    let manifest = latest_manifest(Some(created_at_now()?));
595    if args.dry_run {
596        println!("{}", serde_json::to_string_pretty(&manifest)?);
597        println!(
598            "Dry run: would publish {}",
599            toolchain_ref(&args.repo, "latest")
600        );
601        return Ok(());
602    }
603
604    let auth = registry_auth(args.token.as_deref())?;
605    block_on_maybe_runtime(async {
606        let client = oci_client();
607        let latest_ref = parse_reference(&args.repo, "latest")?;
608        if !args.force && manifest_exists(&client, &latest_ref, &auth).await? {
609            bail!(
610                "latest tag `{}` already exists; pass --force to overwrite it",
611                toolchain_ref(&args.repo, "latest")
612            );
613        }
614        push_manifest_layer(&client, &latest_ref, &auth, &manifest).await
615    })?;
616    println!("Published {}", toolchain_ref(&args.repo, "latest"));
617    Ok(())
618}
619
620fn latest_manifest(created_at: Option<String>) -> ToolchainManifest {
621    ToolchainManifest {
622        schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
623        toolchain: TOOLCHAIN_NAME.to_string(),
624        version: "latest".to_string(),
625        channel: Some("latest".to_string()),
626        created_at,
627        packages: latest_manifest_packages(),
628        extension_packs: Some(
629            GREENTIC_EXTENSION_PACK_PACKAGES
630                .iter()
631                .map(|package| ExtensionPackRef {
632                    id: package.package.to_string(),
633                    version: "latest".to_string(),
634                })
635                .collect(),
636        ),
637        components: Some(
638            GREENTIC_COMPONENT_PACKAGES
639                .iter()
640                .map(|package| ComponentRef {
641                    id: package.package.to_string(),
642                    version: "latest".to_string(),
643                })
644                .collect(),
645        ),
646    }
647}
648
649fn latest_manifest_packages() -> Vec<ToolchainPackage> {
650    std::iter::once(ToolchainPackage {
651        crate_name: delegated_binary_name_for_channel(
652            TOOLCHAIN_NAME,
653            ToolchainChannel::Development,
654        ),
655        bins: vec![delegated_binary_name_for_channel(
656            TOOLCHAIN_NAME,
657            ToolchainChannel::Development,
658        )],
659        version: "latest".to_string(),
660    })
661    .chain(GREENTIC_TOOLCHAIN_PACKAGES.iter().map(|package| {
662        ToolchainPackage {
663            crate_name: delegated_binary_name_for_channel(
664                package.crate_name,
665                ToolchainChannel::Development,
666            ),
667            bins: package
668                .bins
669                .iter()
670                .map(|bin| delegated_binary_name_for_channel(bin, ToolchainChannel::Development))
671                .collect(),
672            version: "latest".to_string(),
673        }
674    }))
675    .collect()
676}
677
678fn release_view_tag(args: &ReleaseViewArgs) -> Result<String> {
679    match (&args.release, &args.tag) {
680        (Some(release), None) => Ok(release.clone()),
681        (None, Some(tag)) => Ok(tag.clone()),
682        _ => bail!("pass exactly one of --release or --tag"),
683    }
684}
685
686pub fn generate_manifest<R: CrateVersionResolver>(
687    release: &str,
688    from: &str,
689    source: Option<&ToolchainManifest>,
690    resolver: &R,
691    created_at: Option<String>,
692) -> Result<ToolchainManifest> {
693    let artifact_resolver = ReleaseArtifactVersionResolver { release };
694    generate_manifest_with_artifact_resolver(
695        release,
696        from,
697        source,
698        resolver,
699        &artifact_resolver,
700        created_at,
701    )
702}
703
704pub fn generate_manifest_with_artifact_resolver<R, A>(
705    release: &str,
706    from: &str,
707    source: Option<&ToolchainManifest>,
708    resolver: &R,
709    artifact_resolver: &A,
710    created_at: Option<String>,
711) -> Result<ToolchainManifest>
712where
713    R: CrateVersionResolver,
714    A: ArtifactVersionResolver,
715{
716    if let Some(source) = source {
717        validate_manifest(source)?;
718    }
719    let source_versions = source_version_map(source);
720    let mut packages = Vec::new();
721    for package in GREENTIC_TOOLCHAIN_PACKAGES {
722        let crate_in_manifest = manifest_crate_name_for_source(from, package.crate_name);
723        let source_version = source_versions.get(&crate_in_manifest);
724        let version = match source_version.map(String::as_str) {
725            Some(version) if version != "latest" => Some(version.to_string()),
726            _ => resolve_manifest_version(
727                resolver,
728                &crate_in_manifest,
729                channel_from_source_tag(from),
730                lane_of(release),
731            )?,
732        };
733        let Some(version) = version else {
734            continue;
735        };
736        packages.push(ToolchainPackage {
737            crate_name: crate_in_manifest,
738            bins: manifest_bins_for_source(from, package.bins),
739            version,
740        });
741    }
742    Ok(ToolchainManifest {
743        schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
744        toolchain: TOOLCHAIN_NAME.to_string(),
745        version: release.to_string(),
746        channel: Some(from.to_string()),
747        created_at,
748        packages,
749        extension_packs: Some(extension_pack_refs_for_release(source, artifact_resolver)?),
750        components: Some(component_refs_for_release(source, artifact_resolver)?),
751    })
752}
753
754/// Map a manifest source-tag (`dev`/`rnd`/`stable`) to its channel.
755fn channel_from_source_tag(from: &str) -> ToolchainChannel {
756    match from {
757        "dev" => ToolchainChannel::Development,
758        "rnd" => ToolchainChannel::Rnd,
759        _ => ToolchainChannel::Stable,
760    }
761}
762
763fn manifest_bins_for_source(from: &str, bins: &[&str]) -> Vec<String> {
764    let channel = channel_from_source_tag(from);
765    bins.iter()
766        .map(|bin| delegated_binary_name_for_channel(bin, channel))
767        .collect()
768}
769
770fn extension_pack_refs_for_release<A: ArtifactVersionResolver>(
771    source: Option<&ToolchainManifest>,
772    artifact_resolver: &A,
773) -> Result<Vec<ExtensionPackRef>> {
774    let source_versions = source_ref_version_map(source.and_then(|manifest| {
775        manifest
776            .extension_packs
777            .as_ref()
778            .map(|refs| refs.iter().map(|item| (&item.id, &item.version)))
779    }));
780    GREENTIC_EXTENSION_PACK_PACKAGES
781        .iter()
782        .map(|package| {
783            Ok(ExtensionPackRef {
784                id: package.package.to_string(),
785                version: ref_version_for_package(package, &source_versions, artifact_resolver)?,
786            })
787        })
788        .collect()
789}
790
791fn component_refs_for_release<A: ArtifactVersionResolver>(
792    source: Option<&ToolchainManifest>,
793    artifact_resolver: &A,
794) -> Result<Vec<ComponentRef>> {
795    let source_versions = source_ref_version_map(source.and_then(|manifest| {
796        manifest
797            .components
798            .as_ref()
799            .map(|refs| refs.iter().map(|item| (&item.id, &item.version)))
800    }));
801    GREENTIC_COMPONENT_PACKAGES
802        .iter()
803        .map(|package| {
804            Ok(ComponentRef {
805                id: package.package.to_string(),
806                version: ref_version_for_package(package, &source_versions, artifact_resolver)?,
807            })
808        })
809        .collect()
810}
811
812fn source_ref_version_map<'a, I>(refs: Option<I>) -> BTreeMap<String, String>
813where
814    I: Iterator<Item = (&'a String, &'a String)>,
815{
816    let mut out = BTreeMap::new();
817    if let Some(refs) = refs {
818        for (id, version) in refs {
819            out.insert(id.clone(), version.clone());
820        }
821    }
822    out
823}
824
825fn ref_version_for_package(
826    package: &OciPackageSpec,
827    source_versions: &BTreeMap<String, String>,
828    artifact_resolver: &impl ArtifactVersionResolver,
829) -> Result<String> {
830    match source_versions.get(package.package).map(String::as_str) {
831        Some(version) if version != "latest" => Ok(version.to_string()),
832        _ => artifact_resolver
833            .resolve_latest(package.package)
834            .with_context(|| format!("failed to resolve GHCR version for `{}`", package.package)),
835    }
836}
837
838/// Apply the dev-channel `-dev` suffix to a crate name when the manifest
839/// channel is `"dev"`. The dev-publish lane mirrors every binary crate as
840/// `<crate>-dev` (binary bifurcation); the toolchain manifest must pin the
841/// mirrored crate so `cargo binstall` resolves the dev artifact instead of
842/// the stable one. Reuses `delegated_binary_name_for_channel` because the
843/// rule is identical for crates and binaries (`-dev` suffix, with the
844/// special carve-out that `greentic-dev` itself becomes `greentic-dev-dev`).
845fn manifest_crate_name_for_source(from: &str, crate_name: &str) -> String {
846    if from == "dev" {
847        delegated_binary_name_for_channel(crate_name, ToolchainChannel::Development)
848    } else {
849        crate_name.to_string()
850    }
851}
852
853pub fn validate_manifest(manifest: &ToolchainManifest) -> Result<()> {
854    if manifest.schema != TOOLCHAIN_MANIFEST_SCHEMA {
855        bail!(
856            "unsupported toolchain manifest schema `{}`",
857            manifest.schema
858        );
859    }
860    if manifest.toolchain != TOOLCHAIN_NAME {
861        bail!("unsupported toolchain `{}`", manifest.toolchain);
862    }
863    Ok(())
864}
865
866pub fn toolchain_ref(repo: &str, tag: &str) -> String {
867    format!("{repo}:{tag}")
868}
869
870// ---------------------------------------------------------------------------
871// Stable-lane release gate — a published manifest must never pin a version
872// whose binaries are not downloadable yet
873// ---------------------------------------------------------------------------
874
875const GITHUB_API_BASE: &str = "https://api.github.com";
876/// Every toolchain package's crate name doubles as its repo name under this org.
877const TOOLCHAIN_RELEASE_OWNER: &str = "greenticai";
878/// Archive extensions the shared `release-binaries.yml` workflow attaches.
879const RELEASE_ARCHIVE_SUFFIXES: [&str; 2] = [".tgz", ".zip"];
880
881/// Reads the asset names of one package's GitHub release. Injected so the gate
882/// is testable without a network round-trip, mirroring [`CrateVersionResolver`].
883trait ReleaseAssetChecker {
884    /// `Ok(None)` when the release does not exist, `Ok(Some(names))` otherwise.
885    fn release_assets(&self, repo: &str, tag: &str) -> Result<Option<Vec<String>>>;
886}
887
888#[derive(Deserialize)]
889struct GithubReleaseAssets {
890    #[serde(default)]
891    assets: Vec<GithubReleaseAsset>,
892}
893
894#[derive(Deserialize)]
895struct GithubReleaseAsset {
896    name: String,
897}
898
899struct GithubReleaseAssetChecker {
900    base_url: String,
901    token: Option<String>,
902    client: reqwest::blocking::Client,
903}
904
905impl GithubReleaseAssetChecker {
906    fn new(base_url: impl Into<String>, token: Option<String>) -> Self {
907        let client = reqwest::blocking::Client::builder()
908            .user_agent(format!("greentic-dev/{}", env!("CARGO_PKG_VERSION")))
909            .build()
910            .expect("failed to build GitHub API client");
911        Self {
912            base_url: base_url.into(),
913            token,
914            client,
915        }
916    }
917}
918
919impl ReleaseAssetChecker for GithubReleaseAssetChecker {
920    fn release_assets(&self, repo: &str, tag: &str) -> Result<Option<Vec<String>>> {
921        let url = format!(
922            "{}/repos/{TOOLCHAIN_RELEASE_OWNER}/{repo}/releases/tags/{tag}",
923            self.base_url.trim_end_matches('/')
924        );
925        let mut request = self
926            .client
927            .get(&url)
928            .header(reqwest::header::ACCEPT, "application/vnd.github+json");
929        if let Some(token) = &self.token {
930            request = request.bearer_auth(token);
931        }
932        let response = request
933            .send()
934            .with_context(|| format!("failed to GET {url}"))?;
935        let status = response.status();
936        let body = response
937            .text()
938            .with_context(|| format!("failed to read body of {url}"))?;
939        let Some(body) = classify_release_response(status, &url, body)? else {
940            return Ok(None);
941        };
942        let release: GithubReleaseAssets = serde_json::from_str(&body)
943            .with_context(|| format!("failed to parse release metadata from {url}"))?;
944        Ok(Some(
945            release.assets.into_iter().map(|asset| asset.name).collect(),
946        ))
947    }
948}
949
950/// The gate's production checker: real GitHub API, ambient token when one is
951/// available. Release reads on these public repos work unauthenticated; the
952/// token only lifts the rate limit.
953fn default_release_checker(raw_token: Option<&str>) -> GithubReleaseAssetChecker {
954    GithubReleaseAssetChecker::new(GITHUB_API_BASE, ambient_github_token(raw_token))
955}
956
957/// Classify a GitHub release-by-tag response: `Ok(None)` for a 404 (no such
958/// release), `Ok(Some(body))` on success, `Err` otherwise. Pure so the
959/// 404-vs-error decision is unit-testable without a live HTTP round-trip.
960fn classify_release_response(
961    status: reqwest::StatusCode,
962    url: &str,
963    body: String,
964) -> Result<Option<String>> {
965    if status == reqwest::StatusCode::NOT_FOUND {
966        return Ok(None);
967    }
968    if !status.is_success() {
969        bail!("GitHub API GET {url} returned {status}: {body}");
970    }
971    Ok(Some(body))
972}
973
974/// True when a push affects what `gtc install` resolves: either the manifest
975/// declares the stable channel, or the push moves the `stable` tag itself.
976///
977/// The tag half is not redundant. `generate_manifest` records `channel: <the
978/// --from value>`, and `--from` defaults to `latest`, so `publish --tag stable`
979/// routinely ships a manifest whose channel is `"latest"` while still moving the
980/// tag users install from. Gating on the channel alone would wave it through.
981fn affects_stable_channel(manifest: &ToolchainManifest, target_tag: Option<&str>) -> bool {
982    manifest.channel.as_deref() == Some("stable") || target_tag == Some("stable")
983}
984
985/// Refuse to publish a stable-lane manifest that pins a package version whose
986/// GitHub release is missing or still uploading.
987///
988/// The toolchain manifest is what `gtc install` resolves, so a pin that outruns
989/// its release build leaves `:stable` pointing at binaries nobody can download.
990/// The dev and research lanes publish on their own cadence and are never gated.
991fn verify_manifest_releases(
992    manifest: &ToolchainManifest,
993    target_tag: Option<&str>,
994    checker: &dyn ReleaseAssetChecker,
995) -> Result<()> {
996    if !affects_stable_channel(manifest, target_tag) {
997        return Ok(());
998    }
999    let mut problems = Vec::new();
1000    for package in &manifest.packages {
1001        let tag = format!("v{}", package.version);
1002        match checker.release_assets(&package.crate_name, &tag)? {
1003            None => problems.push(format!(
1004                "{} {tag}: no GitHub release (build not finished)",
1005                package.crate_name
1006            )),
1007            Some(assets) => {
1008                if let Err(reason) = check_release_assets(&assets, &package.version) {
1009                    problems.push(format!("{} {tag}: {reason}", package.crate_name));
1010                }
1011            }
1012        }
1013    }
1014    if !problems.is_empty() {
1015        bail!(
1016            "refusing to publish toolchain manifest {}: {} pinned package(s) are not \
1017             downloadable yet:\n  {}\nWait for the release builds to finish, then retry.",
1018            manifest.version,
1019            problems.len(),
1020            problems.join("\n  ")
1021        );
1022    }
1023    Ok(())
1024}
1025
1026/// A release is usable once it carries at least one versioned archive and every
1027/// versioned archive has its `.sha256` sibling. The `ensure-release` action
1028/// creates the release and *then* uploads the assets, so a half-populated
1029/// release is an observed state rather than a theoretical one.
1030///
1031/// Only assets embedding `-v<version>-` count. `greentic-pack` also attaches
1032/// unversioned `greentic-pack-<target>.tgz` aliases that carry no checksums —
1033/// deliberate `binstall` shims, not a half-finished upload — and demanding a
1034/// `.sha256` for those would reject every pack release ever published.
1035///
1036/// KNOWN LIMITATION: this cannot detect a release whose upload is partway
1037/// through its *first* target, because it has no notion of the expected target
1038/// matrix. Nothing available makes that knowable cheaply — `ensure-release`
1039/// creates the release without `--draft` (so there is no atomic publish
1040/// marker), the target set varies per package (`include-macos-intel`), and the
1041/// manifest's `bins` holds the delegated binary name, not the archive prefix
1042/// (greentic-mcp declares `greentic-mcp` but ships `greentic-mcp-exec-*` and
1043/// `greentic-mcp-generator-*`), so it cannot drive a per-binary check either.
1044/// The residual window is one `gh release upload` invocation — all assets go up
1045/// in a single call — against the 35-45 minutes of build time this does cover.
1046fn check_release_assets(assets: &[String], version: &str) -> Result<(), String> {
1047    let names: BTreeSet<&str> = assets.iter().map(String::as_str).collect();
1048    let version_marker = format!("-v{version}-");
1049    let archives: Vec<&str> = names
1050        .iter()
1051        .copied()
1052        .filter(|name| {
1053            name.contains(&version_marker)
1054                && RELEASE_ARCHIVE_SUFFIXES
1055                    .iter()
1056                    .any(|suffix| name.ends_with(suffix))
1057        })
1058        .collect();
1059    if archives.is_empty() {
1060        return Err(format!(
1061            "release has no v{version} archives yet (upload in progress)"
1062        ));
1063    }
1064    let unchecksummed: Vec<&str> = archives
1065        .iter()
1066        .copied()
1067        .filter(|name| !names.contains(format!("{name}.sha256").as_str()))
1068        .collect();
1069    if !unchecksummed.is_empty() {
1070        return Err(format!(
1071            "archives missing .sha256 (upload in progress): {}",
1072            unchecksummed.join(", ")
1073        ));
1074    }
1075    Ok(())
1076}
1077
1078fn source_version_map(source: Option<&ToolchainManifest>) -> BTreeMap<String, String> {
1079    let mut out = BTreeMap::new();
1080    if let Some(source) = source {
1081        for package in &source.packages {
1082            out.insert(package.crate_name.clone(), package.version.clone());
1083        }
1084    }
1085    out
1086}
1087
1088fn write_manifest(out_dir: &Path, manifest: &ToolchainManifest) -> Result<PathBuf> {
1089    fs::create_dir_all(out_dir)
1090        .with_context(|| format!("failed to create {}", out_dir.display()))?;
1091    let path = out_dir.join(manifest_file_name(manifest));
1092    let json = serde_json::to_vec_pretty(manifest).context("failed to serialize manifest")?;
1093    fs::write(&path, json).with_context(|| format!("failed to write {}", path.display()))?;
1094    Ok(path)
1095}
1096
1097fn manifest_file_name(manifest: &ToolchainManifest) -> String {
1098    match manifest.channel.as_deref() {
1099        Some("stable") | None => format!("gtc-{}.json", manifest.version),
1100        Some(channel) => format!("gtc-{channel}-{}.json", manifest.version),
1101    }
1102}
1103
1104fn created_at_now() -> Result<String> {
1105    OffsetDateTime::now_utc()
1106        .format(&Rfc3339)
1107        .context("failed to format current time")
1108}
1109
1110/// Outcome of resolving the research (`-rnd`) version of a toolchain crate.
1111///
1112/// Only `start`/`runner`/`setup` carry `-research` builds; the other ~10
1113/// delegated toolchain crates have no `<name>-rnd` published. Resolving those
1114/// must not be a hard error — it is an expected "no research build" signal that
1115/// the caller turns into a skip, so the research channel still assembles.
1116pub enum ResearchVersion {
1117    /// The `<name>-rnd` crate is published; pin this exact version.
1118    Pinned(String),
1119    /// The `<name>-rnd` crate is not published on crates.io (HTTP 404). The
1120    /// tool ships no research build and must be skipped on the research channel
1121    /// rather than aborting the whole install / manifest assembly.
1122    Absent,
1123}
1124
1125pub trait CrateVersionResolver {
1126    fn resolve_latest(&self, crate_name: &str) -> Result<String>;
1127
1128    /// Channel-aware resolution. The research (`rnd`) lane publishes base-name
1129    /// crates at `X.Y.Z-research` PRERELEASES (greentic-runner's
1130    /// research-publish.yml), which `resolve_latest`'s `max_stable_version`
1131    /// preference silently skips — so the dev/stable behaviour returns the old
1132    /// stable (e.g. `0.5.x`) instead of the current `1.2.0-research`. The
1133    /// default delegates to `resolve_latest` (correct for dev/stable).
1134    fn resolve_latest_for_channel(
1135        &self,
1136        crate_name: &str,
1137        _channel: ToolchainChannel,
1138    ) -> Result<String> {
1139        self.resolve_latest(crate_name)
1140    }
1141
1142    /// Resolve the latest version INSIDE a `(major, minor)` lane.
1143    ///
1144    /// The dev channel needs this: greentic versions its lanes by minor (1.2.x
1145    /// dev, 1.3.x research), so "highest overall" lets an abandoned research
1146    /// build outrank an active dev one. The default ignores the lane, which is
1147    /// correct for resolvers that serve a single lane (the test fakes).
1148    fn resolve_latest_in_lane(&self, crate_name: &str, _lane: (u64, u64)) -> Result<String> {
1149        self.resolve_latest(crate_name)
1150    }
1151
1152    /// Resolve the research (`-rnd`) version, distinguishing an unpublished
1153    /// crate (HTTP 404 → [`ResearchVersion::Absent`]) from a genuine resolution
1154    /// error. The default treats every resolvable crate as
1155    /// [`ResearchVersion::Pinned`]; only the crates.io resolver can observe a
1156    /// 404, so it overrides this.
1157    fn resolve_research_version(&self, crate_name: &str) -> Result<ResearchVersion> {
1158        self.resolve_latest_for_channel(crate_name, ToolchainChannel::Rnd)
1159            .map(ResearchVersion::Pinned)
1160    }
1161}
1162
1163/// Default resolver used by `generate`, `publish`, and `snapshot`. Hits the
1164/// crates.io HTTP API directly — see `CratesIoApiVersionResolver` for why
1165/// this is preferred over shelling out to `cargo search`.
1166fn default_resolver() -> CratesIoApiVersionResolver {
1167    CratesIoApiVersionResolver::default()
1168}
1169
1170pub trait ArtifactVersionResolver {
1171    fn resolve_latest(&self, package: &str) -> Result<String>;
1172}
1173
1174const CRATES_IO_API_BASE: &str = "https://crates.io/api/v1/crates";
1175const CRATES_IO_USER_AGENT: &str = concat!(
1176    "greentic-dev/",
1177    env!("CARGO_PKG_VERSION"),
1178    " (https://github.com/greenticai/greentic-dev)"
1179);
1180
1181/// Resolve the latest published version of a crate by hitting the crates.io
1182/// HTTP API directly. Returns `max_stable_version` when present, falling back
1183/// to `newest_version` and then `max_version`. Replaces an earlier
1184/// `cargo search`-based resolver that ranked results by relevance and parsed
1185/// stdout heuristically — both brittle for `<name>-dev` aliases that share
1186/// prefixes with their stable parents.
1187pub struct CratesIoApiVersionResolver {
1188    base_url: String,
1189    client: reqwest::blocking::Client,
1190}
1191
1192impl Default for CratesIoApiVersionResolver {
1193    fn default() -> Self {
1194        Self::new(CRATES_IO_API_BASE)
1195    }
1196}
1197
1198impl CratesIoApiVersionResolver {
1199    pub fn new(base_url: impl Into<String>) -> Self {
1200        let client = reqwest::blocking::Client::builder()
1201            .user_agent(CRATES_IO_USER_AGENT)
1202            .build()
1203            .expect("failed to build crates.io API client");
1204        Self {
1205            base_url: base_url.into(),
1206            client,
1207        }
1208    }
1209
1210    /// GET the crates.io page for `crate_name`. `Ok(None)` when the crate is
1211    /// absent (HTTP 404), `Ok(Some(body))` on success, `Err` on any other
1212    /// status or transport failure. Lets callers treat "no such crate" as a
1213    /// skip rather than a hard error.
1214    fn fetch_crate_body(&self, crate_name: &str) -> Result<Option<String>> {
1215        let url = format!("{}/{}", self.base_url.trim_end_matches('/'), crate_name);
1216        let response = self
1217            .client
1218            .get(&url)
1219            .send()
1220            .with_context(|| format!("failed to GET {url}"))?;
1221        let status = response.status();
1222        let body = response
1223            .text()
1224            .with_context(|| format!("failed to read body of {url}"))?;
1225        classify_crate_response(status, &url, body)
1226    }
1227}
1228
1229/// Classify a crates.io crate-page response by HTTP status: `Ok(None)` for a
1230/// 404 (crate absent), `Ok(Some(body))` for success, `Err` otherwise. Pure so
1231/// the 404-vs-error decision is unit-testable without a live HTTP round-trip.
1232fn classify_crate_response(
1233    status: reqwest::StatusCode,
1234    url: &str,
1235    body: String,
1236) -> Result<Option<String>> {
1237    if status == reqwest::StatusCode::NOT_FOUND {
1238        return Ok(None);
1239    }
1240    if !status.is_success() {
1241        bail!("crates.io API GET {url} returned {status}: {body}");
1242    }
1243    Ok(Some(body))
1244}
1245
1246/// Pick the research version from a crates.io body, or fall back to the latest
1247/// published version when no `-research` prerelease exists. Toolchain crates
1248/// with no `-research` publish yet keep their latest build so the snapshot still
1249/// assembles; the multi-provider-critical crates (runner/setup/start) carry a
1250/// `-research` build. The fallback is logged so silent staleness stays visible.
1251fn research_or_fallback(crate_name: &str, body: &str) -> Result<String> {
1252    match parse_crates_io_research_version(crate_name, body) {
1253        Ok(version) => Ok(version),
1254        Err(_) => {
1255            let fallback = pick_highest_crates_io_version(crate_name, body, false)?;
1256            eprintln!(
1257                "note: `{crate_name}` has no -research publish; the research \
1258                 toolchain falls back to latest `{fallback}`"
1259            );
1260            Ok(fallback)
1261        }
1262    }
1263}
1264
1265struct ReleaseArtifactVersionResolver<'a> {
1266    release: &'a str,
1267}
1268
1269impl ArtifactVersionResolver for ReleaseArtifactVersionResolver<'_> {
1270    fn resolve_latest(&self, _package: &str) -> Result<String> {
1271        Ok(self.release.to_string())
1272    }
1273}
1274
1275struct GhcrArtifactVersionResolver {
1276    client: reqwest::blocking::Client,
1277    registry: String,
1278    namespace: String,
1279    basic_token: Option<String>,
1280}
1281
1282impl GhcrArtifactVersionResolver {
1283    fn new(raw_token: Option<&str>) -> Result<Self> {
1284        Ok(Self {
1285            client: reqwest::blocking::Client::builder()
1286                .build()
1287                .context("failed to build GHCR HTTP client")?,
1288            registry: "ghcr.io".to_string(),
1289            namespace: "greenticai".to_string(),
1290            basic_token: resolve_registry_token(raw_token)?
1291                .or_else(|| std::env::var("GHCR_TOKEN").ok())
1292                .or_else(|| std::env::var("GITHUB_TOKEN").ok()),
1293        })
1294    }
1295
1296    fn bearer_token(&self, repository: &str) -> Result<String> {
1297        let scope = format!("repository:{repository}:pull");
1298        let mut request = self
1299            .client
1300            .get(format!("https://{}/token", self.registry))
1301            .query(&[
1302                ("service", self.registry.as_str()),
1303                ("scope", scope.as_str()),
1304            ]);
1305        if let Some(token) = &self.basic_token {
1306            request = request.basic_auth(DEFAULT_OAUTH_USER, Some(token));
1307        }
1308        let response = request
1309            .send()
1310            .with_context(|| format!("failed to request GHCR token for `{repository}`"))?
1311            .error_for_status()
1312            .with_context(|| format!("GHCR token request failed for `{repository}`"))?;
1313        let body: GhcrTokenResponse = response
1314            .json()
1315            .with_context(|| format!("failed to parse GHCR token response for `{repository}`"))?;
1316        Ok(body.token)
1317    }
1318
1319    fn tags(&self, repository: &str) -> Result<Vec<String>> {
1320        let token = self.bearer_token(repository)?;
1321        let response = self
1322            .client
1323            .get(format!(
1324                "https://{}/v2/{repository}/tags/list",
1325                self.registry
1326            ))
1327            .bearer_auth(token)
1328            .send()
1329            .with_context(|| format!("failed to list GHCR tags for `{repository}`"))?
1330            .error_for_status()
1331            .with_context(|| format!("GHCR tag list request failed for `{repository}`"))?;
1332        let body: GhcrTagsResponse = response
1333            .json()
1334            .with_context(|| format!("failed to parse GHCR tags for `{repository}`"))?;
1335        Ok(body.tags)
1336    }
1337}
1338
1339impl ArtifactVersionResolver for GhcrArtifactVersionResolver {
1340    fn resolve_latest(&self, package: &str) -> Result<String> {
1341        let repository = format!("{}/{}", self.namespace, package);
1342        let tags = self.tags(&repository)?;
1343        select_latest_artifact_tag(&tags)
1344            .with_context(|| format!("no usable tags found for GHCR package `{repository}`"))
1345    }
1346}
1347
1348#[derive(Deserialize)]
1349struct GhcrTokenResponse {
1350    token: String,
1351}
1352
1353#[derive(Deserialize)]
1354struct GhcrTagsResponse {
1355    #[serde(default)]
1356    tags: Vec<String>,
1357}
1358
1359fn select_latest_artifact_tag(tags: &[String]) -> Result<String> {
1360    tags.iter()
1361        .filter_map(|tag| Version::parse(tag).ok().map(|version| (version, tag)))
1362        .max_by(|(left, _), (right, _)| left.cmp(right))
1363        .map(|(_, tag)| tag.clone())
1364        .or_else(|| tags.iter().find(|tag| tag.as_str() == "latest").cloned())
1365        .context("no semver or latest tags found")
1366}
1367
1368impl CrateVersionResolver for CratesIoApiVersionResolver {
1369    fn resolve_latest(&self, crate_name: &str) -> Result<String> {
1370        let url = format!("{}/{}", self.base_url.trim_end_matches('/'), crate_name);
1371        let response = self
1372            .client
1373            .get(&url)
1374            .send()
1375            .with_context(|| format!("failed to GET {url}"))?;
1376        let status = response.status();
1377        let body = response
1378            .text()
1379            .with_context(|| format!("failed to read body of {url}"))?;
1380        if !status.is_success() {
1381            bail!("crates.io API GET {url} returned {status}: {body}");
1382        }
1383        parse_crates_io_version(crate_name, &body)
1384    }
1385
1386    fn resolve_latest_for_channel(
1387        &self,
1388        crate_name: &str,
1389        channel: ToolchainChannel,
1390    ) -> Result<String> {
1391        if channel != ToolchainChannel::Rnd {
1392            return self.resolve_latest(crate_name);
1393        }
1394        let url = format!("{}/{}", self.base_url.trim_end_matches('/'), crate_name);
1395        let body = self.fetch_crate_body(crate_name)?.ok_or_else(|| {
1396            anyhow!("crates.io API GET {url} returned 404 Not Found (no published `{crate_name}`)")
1397        })?;
1398        research_or_fallback(crate_name, &body)
1399    }
1400
1401    fn resolve_latest_in_lane(&self, crate_name: &str, lane: (u64, u64)) -> Result<String> {
1402        let url = format!("{}/{}", self.base_url.trim_end_matches('/'), crate_name);
1403        let body = self.fetch_crate_body(crate_name)?.ok_or_else(|| {
1404            anyhow!("crates.io API GET {url} returned 404 Not Found (no published `{crate_name}`)")
1405        })?;
1406        pick_highest_in_lane(crate_name, &body, lane)
1407    }
1408
1409    fn resolve_research_version(&self, crate_name: &str) -> Result<ResearchVersion> {
1410        // A 404 means the `<name>-rnd` crate is simply not published — the tool
1411        // ships no research build. Map it to `Absent` (a skip signal) instead of
1412        // aborting, so `gtc-research install` / manifest assembly survives the
1413        // ~10 of 13 toolchain crates that have no research line.
1414        match self.fetch_crate_body(crate_name)? {
1415            None => Ok(ResearchVersion::Absent),
1416            Some(body) => research_or_fallback(crate_name, &body).map(ResearchVersion::Pinned),
1417        }
1418    }
1419}
1420
1421/// Pick the highest non-yanked version from the crates.io `/crates/<name>`
1422/// response's top-level `versions` array. When `research_only`, restricts to
1423/// `-research` prereleases (the research lane publishes `X.Y.Z-research`, which
1424/// `max_stable_version` skips). Otherwise picks the highest semver of ANY
1425/// channel — the fallback for toolchain crates with no `-research` build, which
1426/// keeps them at their latest dev build instead of regressing to old stable.
1427/// The `(major, minor)` lane a release belongs to. greentic versions its
1428/// toolchain lanes by minor: 1.2.x is dev, 1.3.x is research.
1429fn lane_of(release: &str) -> Option<(u64, u64)> {
1430    let mut parts = release.split('.');
1431    let major = parts.next()?.parse().ok()?;
1432    let minor = parts.next()?.parse().ok()?;
1433    Some((major, minor))
1434}
1435
1436/// Highest non-yanked version of `crate_name` INSIDE `lane`.
1437///
1438/// The dev channel must not leave its own minor line. Picking the highest
1439/// version overall lets an abandoned lane outrank an active one purely on
1440/// semver ordering — `greentic-setup-dev` stopped publishing 1.3 in July while
1441/// the dev lane kept shipping 1.2.<run_id>, so every later dev manifest pinned
1442/// the July build and the channel froze without anyone doing anything wrong.
1443///
1444/// An empty lane is an error rather than a fallback: falling back to another
1445/// lane is the behaviour this function exists to prevent.
1446fn pick_highest_in_lane(crate_name: &str, body: &str, lane: (u64, u64)) -> Result<String> {
1447    let payload: serde_json::Value = serde_json::from_str(body)
1448        .with_context(|| format!("crates.io API for `{crate_name}` returned invalid JSON"))?;
1449    let versions = payload
1450        .get("versions")
1451        .and_then(|v| v.as_array())
1452        .ok_or_else(|| {
1453            anyhow!("crates.io API for `{crate_name}` is missing the `versions` array")
1454        })?;
1455    let mut best: Option<Version> = None;
1456    for entry in versions {
1457        if entry
1458            .get("yanked")
1459            .and_then(serde_json::Value::as_bool)
1460            .unwrap_or(false)
1461        {
1462            continue;
1463        }
1464        let Some(num) = entry.get("num").and_then(|n| n.as_str()) else {
1465            continue;
1466        };
1467        let Ok(parsed) = Version::parse(num) else {
1468            continue;
1469        };
1470        if (parsed.major, parsed.minor) != lane {
1471            continue;
1472        }
1473        if best.as_ref().is_none_or(|current| parsed > *current) {
1474            best = Some(parsed);
1475        }
1476    }
1477    best.map(|v| v.to_string()).ok_or_else(|| {
1478        anyhow!(
1479            "crates.io has no non-yanked `{crate_name}` in the {}.{} lane",
1480            lane.0,
1481            lane.1
1482        )
1483    })
1484}
1485
1486fn pick_highest_crates_io_version(
1487    crate_name: &str,
1488    body: &str,
1489    research_only: bool,
1490) -> Result<String> {
1491    let payload: serde_json::Value = serde_json::from_str(body)
1492        .with_context(|| format!("crates.io API for `{crate_name}` returned invalid JSON"))?;
1493    let versions = payload
1494        .get("versions")
1495        .and_then(|v| v.as_array())
1496        .ok_or_else(|| {
1497            anyhow!("crates.io API for `{crate_name}` is missing the `versions` array")
1498        })?;
1499    let mut best: Option<Version> = None;
1500    for entry in versions {
1501        if entry
1502            .get("yanked")
1503            .and_then(serde_json::Value::as_bool)
1504            .unwrap_or(false)
1505        {
1506            continue;
1507        }
1508        let Some(num) = entry.get("num").and_then(|n| n.as_str()) else {
1509            continue;
1510        };
1511        let Ok(parsed) = Version::parse(num) else {
1512            continue;
1513        };
1514        if research_only && !parsed.pre.as_str().starts_with("research") {
1515            continue;
1516        }
1517        if best.as_ref().is_none_or(|current| parsed > *current) {
1518            best = Some(parsed);
1519        }
1520    }
1521    best.map(|v| v.to_string()).ok_or_else(|| {
1522        let what = if research_only {
1523            "no non-yanked `-research` version"
1524        } else {
1525            "no non-yanked versions"
1526        };
1527        anyhow!("crates.io API for `{crate_name}` exposes {what}")
1528    })
1529}
1530
1531fn parse_crates_io_research_version(crate_name: &str, body: &str) -> Result<String> {
1532    pick_highest_crates_io_version(crate_name, body, true)
1533}
1534
1535fn parse_crates_io_version(crate_name: &str, body: &str) -> Result<String> {
1536    let payload: serde_json::Value = serde_json::from_str(body)
1537        .with_context(|| format!("crates.io API for `{crate_name}` returned invalid JSON"))?;
1538    let crate_obj = payload.get("crate").ok_or_else(|| {
1539        anyhow!("crates.io API for `{crate_name}` is missing the top-level `crate` object")
1540    })?;
1541    let version = crate_obj
1542        .get("max_stable_version")
1543        .and_then(|v| v.as_str())
1544        .or_else(|| crate_obj.get("newest_version").and_then(|v| v.as_str()))
1545        .or_else(|| crate_obj.get("max_version").and_then(|v| v.as_str()))
1546        .ok_or_else(|| {
1547            anyhow!(
1548                "crates.io API for `{crate_name}` does not expose max_stable_version, \
1549                 newest_version, or max_version"
1550            )
1551        })?;
1552    Version::parse(version).with_context(|| {
1553        format!("crates.io returned an unparseable version `{version}` for `{crate_name}`")
1554    })?;
1555    Ok(version.to_string())
1556}
1557
1558#[async_trait]
1559trait ToolchainManifestSource {
1560    async fn load_manifest(
1561        &self,
1562        repo: &str,
1563        tag: &str,
1564        token: Option<&str>,
1565    ) -> Result<Option<ToolchainManifest>>;
1566}
1567
1568struct OciToolchainManifestSource;
1569
1570#[async_trait]
1571impl ToolchainManifestSource for OciToolchainManifestSource {
1572    async fn load_manifest(
1573        &self,
1574        repo: &str,
1575        tag: &str,
1576        token: Option<&str>,
1577    ) -> Result<Option<ToolchainManifest>> {
1578        let auth = optional_registry_auth(token)?;
1579        let client = oci_client();
1580        let reference = parse_reference(repo, tag)?;
1581        let image = match client
1582            .pull(&reference, &auth, vec![TOOLCHAIN_LAYER_MEDIA_TYPE])
1583            .await
1584        {
1585            Ok(image) => image,
1586            Err(err) if is_missing_manifest_error(&err) || is_unauthorized_error(&err) => {
1587                return Ok(None);
1588            }
1589            Err(err) => {
1590                return Err(err)
1591                    .with_context(|| format!("failed to pull {}", toolchain_ref(repo, tag)));
1592            }
1593        };
1594        let Some(layer) = image
1595            .layers
1596            .into_iter()
1597            .find(|layer| layer.media_type == TOOLCHAIN_LAYER_MEDIA_TYPE)
1598        else {
1599            return Ok(None);
1600        };
1601        let manifest = serde_json::from_slice::<ToolchainManifest>(&layer.data)
1602            .with_context(|| format!("failed to parse {}", toolchain_ref(repo, tag)))?;
1603        validate_manifest(&manifest)?;
1604        Ok(Some(manifest))
1605    }
1606}
1607
1608async fn load_source_manifest(
1609    repo: &str,
1610    tag: &str,
1611    token: Option<&str>,
1612) -> Result<Option<ToolchainManifest>> {
1613    OciToolchainManifestSource
1614        .load_manifest(repo, tag, token)
1615        .await
1616}
1617
1618fn oci_client() -> Client {
1619    Client::new(ClientConfig {
1620        protocol: ClientProtocol::Https,
1621        ..Default::default()
1622    })
1623}
1624
1625// ---------------------------------------------------------------------------
1626// Updater dispatch — notify the coordinated update-plan workflow
1627// ---------------------------------------------------------------------------
1628
1629/// Returns `true` when `version` is a plain `X.Y.Z` (no pre-release, no build
1630/// metadata) AND `channel` is `"stable"`. The update server must only ever
1631/// receive stable-lane content; everything else (dev, rnd, run-id versions)
1632/// is silently skipped.
1633fn should_notify_updater(version: &str, channel: &str) -> bool {
1634    if channel != "stable" {
1635        return false;
1636    }
1637    match Version::parse(version) {
1638        Ok(v) => v.pre.is_empty() && v.build.is_empty(),
1639        Err(_) => false,
1640    }
1641}
1642
1643/// Resolve a GitHub token from `--token`, then the ambient CI environment. An
1644/// empty or whitespace-only value counts as absent. Reads of public release
1645/// metadata work without one; only the dispatch strictly needs it.
1646fn ambient_github_token(raw_token: Option<&str>) -> Option<String> {
1647    resolve_registry_token(raw_token)
1648        .ok()
1649        .flatten()
1650        .or_else(|| std::env::var("GHCR_TOKEN").ok())
1651        .or_else(|| std::env::var("GITHUB_TOKEN").ok())
1652        .filter(|token| !token.trim().is_empty())
1653}
1654
1655/// Fire a `repository_dispatch` to trigger the coordinated update-plan
1656/// publisher workflow. Failure is a warning, never fatal — the GHCR manifest
1657/// push already succeeded, and the workflow has a manual `workflow_dispatch`
1658/// fallback.
1659fn notify_updater_dispatch(version: &str, channel: &str, raw_token: Option<&str>) {
1660    if !should_notify_updater(version, channel) {
1661        eprintln!(
1662            "Skipping updater dispatch: version `{version}` / channel `{channel}` \
1663             is not a stable-lane release"
1664        );
1665        return;
1666    }
1667
1668    let Some(token) = ambient_github_token(raw_token) else {
1669        eprintln!(
1670            "Warning: skipping updater dispatch — no token available \
1671             (pass --token or set GHCR_TOKEN/GITHUB_TOKEN)"
1672        );
1673        return;
1674    };
1675
1676    let client = match reqwest::blocking::Client::builder()
1677        .user_agent("greentic-dev-cli")
1678        .build()
1679    {
1680        Ok(c) => c,
1681        Err(e) => {
1682            eprintln!("Warning: failed to build HTTP client for updater dispatch: {e}");
1683            return;
1684        }
1685    };
1686
1687    let body = serde_json::json!({
1688        "event_type": "toolchain-release-published",
1689        "client_payload": {
1690            "release": version,
1691            "channel": channel,
1692        }
1693    });
1694
1695    let url = "https://api.github.com/repos/greenticai/greentic-dev/dispatches";
1696    match client
1697        .post(url)
1698        .header("Accept", "application/vnd.github+json")
1699        .bearer_auth(&token)
1700        .json(&body)
1701        .send()
1702    {
1703        Ok(resp) if resp.status().is_success() || resp.status().as_u16() == 204 => {
1704            eprintln!("Dispatched toolchain-release-published for {version} (channel={channel})");
1705        }
1706        Ok(resp) => {
1707            let status = resp.status();
1708            let text = resp.text().unwrap_or_default();
1709            eprintln!("Warning: updater dispatch returned HTTP {status}: {text}");
1710        }
1711        Err(e) => {
1712            eprintln!("Warning: updater dispatch failed: {e}");
1713        }
1714    }
1715}
1716
1717fn registry_auth(raw_token: Option<&str>) -> Result<RegistryAuth> {
1718    let token = resolve_registry_token(raw_token)?
1719        .or_else(|| std::env::var("GHCR_TOKEN").ok())
1720        .or_else(|| std::env::var("GITHUB_TOKEN").ok())
1721        .context("GHCR token is required; pass --token or set GHCR_TOKEN/GITHUB_TOKEN")?;
1722    if token.trim().is_empty() {
1723        bail!("GHCR token is empty");
1724    }
1725    Ok(RegistryAuth::Basic(DEFAULT_OAUTH_USER.to_string(), token))
1726}
1727
1728fn optional_registry_auth(raw_token: Option<&str>) -> Result<RegistryAuth> {
1729    match registry_auth(raw_token) {
1730        Ok(auth) => Ok(auth),
1731        Err(_) if raw_token.is_none() => Ok(RegistryAuth::Anonymous),
1732        Err(err) => Err(err),
1733    }
1734}
1735
1736fn resolve_registry_token(raw_token: Option<&str>) -> Result<Option<String>> {
1737    let Some(raw_token) = raw_token else {
1738        return Ok(None);
1739    };
1740    if let Some(var) = raw_token.strip_prefix("env:") {
1741        let token =
1742            std::env::var(var).with_context(|| format!("failed to resolve env var {var}"))?;
1743        if token.trim().is_empty() {
1744            bail!("env var {var} resolved to an empty token");
1745        }
1746        return Ok(Some(token));
1747    }
1748    if raw_token.trim().is_empty() {
1749        bail!("GHCR token is empty");
1750    }
1751    Ok(Some(raw_token.to_string()))
1752}
1753
1754fn parse_reference(repo: &str, tag: &str) -> Result<Reference> {
1755    Reference::from_str(&toolchain_ref(repo, tag))
1756        .with_context(|| format!("invalid OCI reference `{}`", toolchain_ref(repo, tag)))
1757}
1758
1759async fn manifest_exists(
1760    client: &Client,
1761    reference: &Reference,
1762    auth: &RegistryAuth,
1763) -> Result<bool> {
1764    match client.pull_manifest(reference, auth).await {
1765        Ok(_) => Ok(true),
1766        Err(err) if is_missing_manifest_error(&err) => Ok(false),
1767        Err(err) => Err(err).context("failed to check whether release tag exists"),
1768    }
1769}
1770
1771fn is_missing_manifest_error(err: &oci_distribution::errors::OciDistributionError) -> bool {
1772    let msg = err.to_string().to_ascii_lowercase();
1773    msg.contains("manifest unknown")
1774        || msg.contains("name unknown")
1775        || msg.contains("not found")
1776        || msg.contains("404")
1777}
1778
1779fn is_unauthorized_error(err: &oci_distribution::errors::OciDistributionError) -> bool {
1780    let msg = err.to_string().to_ascii_lowercase();
1781    msg.contains("not authorized") || msg.contains("unauthorized") || msg.contains("401")
1782}
1783
1784async fn push_manifest_layer(
1785    client: &Client,
1786    reference: &Reference,
1787    auth: &RegistryAuth,
1788    manifest: &ToolchainManifest,
1789) -> Result<()> {
1790    let data = serde_json::to_vec_pretty(manifest).context("failed to serialize manifest")?;
1791    let layer = ImageLayer::new(data, TOOLCHAIN_LAYER_MEDIA_TYPE.to_string(), None);
1792    let config = Config::new(
1793        br#"{"toolchain":"gtc"}"#.to_vec(),
1794        TOOLCHAIN_CONFIG_MEDIA_TYPE.to_string(),
1795        None,
1796    );
1797    client
1798        .push(reference, &[layer], config, auth, None)
1799        .await
1800        .context("failed to push toolchain manifest")?;
1801    Ok(())
1802}
1803
1804#[cfg(test)]
1805mod tests {
1806    use super::*;
1807    use once_cell::sync::Lazy;
1808    use std::sync::Mutex;
1809
1810    static ENV_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
1811
1812    struct FixedResolver;
1813
1814    impl CrateVersionResolver for FixedResolver {
1815        fn resolve_latest(&self, crate_name: &str) -> Result<String> {
1816            Ok(match crate_name {
1817                "greentic-runner" => "0.5.10",
1818                _ => "1.2.3",
1819            }
1820            .to_string())
1821        }
1822    }
1823
1824    struct FixedArtifactResolver;
1825
1826    impl ArtifactVersionResolver for FixedArtifactResolver {
1827        fn resolve_latest(&self, package: &str) -> Result<String> {
1828            Ok(match package {
1829                "packs/messaging/messaging-webchat-gui" => "0.4.93",
1830                "components/component-adaptive-card" => "0.5.8",
1831                _ => "0.1.0",
1832            }
1833            .to_string())
1834        }
1835    }
1836
1837    #[test]
1838    fn parses_crates_io_max_stable_version() {
1839        let body = r#"{"crate":{"id":"greentic-operator-dev","max_stable_version":"0.5.123"}}"#;
1840        let version = parse_crates_io_version("greentic-operator-dev", body).unwrap();
1841        assert_eq!(version, "0.5.123");
1842    }
1843
1844    #[test]
1845    fn research_resolver_picks_highest_non_yanked_research_prerelease() {
1846        // The research lane must ignore the stable `max_stable_version` (0.5.48)
1847        // and pick the highest non-yanked `-research` prerelease.
1848        let body = r#"{"crate":{"id":"greentic-runner","max_stable_version":"0.5.48"},
1849            "versions":[
1850                {"num":"0.5.48","yanked":false},
1851                {"num":"1.2.0-research.0","yanked":false},
1852                {"num":"1.2.0-research.1","yanked":false},
1853                {"num":"1.2.0-research.2","yanked":true}
1854            ]}"#;
1855        let version = parse_crates_io_research_version("greentic-runner", body).unwrap();
1856        assert_eq!(version, "1.2.0-research.1");
1857    }
1858
1859    #[test]
1860    fn research_resolver_errors_when_no_research_version() {
1861        let body = r#"{"crate":{"id":"greentic-setup"},
1862            "versions":[{"num":"1.2.0-dev.123","yanked":false},{"num":"0.5.25","yanked":false}]}"#;
1863        let err = parse_crates_io_research_version("greentic-setup", body).unwrap_err();
1864        assert!(err.to_string().contains("no non-yanked `-research`"));
1865    }
1866
1867    /// Mirrors the real fleet: only start/runner/setup ship `-research` crates,
1868    /// so any `operator` crate resolves to `Absent` (a skip), everything else to
1869    /// a pinned research version.
1870    struct ResearchSkipResolver;
1871
1872    impl CrateVersionResolver for ResearchSkipResolver {
1873        fn resolve_latest(&self, _crate_name: &str) -> Result<String> {
1874            Ok("1.2.0-research.4".to_string())
1875        }
1876
1877        fn resolve_research_version(&self, crate_name: &str) -> Result<ResearchVersion> {
1878            if crate_name.contains("operator") {
1879                Ok(ResearchVersion::Absent)
1880            } else {
1881                Ok(ResearchVersion::Pinned("1.2.0-research.4".to_string()))
1882            }
1883        }
1884    }
1885
1886    #[test]
1887    fn classify_crate_response_maps_404_to_absent() {
1888        let outcome =
1889            classify_crate_response(reqwest::StatusCode::NOT_FOUND, "url", "missing".to_string())
1890                .unwrap();
1891        assert!(outcome.is_none(), "404 must classify as absent (None)");
1892    }
1893
1894    #[test]
1895    fn classify_crate_response_returns_body_on_success() {
1896        let outcome =
1897            classify_crate_response(reqwest::StatusCode::OK, "url", "payload".to_string()).unwrap();
1898        assert_eq!(outcome.as_deref(), Some("payload"));
1899    }
1900
1901    #[test]
1902    fn classify_crate_response_errors_on_other_status() {
1903        let err = classify_crate_response(
1904            reqwest::StatusCode::INTERNAL_SERVER_ERROR,
1905            "url",
1906            "boom".to_string(),
1907        )
1908        .unwrap_err();
1909        assert!(err.to_string().contains("500"));
1910    }
1911
1912    #[test]
1913    fn snapshot_manifest_skips_crates_without_research_build() {
1914        // The absent `operator` crate must be omitted, not abort the whole
1915        // research manifest — the regression behind .github#212's install hang.
1916        let manifest = snapshot_manifest(
1917            "1.2.0-research.4",
1918            ToolchainChannel::Rnd,
1919            &ResearchSkipResolver,
1920            None,
1921        )
1922        .unwrap();
1923        assert!(
1924            !manifest.packages.is_empty(),
1925            "research-built tools must remain in the manifest"
1926        );
1927        assert!(
1928            manifest.packages.len() < GREENTIC_TOOLCHAIN_PACKAGES.len(),
1929            "at least one tool without a research build must be skipped"
1930        );
1931        assert!(
1932            manifest
1933                .packages
1934                .iter()
1935                .all(|package| !package.crate_name.contains("operator")),
1936            "the absent `operator` crate must be omitted"
1937        );
1938        assert!(
1939            manifest
1940                .packages
1941                .iter()
1942                .all(|package| package.version == "1.2.0-research.4"),
1943            "remaining research tools pin their resolved -research version"
1944        );
1945    }
1946
1947    #[test]
1948    fn parses_crates_io_falls_back_to_newest_version() {
1949        let body = r#"{"crate":{"id":"greentic-flow-dev","newest_version":"0.6.7"}}"#;
1950        let version = parse_crates_io_version("greentic-flow-dev", body).unwrap();
1951        assert_eq!(version, "0.6.7");
1952    }
1953
1954    #[test]
1955    fn parses_crates_io_falls_back_to_max_version() {
1956        let body = r#"{"crate":{"id":"greentic-runner-dev","max_version":"0.4.99"}}"#;
1957        let version = parse_crates_io_version("greentic-runner-dev", body).unwrap();
1958        assert_eq!(version, "0.4.99");
1959    }
1960
1961    #[test]
1962    fn rejects_crates_io_payload_without_versions() {
1963        let body = r#"{"crate":{"id":"greentic-dev"}}"#;
1964        let err = parse_crates_io_version("greentic-dev", body).unwrap_err();
1965        assert!(
1966            err.to_string()
1967                .contains("does not expose max_stable_version")
1968        );
1969    }
1970
1971    #[test]
1972    fn rejects_crates_io_payload_with_unparseable_version() {
1973        let body = r#"{"crate":{"max_stable_version":"not-a-version"}}"#;
1974        let err = parse_crates_io_version("greentic-dev", body).unwrap_err();
1975        assert!(err.to_string().contains("unparseable version"));
1976    }
1977
1978    #[test]
1979    fn rejects_crates_io_payload_without_crate_object() {
1980        let body = r#"{"errors":[{"detail":"not found"}]}"#;
1981        let err = parse_crates_io_version("greentic-dev", body).unwrap_err();
1982        assert!(
1983            err.to_string()
1984                .contains("missing the top-level `crate` object")
1985        );
1986    }
1987
1988    #[test]
1989    fn selects_latest_semver_tag() {
1990        let tags = vec![
1991            "latest".to_string(),
1992            "0.4.93".to_string(),
1993            "0.4.9".to_string(),
1994            "1.0.0-beta.1".to_string(),
1995            "1.0.0".to_string(),
1996        ];
1997
1998        assert_eq!(select_latest_artifact_tag(&tags).unwrap(), "1.0.0");
1999    }
2000
2001    #[test]
2002    fn selects_latest_tag_when_no_semver_tags_exist() {
2003        let tags = vec!["latest".to_string()];
2004
2005        assert_eq!(select_latest_artifact_tag(&tags).unwrap(), "latest");
2006    }
2007
2008    #[test]
2009    fn generates_manifest_from_catalogue() {
2010        let manifest = generate_manifest("1.0.5", "latest", None, &FixedResolver, None).unwrap();
2011        assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
2012        assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
2013        assert_eq!(manifest.version, "1.0.5");
2014        assert_eq!(manifest.channel.as_deref(), Some("latest"));
2015        assert!(
2016            manifest
2017                .packages
2018                .iter()
2019                .any(|package| package.crate_name == "greentic-bundle"
2020                    && package.bins == ["greentic-bundle"])
2021        );
2022        assert!(
2023            manifest
2024                .packages
2025                .iter()
2026                .any(|package| package.crate_name == "greentic-runner"
2027                    && package.bins == ["greentic-runner"])
2028        );
2029        assert_eq!(manifest.extension_packs.as_ref().unwrap().len(), 81);
2030        assert_eq!(manifest.components.as_ref().unwrap().len(), 10);
2031        assert!(
2032            manifest
2033                .extension_packs
2034                .as_ref()
2035                .unwrap()
2036                .iter()
2037                .all(|item| item.version == "1.0.5")
2038        );
2039        assert!(
2040            manifest
2041                .components
2042                .as_ref()
2043                .unwrap()
2044                .iter()
2045                .all(|item| item.version == "1.0.5")
2046        );
2047    }
2048
2049    #[test]
2050    fn generated_manifest_can_use_artifact_resolver_versions() {
2051        let manifest = generate_manifest_with_artifact_resolver(
2052            "1.0.17",
2053            "stable",
2054            None,
2055            &FixedResolver,
2056            &FixedArtifactResolver,
2057            None,
2058        )
2059        .unwrap();
2060
2061        assert!(
2062            manifest
2063                .extension_packs
2064                .as_ref()
2065                .unwrap()
2066                .iter()
2067                .any(|item| item.id == "packs/messaging/messaging-webchat-gui"
2068                    && item.version == "0.4.93")
2069        );
2070        assert!(
2071            manifest
2072                .components
2073                .as_ref()
2074                .unwrap()
2075                .iter()
2076                .any(|item| item.id == "components/component-adaptive-card"
2077                    && item.version == "0.5.8")
2078        );
2079    }
2080
2081    #[test]
2082    fn source_manifest_can_pin_package_versions() {
2083        let source = ToolchainManifest {
2084            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
2085            toolchain: TOOLCHAIN_NAME.to_string(),
2086            version: "latest".to_string(),
2087            channel: Some("latest".to_string()),
2088            created_at: None,
2089            packages: vec![ToolchainPackage {
2090                crate_name: "greentic-dev".to_string(),
2091                bins: vec!["greentic-dev".to_string()],
2092                version: "0.5.9".to_string(),
2093            }],
2094            extension_packs: None,
2095            components: None,
2096        };
2097        let manifest =
2098            generate_manifest("1.0.5", "latest", Some(&source), &FixedResolver, None).unwrap();
2099        let greentic_dev = manifest
2100            .packages
2101            .iter()
2102            .find(|package| package.crate_name == "greentic-dev")
2103            .unwrap();
2104        assert_eq!(greentic_dev.version, "0.5.9");
2105    }
2106
2107    #[test]
2108    fn from_argument_controls_generated_channel_over_source_manifest() {
2109        let source = ToolchainManifest {
2110            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
2111            toolchain: TOOLCHAIN_NAME.to_string(),
2112            version: "latest".to_string(),
2113            channel: Some("stable".to_string()),
2114            created_at: None,
2115            packages: Vec::new(),
2116            extension_packs: None,
2117            components: None,
2118        };
2119        let manifest =
2120            generate_manifest("1.0.16", "dev", Some(&source), &FixedResolver, None).unwrap();
2121        assert_eq!(manifest.channel.as_deref(), Some("dev"));
2122        assert_eq!(manifest_file_name(&manifest), "gtc-dev-1.0.16.json");
2123    }
2124
2125    #[test]
2126    fn generate_from_dev_uses_dev_crate_and_binary_names() {
2127        let manifest = generate_manifest("1.0.16", "dev", None, &FixedResolver, None).unwrap();
2128        assert!(
2129            manifest
2130                .packages
2131                .iter()
2132                .flat_map(|package| package.bins.iter())
2133                .all(|bin| bin.ends_with("-dev"))
2134        );
2135        assert!(
2136            manifest
2137                .packages
2138                .iter()
2139                .all(|package| package.crate_name.ends_with("-dev")),
2140            "dev manifest must pin -dev crate names so binstall resolves the dev mirror"
2141        );
2142        assert!(manifest.packages.iter().any(|package| {
2143            package.crate_name == "greentic-flow-dev" && package.bins == ["greentic-flow-dev"]
2144        }));
2145        assert!(manifest.packages.iter().any(|package| {
2146            package.crate_name == "greentic-component-dev"
2147                && package.bins == ["greentic-component-dev"]
2148        }));
2149        assert!(manifest.packages.iter().any(|package| {
2150            package.crate_name == "greentic-dev-dev" && package.bins == ["greentic-dev-dev"]
2151        }));
2152    }
2153
2154    #[test]
2155    fn snapshot_manifest_dev_channel_resolves_dev_aliases() {
2156        let manifest =
2157            snapshot_manifest("1.1.5", ToolchainChannel::Development, &FixedResolver, None)
2158                .unwrap();
2159        assert_eq!(manifest.version, "1.1.5");
2160        assert_eq!(manifest.channel.as_deref(), Some("dev"));
2161        for package in &manifest.packages {
2162            assert!(
2163                package.crate_name.ends_with("-dev"),
2164                "dev snapshot must pin -dev crate names; got {}",
2165                package.crate_name
2166            );
2167            assert!(
2168                package.bins.iter().all(|bin| bin.ends_with("-dev")),
2169                "dev snapshot must pin -dev bin names; got {:?}",
2170                package.bins
2171            );
2172            assert_ne!(
2173                package.version, "latest",
2174                "snapshot must always resolve concrete versions"
2175            );
2176        }
2177        assert!(
2178            manifest
2179                .packages
2180                .iter()
2181                .any(|package| package.crate_name == "greentic-operator-dev")
2182        );
2183    }
2184
2185    #[test]
2186    fn snapshot_manifest_stable_channel_keeps_plain_names() {
2187        let manifest =
2188            snapshot_manifest("1.0.20", ToolchainChannel::Stable, &FixedResolver, None).unwrap();
2189        assert_eq!(manifest.channel.as_deref(), Some("stable"));
2190        // The stable channel must NOT apply the `-dev` suffix transform.
2191        // Cross-check against the catalogue: every stable package must match
2192        // a catalogue entry by exact name (no transform applied).
2193        let catalogue_names: std::collections::BTreeSet<_> = GREENTIC_TOOLCHAIN_PACKAGES
2194            .iter()
2195            .map(|spec| spec.crate_name)
2196            .collect();
2197        for package in &manifest.packages {
2198            assert!(
2199                catalogue_names.contains(package.crate_name.as_str()),
2200                "stable snapshot crate `{}` was transformed; expected a verbatim catalogue entry",
2201                package.crate_name
2202            );
2203        }
2204    }
2205
2206    #[test]
2207    fn snapshot_manifest_resolves_via_resolver() {
2208        let manifest =
2209            snapshot_manifest("1.1.6", ToolchainChannel::Development, &FixedResolver, None)
2210                .unwrap();
2211        // FixedResolver returns 1.2.3 for everything except `greentic-runner`.
2212        // The dev channel queries `greentic-runner-dev`, not `greentic-runner`,
2213        // so the special case in FixedResolver does not apply and every
2214        // package should land on the default 1.2.3 — proving the resolver was
2215        // hit (rather than versions copied from somewhere).
2216        for package in &manifest.packages {
2217            assert_eq!(
2218                package.version, "1.2.3",
2219                "resolver must be hit for {}",
2220                package.crate_name
2221            );
2222        }
2223    }
2224
2225    #[test]
2226    fn parses_dev_channel_argument() {
2227        assert_eq!(parse_channel("dev").unwrap(), ToolchainChannel::Development);
2228        assert_eq!(
2229            parse_channel("development").unwrap(),
2230            ToolchainChannel::Development
2231        );
2232        assert_eq!(parse_channel("stable").unwrap(), ToolchainChannel::Stable);
2233        assert!(parse_channel("rc").is_err());
2234    }
2235
2236    /// The dev channel must stay inside its own minor line.
2237    ///
2238    /// greentic uses 1.2.x for the dev lane and 1.3.x for research. Picking the
2239    /// highest version overall makes an ABANDONED research build outrank an
2240    /// active dev one: `greentic-setup-dev` published 1.3.29488015798 in July
2241    /// and nothing since, while the dev lane kept shipping 1.2.<run_id>. Every
2242    /// dev manifest generated after that pinned the July build, which is how
2243    /// the dev channel silently froze.
2244    #[test]
2245    fn the_dev_lane_ignores_a_higher_research_minor() {
2246        let body = r#"{"versions":[
2247            {"num":"1.2.32329835532","yanked":false},
2248            {"num":"1.2.32374877786","yanked":false},
2249            {"num":"1.3.29293243074","yanked":false},
2250            {"num":"1.3.29488015798","yanked":false}
2251        ]}"#;
2252
2253        assert_eq!(
2254            pick_highest_in_lane("greentic-setup-dev", body, (1, 2)).unwrap(),
2255            "1.2.32374877786",
2256            "the newest 1.2 build must win over any 1.3"
2257        );
2258        assert_eq!(
2259            pick_highest_in_lane("greentic-setup-dev", body, (1, 3)).unwrap(),
2260            "1.3.29488015798",
2261            "asking for the 1.3 lane still resolves inside 1.3"
2262        );
2263    }
2264
2265    /// A yanked build must never be pinned, lane or not.
2266    #[test]
2267    fn a_yanked_build_is_not_pinned_in_lane() {
2268        let body = r#"{"versions":[
2269            {"num":"1.2.100","yanked":false},
2270            {"num":"1.2.200","yanked":true}
2271        ]}"#;
2272        assert_eq!(pick_highest_in_lane("c", body, (1, 2)).unwrap(), "1.2.100");
2273    }
2274
2275    /// A crate with nothing in the lane is an error the caller can report,
2276    /// not a silent fall back to another lane — falling back is the bug.
2277    #[test]
2278    fn an_empty_lane_is_an_error_not_a_fallback() {
2279        let body = r#"{"versions":[{"num":"1.3.5","yanked":false}]}"#;
2280        let err = pick_highest_in_lane("c", body, (1, 2)).unwrap_err();
2281        assert!(
2282            err.to_string().contains("1.2"),
2283            "the error must name the lane it searched; got {err}"
2284        );
2285    }
2286
2287    /// `--release 1.2.1` means the 1.2 lane.
2288    #[test]
2289    fn the_lane_comes_from_the_release_being_generated() {
2290        assert_eq!(lane_of("1.2.1"), Some((1, 2)));
2291        assert_eq!(lane_of("1.2.32374413367"), Some((1, 2)));
2292        assert_eq!(lane_of("nonsense"), None);
2293    }
2294
2295    #[test]
2296    fn detects_concrete_pins_for_publish_deprecation_warning() {
2297        let with_pins = ToolchainManifest {
2298            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
2299            toolchain: TOOLCHAIN_NAME.to_string(),
2300            version: "0.0.1".to_string(),
2301            channel: Some("dev".to_string()),
2302            created_at: None,
2303            packages: vec![ToolchainPackage {
2304                crate_name: "greentic-operator-dev".to_string(),
2305                bins: vec!["greentic-operator-dev".to_string()],
2306                version: "0.5.123".to_string(),
2307            }],
2308            extension_packs: None,
2309            components: None,
2310        };
2311        assert!(source_manifest_has_concrete_pins(&with_pins));
2312
2313        let only_latest = ToolchainManifest {
2314            packages: vec![ToolchainPackage {
2315                crate_name: "greentic-operator".to_string(),
2316                bins: vec!["greentic-operator".to_string()],
2317                version: "latest".to_string(),
2318            }],
2319            ..with_pins
2320        };
2321        assert!(!source_manifest_has_concrete_pins(&only_latest));
2322    }
2323
2324    #[test]
2325    fn generate_from_rnd_uses_rnd_binary_names() {
2326        let manifest = generate_manifest("1.2.0", "rnd", None, &FixedResolver, None).unwrap();
2327        assert_eq!(manifest.channel.as_deref(), Some("rnd"));
2328        assert!(
2329            manifest
2330                .packages
2331                .iter()
2332                .flat_map(|package| package.bins.iter())
2333                .all(|bin| bin.ends_with("-rnd"))
2334        );
2335        assert!(manifest.packages.iter().any(|package| {
2336            package.crate_name == "greentic-flow" && package.bins == ["greentic-flow-rnd"]
2337        }));
2338    }
2339
2340    #[test]
2341    fn bootstrap_source_manifest_uses_source_tag_identity() {
2342        let manifest = bootstrap_source_manifest("latest", &FixedResolver, None).unwrap();
2343        assert_eq!(manifest.version, "latest");
2344        assert_eq!(manifest.channel.as_deref(), Some("latest"));
2345        assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
2346        assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
2347        assert!(
2348            manifest
2349                .packages
2350                .iter()
2351                .all(|package| package.version != "latest")
2352        );
2353    }
2354
2355    #[test]
2356    fn validates_schema_and_toolchain() {
2357        let mut manifest =
2358            generate_manifest("1.0.5", "latest", None, &FixedResolver, None).unwrap();
2359        assert!(validate_manifest(&manifest).is_ok());
2360        manifest.schema = "wrong".to_string();
2361        assert!(validate_manifest(&manifest).is_err());
2362        manifest.schema = TOOLCHAIN_MANIFEST_SCHEMA.to_string();
2363        manifest.toolchain = "other".to_string();
2364        assert!(validate_manifest(&manifest).is_err());
2365    }
2366
2367    #[test]
2368    fn resolves_inline_registry_token() {
2369        assert_eq!(
2370            resolve_registry_token(Some("secret-token"))
2371                .unwrap()
2372                .as_deref(),
2373            Some("secret-token")
2374        );
2375    }
2376
2377    #[test]
2378    fn resolves_registry_token_from_environment_reference() {
2379        let _guard = ENV_LOCK.lock().unwrap();
2380        let previous = std::env::var("RELEASE_CMD_TEST_TOKEN").ok();
2381        unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", "env-secret") };
2382
2383        let resolved = resolve_registry_token(Some("env:RELEASE_CMD_TEST_TOKEN")).unwrap();
2384        assert_eq!(resolved.as_deref(), Some("env-secret"));
2385
2386        match previous {
2387            Some(value) => unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", value) },
2388            None => unsafe { std::env::remove_var("RELEASE_CMD_TEST_TOKEN") },
2389        }
2390    }
2391
2392    #[test]
2393    fn rejects_empty_registry_token_from_environment_reference() {
2394        let _guard = ENV_LOCK.lock().unwrap();
2395        let previous = std::env::var("RELEASE_CMD_TEST_TOKEN").ok();
2396        unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", "   ") };
2397
2398        let err = resolve_registry_token(Some("env:RELEASE_CMD_TEST_TOKEN")).unwrap_err();
2399        assert!(err.to_string().contains("resolved to an empty token"));
2400
2401        match previous {
2402            Some(value) => unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", value) },
2403            None => unsafe { std::env::remove_var("RELEASE_CMD_TEST_TOKEN") },
2404        }
2405    }
2406
2407    #[test]
2408    fn registry_auth_uses_environment_fallbacks() {
2409        let _guard = ENV_LOCK.lock().unwrap();
2410        let previous_ghcr = std::env::var("GHCR_TOKEN").ok();
2411        let previous_github = std::env::var("GITHUB_TOKEN").ok();
2412        unsafe { std::env::set_var("GHCR_TOKEN", "ghcr-secret") };
2413        unsafe { std::env::remove_var("GITHUB_TOKEN") };
2414
2415        let auth = registry_auth(None).unwrap();
2416        match auth {
2417            RegistryAuth::Basic(user, token) => {
2418                assert_eq!(user, DEFAULT_OAUTH_USER);
2419                assert_eq!(token, "ghcr-secret");
2420            }
2421            _ => panic!("expected basic auth"),
2422        }
2423
2424        match previous_ghcr {
2425            Some(value) => unsafe { std::env::set_var("GHCR_TOKEN", value) },
2426            None => unsafe { std::env::remove_var("GHCR_TOKEN") },
2427        }
2428        match previous_github {
2429            Some(value) => unsafe { std::env::set_var("GITHUB_TOKEN", value) },
2430            None => unsafe { std::env::remove_var("GITHUB_TOKEN") },
2431        }
2432    }
2433
2434    #[test]
2435    fn optional_registry_auth_allows_missing_implicit_token() {
2436        let _guard = ENV_LOCK.lock().unwrap();
2437        let previous_ghcr = std::env::var("GHCR_TOKEN").ok();
2438        let previous_github = std::env::var("GITHUB_TOKEN").ok();
2439        unsafe { std::env::remove_var("GHCR_TOKEN") };
2440        unsafe { std::env::remove_var("GITHUB_TOKEN") };
2441
2442        let auth = optional_registry_auth(None).unwrap();
2443        assert!(matches!(auth, RegistryAuth::Anonymous));
2444
2445        match previous_ghcr {
2446            Some(value) => unsafe { std::env::set_var("GHCR_TOKEN", value) },
2447            None => unsafe { std::env::remove_var("GHCR_TOKEN") },
2448        }
2449        match previous_github {
2450            Some(value) => unsafe { std::env::set_var("GITHUB_TOKEN", value) },
2451            None => unsafe { std::env::remove_var("GITHUB_TOKEN") },
2452        }
2453    }
2454
2455    #[test]
2456    fn release_view_tag_prefers_release_or_tag() {
2457        let args = ReleaseViewArgs {
2458            release: Some("1.0.5".to_string()),
2459            tag: None,
2460            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2461            token: None,
2462        };
2463        assert_eq!(release_view_tag(&args).unwrap(), "1.0.5");
2464
2465        let args = ReleaseViewArgs {
2466            release: None,
2467            tag: Some("stable".to_string()),
2468            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2469            token: None,
2470        };
2471        assert_eq!(release_view_tag(&args).unwrap(), "stable");
2472    }
2473
2474    #[test]
2475    fn release_view_tag_rejects_invalid_argument_combinations() {
2476        let err = release_view_tag(&ReleaseViewArgs {
2477            release: Some("1.0.5".to_string()),
2478            tag: Some("stable".to_string()),
2479            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2480            token: None,
2481        })
2482        .unwrap_err();
2483        assert!(
2484            err.to_string()
2485                .contains("pass exactly one of --release or --tag")
2486        );
2487
2488        let err = release_view_tag(&ReleaseViewArgs {
2489            release: None,
2490            tag: None,
2491            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2492            token: None,
2493        })
2494        .unwrap_err();
2495        assert!(
2496            err.to_string()
2497                .contains("pass exactly one of --release or --tag")
2498        );
2499    }
2500
2501    #[test]
2502    fn publish_manifest_input_uses_local_manifest_version() {
2503        let dir = tempfile::tempdir().unwrap();
2504        let path = dir.path().join("gtc-1.0.12.json");
2505        let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
2506        fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
2507        let args = ReleasePublishArgs {
2508            release: None,
2509            from: None,
2510            tag: Some("stable".to_string()),
2511            manifest: Some(path.clone()),
2512            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2513            token: None,
2514            out: dir.path().to_path_buf(),
2515            dry_run: true,
2516            force: true,
2517            no_notify_updater: true,
2518        };
2519        let (release, loaded, source_path) = publish_manifest_input(&args).unwrap();
2520        assert_eq!(release, "1.0.12");
2521        assert_eq!(loaded, manifest);
2522        assert_eq!(
2523            source_path,
2524            Some(PublishManifestSource::Local(path.clone()))
2525        );
2526    }
2527
2528    #[test]
2529    fn publish_manifest_input_allows_release_override_for_local_manifest() {
2530        let dir = tempfile::tempdir().unwrap();
2531        let path = dir.path().join("gtc-1.0.13.json");
2532        let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
2533        fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
2534        let args = ReleasePublishArgs {
2535            release: Some("1.0.13".to_string()),
2536            from: None,
2537            tag: Some("stable".to_string()),
2538            manifest: Some(path.clone()),
2539            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2540            token: None,
2541            out: dir.path().to_path_buf(),
2542            dry_run: true,
2543            force: true,
2544            no_notify_updater: true,
2545        };
2546        let (release, loaded, source_path) = publish_manifest_input(&args).unwrap();
2547        assert_eq!(release, "1.0.13");
2548        assert_eq!(loaded.version, "1.0.13");
2549        assert_eq!(
2550            source_path,
2551            Some(PublishManifestSource::Local(path.clone()))
2552        );
2553    }
2554
2555    #[test]
2556    fn manifest_file_name_omits_stable_channel() {
2557        let manifest = ToolchainManifest {
2558            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
2559            toolchain: TOOLCHAIN_NAME.to_string(),
2560            version: "1.0.12".to_string(),
2561            channel: Some("stable".to_string()),
2562            created_at: None,
2563            packages: Vec::new(),
2564            extension_packs: None,
2565            components: None,
2566        };
2567        assert_eq!(manifest_file_name(&manifest), "gtc-1.0.12.json");
2568    }
2569
2570    #[test]
2571    fn parses_manifest_without_extension_sections() {
2572        let manifest: ToolchainManifest = serde_json::from_str(
2573            r#"{
2574              "schema": "greentic.toolchain-manifest.v1",
2575              "toolchain": "gtc",
2576              "version": "1.0.16",
2577              "channel": "stable",
2578              "packages": []
2579            }"#,
2580        )
2581        .unwrap();
2582
2583        assert_eq!(manifest.extension_packs, None);
2584        assert_eq!(manifest.components, None);
2585    }
2586
2587    #[test]
2588    fn generated_manifest_includes_catalogue_extension_sections() {
2589        let manifest = generate_manifest("1.0.16", "stable", None, &FixedResolver, None).unwrap();
2590        let json = serde_json::to_value(&manifest).unwrap();
2591
2592        assert!(json.get("extension_packs").is_some());
2593        assert!(json.get("components").is_some());
2594        assert!(
2595            manifest
2596                .extension_packs
2597                .as_ref()
2598                .unwrap()
2599                .iter()
2600                .any(|item| item.id == "packs/messaging/messaging-webchat-gui"
2601                    && item.version == "1.0.16")
2602        );
2603        assert!(
2604            manifest
2605                .extension_packs
2606                .as_ref()
2607                .unwrap()
2608                .iter()
2609                .any(|item| item.id == "packs/deployer/greentic.deploy.aws"
2610                    && item.version == "1.0.16")
2611        );
2612        assert!(
2613            manifest
2614                .components
2615                .as_ref()
2616                .unwrap()
2617                .iter()
2618                .any(|item| item.id == "component/component-llm-openai"
2619                    && item.version == "1.0.16")
2620        );
2621    }
2622
2623    #[test]
2624    fn generated_manifest_preserves_source_versions_for_tracked_extension_sections() {
2625        let source = ToolchainManifest {
2626            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
2627            toolchain: TOOLCHAIN_NAME.to_string(),
2628            version: "dev".to_string(),
2629            channel: Some("dev".to_string()),
2630            created_at: None,
2631            packages: Vec::new(),
2632            extension_packs: Some(vec![ExtensionPackRef {
2633                id: "packs/messaging/messaging-webchat-gui".to_string(),
2634                version: "0.5.4".to_string(),
2635            }]),
2636            components: Some(vec![ComponentRef {
2637                id: "components/component-adaptive-card".to_string(),
2638                version: "0.5.8".to_string(),
2639            }]),
2640        };
2641
2642        let manifest =
2643            generate_manifest("1.0.16", "stable", Some(&source), &FixedResolver, None).unwrap();
2644
2645        assert!(
2646            manifest
2647                .extension_packs
2648                .as_ref()
2649                .unwrap()
2650                .iter()
2651                .any(|item| item.id == "packs/messaging/messaging-webchat-gui"
2652                    && item.version == "0.5.4")
2653        );
2654        assert!(
2655            manifest
2656                .components
2657                .as_ref()
2658                .unwrap()
2659                .iter()
2660                .any(|item| item.id == "components/component-adaptive-card"
2661                    && item.version == "0.5.8")
2662        );
2663    }
2664
2665    #[test]
2666    fn manifest_file_name_includes_non_stable_channel() {
2667        let mut manifest = generate_manifest("1.0.12", "dev", None, &FixedResolver, None).unwrap();
2668        assert_eq!(manifest_file_name(&manifest), "gtc-dev-1.0.12.json");
2669
2670        manifest.channel = Some("customer-a".to_string());
2671        assert_eq!(manifest_file_name(&manifest), "gtc-customer-a-1.0.12.json");
2672    }
2673
2674    #[test]
2675    fn manifest_helpers_only_apply_dev_suffix_for_dev_channel() {
2676        assert_eq!(
2677            manifest_bins_for_source("latest", &["greentic-dev", "greentic-runner"]),
2678            vec!["greentic-dev".to_string(), "greentic-runner".to_string()]
2679        );
2680        assert_eq!(
2681            manifest_bins_for_source("dev", &["greentic-dev"]),
2682            vec!["greentic-dev-dev".to_string()]
2683        );
2684        assert_eq!(
2685            manifest_crate_name_for_source("latest", "greentic-runner"),
2686            "greentic-runner"
2687        );
2688        assert_eq!(
2689            manifest_crate_name_for_source("dev", "greentic-runner"),
2690            "greentic-runner-dev"
2691        );
2692    }
2693
2694    #[test]
2695    fn source_version_map_handles_missing_and_present_sources() {
2696        assert!(source_version_map(None).is_empty());
2697
2698        let source = ToolchainManifest {
2699            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
2700            toolchain: TOOLCHAIN_NAME.to_string(),
2701            version: "latest".to_string(),
2702            channel: Some("latest".to_string()),
2703            created_at: None,
2704            packages: vec![ToolchainPackage {
2705                crate_name: "greentic-dev".to_string(),
2706                bins: vec!["greentic-dev".to_string()],
2707                version: "0.6.0".to_string(),
2708            }],
2709            extension_packs: None,
2710            components: None,
2711        };
2712
2713        let versions = source_version_map(Some(&source));
2714        assert_eq!(
2715            versions.get("greentic-dev").map(String::as_str),
2716            Some("0.6.0")
2717        );
2718    }
2719
2720    #[test]
2721    fn write_manifest_persists_json_to_expected_file_name() {
2722        let dir = tempfile::tempdir().unwrap();
2723        let manifest = generate_manifest("1.0.12", "dev", None, &FixedResolver, None).unwrap();
2724
2725        let path = write_manifest(dir.path(), &manifest).unwrap();
2726        assert_eq!(
2727            path.file_name().and_then(|name| name.to_str()),
2728            Some("gtc-dev-1.0.12.json")
2729        );
2730
2731        let roundtrip = read_manifest_file(&path).unwrap();
2732        assert_eq!(roundtrip, manifest);
2733    }
2734
2735    #[test]
2736    fn latest_manifest_uses_latest_dev_bins() {
2737        let manifest = latest_manifest(None);
2738        assert_eq!(manifest.version, "latest");
2739        assert_eq!(manifest.channel.as_deref(), Some("latest"));
2740        assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
2741        assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
2742        assert!(!manifest.packages.is_empty());
2743        assert!(
2744            manifest
2745                .packages
2746                .iter()
2747                .all(|package| package.version == "latest")
2748        );
2749        assert!(
2750            manifest
2751                .packages
2752                .iter()
2753                .flat_map(|package| package.bins.iter())
2754                .all(|bin| bin.ends_with("-dev"))
2755        );
2756        assert!(
2757            manifest
2758                .packages
2759                .iter()
2760                .all(|package| package.crate_name.ends_with("-dev")),
2761            "latest-channel manifest mirrors dev binaries, so crate names must be -dev too"
2762        );
2763        assert!(
2764            manifest
2765                .packages
2766                .iter()
2767                .any(|package| { package.crate_name == "gtc-dev" && package.bins == ["gtc-dev"] })
2768        );
2769        assert!(manifest.packages.iter().any(|package| {
2770            package.crate_name == "greentic-dev-dev" && package.bins == ["greentic-dev-dev"]
2771        }));
2772        assert!(
2773            manifest
2774                .extension_packs
2775                .as_ref()
2776                .unwrap()
2777                .iter()
2778                .all(|item| item.version == "latest")
2779        );
2780        assert!(
2781            manifest
2782                .components
2783                .as_ref()
2784                .unwrap()
2785                .iter()
2786                .all(|item| item.version == "latest")
2787        );
2788    }
2789
2790    #[test]
2791    fn publish_dry_run_with_local_manifest_succeeds() {
2792        let dir = tempfile::tempdir().unwrap();
2793        let path = dir.path().join("gtc-1.0.12.json");
2794        let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
2795        fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
2796
2797        // `--tag stable` puts this dry run inside the stable gate, so it needs a
2798        // checker; a live one would reach for github.com from a unit test.
2799        let checker = StubReleaseChecker::complete_for(&manifest);
2800        publish_with_checker(
2801            ReleasePublishArgs {
2802                release: None,
2803                from: None,
2804                tag: Some("stable".to_string()),
2805                manifest: Some(path),
2806                repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2807                token: None,
2808                out: dir.path().to_path_buf(),
2809                dry_run: true,
2810                force: false,
2811                no_notify_updater: true,
2812            },
2813            &checker,
2814        )
2815        .unwrap();
2816    }
2817
2818    // -----------------------------------------------------------------------
2819    // verify_manifest_releases — stable-lane release gate
2820    // -----------------------------------------------------------------------
2821
2822    /// Maps `"<crate>@<tag>"` to that release's asset names. An absent key means
2823    /// the release does not exist.
2824    struct StubReleaseChecker(BTreeMap<String, Vec<String>>);
2825
2826    impl ReleaseAssetChecker for StubReleaseChecker {
2827        fn release_assets(&self, repo: &str, tag: &str) -> Result<Option<Vec<String>>> {
2828            Ok(self.0.get(&format!("{repo}@{tag}")).cloned())
2829        }
2830    }
2831
2832    impl StubReleaseChecker {
2833        /// Every package in `manifest` present with a complete asset set.
2834        fn complete_for(manifest: &ToolchainManifest) -> Self {
2835            Self(
2836                manifest
2837                    .packages
2838                    .iter()
2839                    .map(|package| {
2840                        (
2841                            format!("{}@v{}", package.crate_name, package.version),
2842                            complete_assets(&package.crate_name, &package.version),
2843                        )
2844                    })
2845                    .collect(),
2846            )
2847        }
2848
2849        fn with(entries: &[(&str, Vec<String>)]) -> Self {
2850            Self(
2851                entries
2852                    .iter()
2853                    .map(|(key, assets)| ((*key).to_string(), assets.clone()))
2854                    .collect(),
2855            )
2856        }
2857    }
2858
2859    /// One archive plus its checksum — the shape `ensure-release` ends up with.
2860    fn complete_assets(crate_name: &str, version: &str) -> Vec<String> {
2861        let archive = format!("{crate_name}-v{version}-x86_64-unknown-linux-gnu.tgz");
2862        vec![format!("{archive}.sha256"), archive]
2863    }
2864
2865    fn manifest_with_channel(
2866        channel: Option<&str>,
2867        packages: &[(&str, &str)],
2868    ) -> ToolchainManifest {
2869        ToolchainManifest {
2870            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
2871            toolchain: TOOLCHAIN_NAME.to_string(),
2872            version: "1.1.13".to_string(),
2873            channel: channel.map(str::to_string),
2874            created_at: None,
2875            packages: packages
2876                .iter()
2877                .map(|(crate_name, version)| ToolchainPackage {
2878                    crate_name: (*crate_name).to_string(),
2879                    bins: vec![(*crate_name).to_string()],
2880                    version: (*version).to_string(),
2881                })
2882                .collect(),
2883            extension_packs: None,
2884            components: None,
2885        }
2886    }
2887
2888    #[test]
2889    fn release_gate_skips_dev_channel() {
2890        let manifest = manifest_with_channel(Some("dev"), &[("greentic-setup", "1.2.30516109579")]);
2891        // Empty checker: every release is "missing", so a firing gate would fail.
2892        verify_manifest_releases(&manifest, Some("dev"), &StubReleaseChecker::with(&[])).unwrap();
2893    }
2894
2895    #[test]
2896    fn release_gate_skips_manifest_without_channel_or_stable_tag() {
2897        let manifest = manifest_with_channel(None, &[("greentic-setup", "1.1.31")]);
2898        verify_manifest_releases(&manifest, None, &StubReleaseChecker::with(&[])).unwrap();
2899    }
2900
2901    #[test]
2902    fn release_gate_accepts_complete_stable_releases() {
2903        let manifest = manifest_with_channel(
2904            Some("stable"),
2905            &[("greentic-setup", "1.1.31"), ("greentic-start", "1.1.38")],
2906        );
2907        let checker = StubReleaseChecker::complete_for(&manifest);
2908        verify_manifest_releases(&manifest, Some("stable"), &checker).unwrap();
2909    }
2910
2911    /// The regression this gate exists for: greentic-setup 1.1.31 was pinned
2912    /// while its release build was still running, so `:stable` moved onto a
2913    /// binary nobody could download.
2914    #[test]
2915    fn release_gate_rejects_pin_whose_release_does_not_exist() {
2916        let manifest = manifest_with_channel(
2917            Some("stable"),
2918            &[("greentic-setup", "1.1.31"), ("greentic-start", "1.1.38")],
2919        );
2920        let checker = StubReleaseChecker::with(&[(
2921            "greentic-start@v1.1.38",
2922            complete_assets("greentic-start", "1.1.38"),
2923        )]);
2924        let error = verify_manifest_releases(&manifest, Some("stable"), &checker)
2925            .unwrap_err()
2926            .to_string();
2927        assert!(error.contains("greentic-setup v1.1.31"), "{error}");
2928        assert!(error.contains("no GitHub release"), "{error}");
2929        assert!(!error.contains("greentic-start"), "{error}");
2930    }
2931
2932    /// A `--from latest` manifest records `channel: "latest"`, yet `--tag stable`
2933    /// still moves the tag `gtc install` resolves. It must be gated.
2934    #[test]
2935    fn release_gate_fires_on_stable_tag_despite_latest_channel() {
2936        let manifest = manifest_with_channel(Some("latest"), &[("greentic-setup", "1.1.31")]);
2937        let error =
2938            verify_manifest_releases(&manifest, Some("stable"), &StubReleaseChecker::with(&[]))
2939                .unwrap_err()
2940                .to_string();
2941        assert!(error.contains("greentic-setup v1.1.31"), "{error}");
2942    }
2943
2944    #[test]
2945    fn release_gate_rejects_release_with_no_archives_yet() {
2946        let manifest = manifest_with_channel(Some("stable"), &[("greentic-setup", "1.1.31")]);
2947        let checker = StubReleaseChecker::with(&[("greentic-setup@v1.1.31", Vec::new())]);
2948        let error = verify_manifest_releases(&manifest, Some("stable"), &checker)
2949            .unwrap_err()
2950            .to_string();
2951        assert!(error.contains("no v1.1.31 archives yet"), "{error}");
2952    }
2953
2954    #[test]
2955    fn release_gate_rejects_archive_missing_its_checksum() {
2956        let manifest = manifest_with_channel(Some("stable"), &[("greentic-setup", "1.1.31")]);
2957        let checker = StubReleaseChecker::with(&[(
2958            "greentic-setup@v1.1.31",
2959            vec![
2960                "greentic-setup-v1.1.31-x86_64-unknown-linux-gnu.tgz".to_string(),
2961                "greentic-setup-v1.1.31-x86_64-unknown-linux-gnu.tgz.sha256".to_string(),
2962                "greentic-setup-v1.1.31-aarch64-apple-darwin.tgz".to_string(),
2963            ],
2964        )]);
2965        let error = verify_manifest_releases(&manifest, Some("stable"), &checker)
2966            .unwrap_err()
2967            .to_string();
2968        assert!(error.contains("missing .sha256"), "{error}");
2969        assert!(error.contains("aarch64-apple-darwin"), "{error}");
2970    }
2971
2972    /// Multi-binary repos (greentic-mcp ships two) attach several archives per
2973    /// target; the rule is per-archive, not a fixed asset count.
2974    #[test]
2975    fn release_gate_accepts_multi_binary_release() {
2976        let manifest = manifest_with_channel(Some("stable"), &[("greentic-mcp", "1.1.1")]);
2977        let checker = StubReleaseChecker::with(&[(
2978            "greentic-mcp@v1.1.1",
2979            vec![
2980                "greentic-mcp-v1.1.1-x86_64-unknown-linux-gnu.tgz".to_string(),
2981                "greentic-mcp-v1.1.1-x86_64-unknown-linux-gnu.tgz.sha256".to_string(),
2982                "greentic-mcp-generator-v1.1.1-x86_64-pc-windows-msvc.zip".to_string(),
2983                "greentic-mcp-generator-v1.1.1-x86_64-pc-windows-msvc.zip.sha256".to_string(),
2984            ],
2985        )]);
2986        verify_manifest_releases(&manifest, Some("stable"), &checker).unwrap();
2987    }
2988
2989    /// `snapshot --channel stable` passes no `--tag`, so the channel half of the
2990    /// predicate is what gates it. crates.io presence does not imply a finished
2991    /// release: greentic-pack publishes crates on its own `push: tags` trigger,
2992    /// independent of the release job.
2993    #[test]
2994    fn release_gate_fires_on_stable_channel_without_a_target_tag() {
2995        let manifest = manifest_with_channel(Some("stable"), &[("greentic-pack", "1.1.5")]);
2996        let error = verify_manifest_releases(&manifest, None, &StubReleaseChecker::with(&[]))
2997            .unwrap_err()
2998            .to_string();
2999        assert!(error.contains("greentic-pack v1.1.5"), "{error}");
3000        assert!(error.contains("no GitHub release"), "{error}");
3001    }
3002
3003    /// greentic-pack attaches unversioned `greentic-pack-<target>.tgz` binstall
3004    /// aliases with no checksums next to the canonical versioned set. Requiring
3005    /// a `.sha256` for every archive rejected every real pack release.
3006    #[test]
3007    fn release_gate_ignores_unversioned_binstall_aliases() {
3008        let manifest = manifest_with_channel(Some("stable"), &[("greentic-pack", "1.1.5")]);
3009        let checker = StubReleaseChecker::with(&[(
3010            "greentic-pack@v1.1.5",
3011            vec![
3012                "greentic-pack-v1.1.5-x86_64-unknown-linux-gnu.tgz".to_string(),
3013                "greentic-pack-v1.1.5-x86_64-unknown-linux-gnu.tgz.sha256".to_string(),
3014                // Alias, no checksum — must not be read as an unfinished upload.
3015                "greentic-pack-x86_64-unknown-linux-gnu.tgz".to_string(),
3016            ],
3017        )]);
3018        verify_manifest_releases(&manifest, Some("stable"), &checker).unwrap();
3019    }
3020
3021    /// ...but aliases alone are not a usable release: binstall resolves the
3022    /// versioned names.
3023    #[test]
3024    fn release_gate_rejects_release_with_only_unversioned_aliases() {
3025        let manifest = manifest_with_channel(Some("stable"), &[("greentic-pack", "1.1.5")]);
3026        let checker = StubReleaseChecker::with(&[(
3027            "greentic-pack@v1.1.5",
3028            vec!["greentic-pack-x86_64-unknown-linux-gnu.tgz".to_string()],
3029        )]);
3030        let error = verify_manifest_releases(&manifest, Some("stable"), &checker)
3031            .unwrap_err()
3032            .to_string();
3033        assert!(error.contains("no v1.1.5 archives yet"), "{error}");
3034    }
3035
3036    #[test]
3037    fn release_response_404_means_absent() {
3038        assert_eq!(
3039            classify_release_response(reqwest::StatusCode::NOT_FOUND, "url", "{}".to_string())
3040                .unwrap(),
3041            None
3042        );
3043    }
3044
3045    #[test]
3046    fn release_response_success_returns_body() {
3047        assert_eq!(
3048            classify_release_response(reqwest::StatusCode::OK, "url", "{}".to_string()).unwrap(),
3049            Some("{}".to_string())
3050        );
3051    }
3052
3053    #[test]
3054    fn release_response_server_error_is_fatal() {
3055        // A 5xx must never be mistaken for "release absent" — that would let a
3056        // GitHub outage wave a bad manifest straight through the gate.
3057        assert!(
3058            classify_release_response(
3059                reqwest::StatusCode::INTERNAL_SERVER_ERROR,
3060                "url",
3061                "boom".to_string()
3062            )
3063            .is_err()
3064        );
3065    }
3066
3067    #[test]
3068    fn latest_dry_run_succeeds() {
3069        latest(ReleaseLatestArgs {
3070            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
3071            token: None,
3072            dry_run: true,
3073            force: false,
3074        })
3075        .unwrap();
3076    }
3077
3078    #[test]
3079    fn promote_dry_run_succeeds() {
3080        promote(ReleasePromoteArgs {
3081            release: "1.0.12".to_string(),
3082            tag: "stable".to_string(),
3083            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
3084            token: None,
3085            dry_run: true,
3086            no_notify_updater: true,
3087        })
3088        .unwrap();
3089    }
3090
3091    #[test]
3092    fn builds_toolchain_ref() {
3093        assert_eq!(
3094            toolchain_ref("ghcr.io/greenticai/greentic-versions/gtc", "stable"),
3095            "ghcr.io/greenticai/greentic-versions/gtc:stable"
3096        );
3097    }
3098
3099    // -----------------------------------------------------------------------
3100    // should_notify_updater — stable-lane gate tests
3101    // -----------------------------------------------------------------------
3102
3103    #[test]
3104    fn notify_gate_accepts_stable_plain_version() {
3105        assert!(should_notify_updater("1.1.2", "stable"));
3106    }
3107
3108    #[test]
3109    fn notify_gate_accepts_stable_zero_version() {
3110        assert!(should_notify_updater("0.1.0", "stable"));
3111    }
3112
3113    #[test]
3114    fn notify_gate_rejects_dev_channel() {
3115        assert!(!should_notify_updater("1.1.2", "dev"));
3116    }
3117
3118    #[test]
3119    fn notify_gate_rejects_rnd_channel() {
3120        assert!(!should_notify_updater("1.1.2", "rnd"));
3121    }
3122
3123    #[test]
3124    fn notify_gate_rejects_prerelease_version() {
3125        assert!(!should_notify_updater("1.2.0-dev.3", "stable"));
3126    }
3127
3128    #[test]
3129    fn notify_gate_rejects_run_id_version_on_dev_channel() {
3130        // Run-id versions (e.g. 1.1.14995680637) are dev-lane artifacts and
3131        // always carry channel "dev". The channel check catches them.
3132        assert!(!should_notify_updater("1.1.14995680637", "dev"));
3133    }
3134
3135    #[test]
3136    fn notify_gate_rejects_research_prerelease() {
3137        assert!(!should_notify_updater("1.3.0-research.1", "stable"));
3138    }
3139
3140    #[test]
3141    fn notify_gate_rejects_build_metadata() {
3142        assert!(!should_notify_updater("1.1.2+build.42", "stable"));
3143    }
3144
3145    #[test]
3146    fn notify_gate_rejects_empty_channel() {
3147        assert!(!should_notify_updater("1.1.2", ""));
3148    }
3149
3150    #[test]
3151    fn notify_gate_rejects_unparseable_version() {
3152        assert!(!should_notify_updater("not-a-version", "stable"));
3153    }
3154}