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