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