Skip to main content

anodizer_core/context/
populate.rs

1use super::*;
2
3impl Context {
4    /// Populate template variables from `self.git_info`.
5    ///
6    /// Must be called after `self.git_info` is set. Sets the following vars:
7    /// - `Tag`, `Version`, `RawVersion` — tag and version strings
8    /// - `Major`, `Minor`, `Patch` — semver components
9    /// - `Prerelease` — prerelease suffix (or empty)
10    /// - `BuildMetadata` — build metadata from semver tag (or empty)
11    /// - `FullCommit`, `Commit` — full commit SHA (`Commit` is alias for `FullCommit`)
12    /// - `ShortCommit` — abbreviated commit SHA
13    /// - `Branch` — current git branch
14    /// - `CommitDate` — ISO 8601 author date of HEAD commit
15    /// - `CommitTimestamp` — unix timestamp of HEAD commit
16    /// - `IsGitDirty` — "true"/"false"
17    /// - `IsGitClean` — "true"/"false" (inverse of `IsGitDirty`)
18    /// - `GitTreeState` — "clean"/"dirty"
19    /// - `GitURL` — git remote URL
20    /// - `Summary` — git describe summary
21    /// - `TagSubject` — annotated tag subject or commit subject
22    /// - `TagContents` — full annotated tag message or commit message
23    /// - `TagBody` — tag message body or commit message body
24    /// - `IsSnapshot` — from context options
25    /// - `IsNightly` — from context options
26    /// - `IsDraft` — "false" (stages may override to "true")
27    /// - `IsSingleTarget` — "true"/"false" based on single_target option
28    /// - `PreviousTag` — previous matching tag, stripped in monorepo mode (or empty)
29    /// - `PrefixedTag` — full tag with monorepo prefix, or tag_prefix-prepended (Pro addition)
30    /// - `PrefixedPreviousTag` — full previous tag with prefix (Pro addition)
31    /// - `PrefixedSummary` — full summary with prefix (Pro addition)
32    /// - `IsRelease` — "true" if not snapshot and not nightly (Pro addition)
33    /// - `IsMerging` — "true" if running with --merge flag (Pro addition)
34    ///
35    /// **Stage-scoped variables** (NOT set here; set per-artifact during stage execution):
36    /// - `Binary` — binary name, set by build stage per binary and archive stage per archive
37    /// - `ArtifactName` — output artifact filename, set by archive stage after creating each archive
38    /// - `ArtifactPath` — absolute path to artifact, set by archive stage after creating each archive
39    /// - `ArtifactExt` — artifact file extension (e.g. `.tar.gz`, `.exe`), set alongside ArtifactName
40    /// - `ArtifactID` — build config `id` field, set by build stage per build config
41    /// - `Os` — target OS, set by archive/nfpm stages per target
42    /// - `Arch` — target architecture, set by archive/nfpm stages per target
43    /// - `Target` — full target triple (e.g. `x86_64-unknown-linux-gnu`), set alongside Os/Arch
44    /// - `Checksums` — combined checksum file contents, set by checksum stage
45    pub fn populate_git_vars(&mut self) {
46        if let Some(ref info) = self.git_info {
47            // The version-derived var block (Tag/Version/RawVersion/Base/Major/
48            // Minor/Patch/Prerelease/BuildMetadata) is factored into
49            // `set_version_vars` so `render_template_for_version` can re-derive
50            // the SAME block for a promotion's target version without drift.
51            // Deriving Version/RawVersion from the parsed `SemVer` struct (not
52            // `tag.strip_prefix('v')`) handles monorepo tags like `core-v0.3.2`.
53            set_version_vars(&mut self.template_vars, &info.semver, &info.tag);
54            self.template_vars.set("FullCommit", &info.commit);
55            self.template_vars.set("Commit", &info.commit);
56            self.template_vars.set("ShortCommit", &info.short_commit);
57            self.template_vars.set("Branch", &info.branch);
58            self.template_vars.set("CommitDate", &info.commit_date);
59            self.template_vars
60                .set("CommitTimestamp", &info.commit_timestamp);
61            self.template_vars.set_bool("IsGitDirty", info.dirty);
62            self.template_vars.set_bool("IsGitClean", !info.dirty);
63            self.template_vars
64                .set("GitTreeState", if info.dirty { "dirty" } else { "clean" });
65            self.template_vars.set("GitURL", &info.remote_url);
66            self.template_vars.set("Summary", &info.summary);
67            self.template_vars.set("TagSubject", &info.tag_subject);
68            self.template_vars.set("TagContents", &info.tag_contents);
69            self.template_vars.set("TagBody", &info.tag_body);
70            self.template_vars
71                .set("PreviousTag", info.previous_tag.as_deref().unwrap_or(""));
72            self.template_vars
73                .set("FirstCommit", info.first_commit.as_deref().unwrap_or(""));
74
75            // Pro additions: PrefixedTag, PrefixedPreviousTag, PrefixedSummary
76            //
77            // When monorepo.tag_prefix is configured, the git tag already
78            // contains the prefix (e.g. "subproject1/v1.2.3"). In this case:
79            //   - Tag = prefix stripped (e.g. "v1.2.3")
80            //   - PrefixedTag = full tag (e.g. "subproject1/v1.2.3")
81            //   - PrefixedPreviousTag = full previous tag
82            //
83            // When monorepo is NOT configured, fall back to the original
84            // behavior: prepend tag.tag_prefix to construct PrefixedTag.
85            let monorepo_prefix = self.config.monorepo_tag_prefix();
86
87            // monorepo.tag_prefix takes precedence over tag.tag_prefix for
88            // PrefixedTag / PrefixedPreviousTag / PrefixedSummary behavior.
89            // When monorepo is configured, info.tag and info.summary already
90            // contain the prefix from git, so we strip for the base vars and
91            // use the raw values for the Prefixed variants.
92            if let Some(prefix) = monorepo_prefix {
93                // Monorepo mode: the tag in git_info is the FULL prefixed tag.
94                // PrefixedTag = full tag (already has prefix).
95                self.template_vars.set("PrefixedTag", &info.tag);
96
97                // Tag = prefix stripped. Override the Tag we set above.
98                let stripped_tag = crate::git::strip_monorepo_prefix(&info.tag, prefix);
99                self.template_vars.set("Tag", stripped_tag);
100
101                // Version: derived from the parsed SemVer struct (same source as
102                // the non-monorepo path and the build stage's per-crate
103                // re-scoping) so all three stay byte-identical. `info.semver`
104                // was parsed from the full prefixed tag, so it already excludes
105                // the monorepo prefix — no separate string-strip needed.
106                //
107                // For a non-semver tag under `--skip=validate`, info.semver is
108                // the skip-validate fallback, so this yields "0.0.0" rather than
109                // the old raw prefix-stripped string.
110                let version = info.semver.version_string();
111                self.template_vars.set("Version", &version);
112
113                // PrefixedPreviousTag = full previous tag (already has prefix).
114                let prev_tag = info.previous_tag.as_deref().unwrap_or("");
115                self.template_vars.set("PrefixedPreviousTag", prev_tag);
116
117                // PreviousTag = prefix stripped, consistent with Tag being stripped.
118                let stripped_prev = crate::git::strip_monorepo_prefix(prev_tag, prefix);
119                self.template_vars.set("PreviousTag", stripped_prev);
120
121                // PrefixedSummary: info.summary from `git describe` already
122                // includes the monorepo prefix (e.g. "subproject1/v1.2.3-0-gabc123d"),
123                // so use it as-is for the prefixed variant.
124                self.template_vars.set("PrefixedSummary", &info.summary);
125                // Summary: strip the monorepo prefix for the base variant.
126                let stripped_summary = crate::git::strip_monorepo_prefix(&info.summary, prefix);
127                self.template_vars.set("Summary", stripped_summary);
128            } else {
129                // Non-monorepo: prepend tag.tag_prefix to construct PrefixedTag.
130                let tag_prefix = self
131                    .config
132                    .tag
133                    .as_ref()
134                    .and_then(|t| t.tag_prefix.as_deref())
135                    .unwrap_or("");
136                self.template_vars
137                    .set("PrefixedTag", &format!("{}{}", tag_prefix, info.tag));
138                let prev_tag = info.previous_tag.as_deref().unwrap_or("");
139                let prefixed_prev = if prev_tag.is_empty() {
140                    String::new()
141                } else {
142                    format!("{}{}", tag_prefix, prev_tag)
143                };
144                self.template_vars
145                    .set("PrefixedPreviousTag", &prefixed_prev);
146                self.template_vars.set(
147                    "PrefixedSummary",
148                    &format!("{}{}", tag_prefix, info.summary),
149                );
150            }
151        }
152
153        // `NightlyBuild`: stateless per-base-version build counter derived
154        // from `git rev-list --count <last-tag>..HEAD`. Resets automatically
155        // when a new version tag lands (no state anodizer persists). Set
156        // unconditionally (it is just a count), but intended for nightly /
157        // snapshot `version_template`s such as
158        // `"{{ .Base }}-nightly.{{ .NightlyBuild }}+{{ .ShortCommit }}"`.
159        // Defaults to "0" outside a git repo (synthetic snapshot/scratch
160        // builds) and on any git error so templates never fail to render.
161        //
162        // The monorepo prefix constrains the last-tag lookup to the active
163        // crate's tags so per-crate workspace runs count since the right
164        // tag (not the nearest tag from another subproject).
165        let nightly_build = if self.git_info.is_some() {
166            let root = self
167                .options
168                .project_root
169                .clone()
170                .unwrap_or_else(|| PathBuf::from("."));
171            let monorepo_prefix = self.config.monorepo_tag_prefix();
172            crate::git::count_commits_since_last_tag_in(&root, monorepo_prefix).unwrap_or(0)
173        } else {
174            0
175        };
176        self.template_vars
177            .set_structured("NightlyBuild", serde_json::Value::from(nightly_build));
178
179        // Mode flags are injected as real bools (not "true"/"false" strings)
180        // so `not IsSnapshot` / `IsSnapshot == false` / bare `{% if … %}`
181        // forms all evaluate correctly; `{{ IsSnapshot }}` interpolation
182        // still renders "true"/"false".
183        self.template_vars
184            .set_bool("IsSnapshot", self.options.snapshot);
185        self.template_vars
186            .set_bool("IsNightly", self.options.nightly);
187        // Surfaced to user `if_condition:` templates so stages can
188        // selectively run inside the determinism harness even when
189        // `not IsSnapshot` would otherwise skip them.
190        self.template_vars.set_bool(
191            "IsHarness",
192            self.env_var("ANODIZER_IN_DETERMINISM_HARNESS").is_some(),
193        );
194        // Wire IsDraft from `release.draft`.
195        let is_draft = self
196            .config
197            .release
198            .as_ref()
199            .and_then(|r| r.draft)
200            .unwrap_or(false);
201        self.template_vars.set_bool("IsDraft", is_draft);
202        self.template_vars
203            .set_bool("IsSingleTarget", self.options.single_target.is_some());
204
205        // Pro addition: IsRelease — true if this is a regular release (not snapshot, not nightly).
206        let is_release = !self.options.snapshot && !self.options.nightly;
207        self.template_vars.set_bool("IsRelease", is_release);
208
209        // Pro addition: IsMerging — true if running with --merge flag.
210        self.template_vars.set_bool("IsMerging", self.options.merge);
211    }
212
213    /// Populate time-related template variables.
214    ///
215    /// Sets:
216    /// - `Date` — UTC time as RFC 3339
217    /// - `Timestamp` — unix timestamp as string
218    /// - `Now` — UTC time as RFC 3339
219    /// - `Year` — four-digit year (e.g. "2026")
220    /// - `Month` — zero-padded month (e.g. "03")
221    /// - `Day` — zero-padded day (e.g. "30")
222    /// - `Hour` — zero-padded hour (e.g. "14")
223    /// - `Minute` — zero-padded minute (e.g. "05")
224    ///
225    /// Time source resolution (first match wins):
226    ///
227    /// 1. `SOURCE_DATE_EPOCH` env var — the standard reproducibility contract
228    ///    (set by the determinism harness on every child release subprocess,
229    ///    and the conventional way external CI / packagers signal a fixed
230    ///    epoch). This is load-bearing for byte-stability of `metadata.json`
231    ///    (which embeds `Date`) and any user template that consumes `Date` /
232    ///    `Timestamp` / `Now`. Without this branch, two from-clean runs of
233    ///    the same commit emit metadata.json files that differ in the `date`
234    ///    field, defeating release-asset idempotency.
235    /// 2. `chrono::Utc::now()` — wall-clock fallback. The
236    ///    legacy semantics for runs without SDE wired in. Note that the
237    ///    template docs explicitly call `.Now` "not deterministic"
238    ///    — under SDE-aware reproducible builds we deviate from that
239    ///    behavior intentionally.
240    pub fn populate_time_vars(&mut self) {
241        // Resolution order (SDE first, else wall-clock) is centralized in
242        // `crate::sde::resolve_now_with_env` so any caller —
243        // `populate_time_vars`, Tera built-ins, stage-srpm's `%changelog`
244        // date, nightly `date_str` — sees identical "now" semantics.
245        // Routes through the injected `env_source` so tests can inject
246        // SOURCE_DATE_EPOCH via TestContextBuilder::env() without
247        // mutating the process env.
248        let now = crate::sde::resolve_now_with_env(self.env_source());
249        self.template_vars.set("Date", &now.to_rfc3339());
250        self.template_vars
251            .set("Timestamp", &now.timestamp().to_string());
252        self.template_vars.set("Now", &now.to_rfc3339());
253        self.template_vars
254            .set("Year", &now.format("%Y").to_string());
255        self.template_vars
256            .set("Month", &now.format("%m").to_string());
257        self.template_vars.set("Day", &now.format("%d").to_string());
258        self.template_vars
259            .set("Hour", &now.format("%H").to_string());
260        self.template_vars
261            .set("Minute", &now.format("%M").to_string());
262    }
263
264    /// Populate runtime environment variables.
265    ///
266    /// Sets:
267    /// - `RuntimeGoos` — host OS in Go-compatible naming (e.g. "linux", "darwin", "windows")
268    /// - `RuntimeGoarch` — host architecture in Go-compatible naming (e.g. "amd64", "arm64")
269    /// - `Runtime_Goos` / `Runtime_Goarch` — nested aliases
270    /// - `RustcVersion` — host rustc release version (e.g. "1.96.0"), or "" when
271    ///   rustc is unavailable
272    pub fn populate_runtime_vars(&mut self) {
273        let goos = map_os_to_goos(std::env::consts::OS);
274        let goarch = map_arch_to_goarch(std::env::consts::ARCH);
275        self.template_vars.set("RuntimeGoos", goos);
276        self.template_vars.set("RuntimeGoarch", goarch);
277        // Runtime.Goos / Runtime.Goarch — after preprocessing
278        // the dot becomes an underscore-separated flat key. We expose both forms.
279        self.template_vars.set("Runtime_Goos", goos);
280        self.template_vars.set("Runtime_Goarch", goarch);
281        // RustcVersion is a host-environment fact like OS/arch, so it is set in
282        // the same call — keeping it a separate populate step risks a call-site
283        // forgetting to invoke the sibling.
284        self.populate_rustc_vars();
285    }
286
287    /// Populate the `RustcVersion` built-in template variable.
288    ///
289    /// Probes `rustc -vV` and extracts the `release:` line (e.g. `"1.96.0"`).
290    /// Sets `RustcVersion` to the extracted string, or to `""` when rustc is
291    /// unavailable or the line is absent — templates that reference
292    /// `{{ .RustcVersion }}` degrade to an empty value rather than erroring.
293    fn populate_rustc_vars(&mut self) {
294        let ver = crate::partial::detect_rustc_version().unwrap_or_default();
295        self.template_vars.set("RustcVersion", &ver);
296    }
297
298    /// Populate the `ReleaseNotes` template variable from stored changelogs.
299    ///
300    /// Should be called after the changelog stage has run and populated
301    /// `self.stage_outputs.changelogs`. Uses the first crate (by crate
302    /// universe order — top-level `crates:` then every `workspaces[].crates`
303    /// entry) whose changelog is present, or an empty string if no
304    /// changelogs exist. Universe order is deterministic, unlike HashMap
305    /// iteration order.
306    pub fn populate_release_notes_var(&mut self) {
307        // Look up changelogs in universe order for determinism. The universe
308        // walk (not `config.crates`) is what lets a pure-`workspaces:` config
309        // resolve a non-empty `ReleaseNotes` — its crates carry the
310        // changelogs but never appear in the top-level list.
311        let notes = self
312            .config
313            .crate_universe()
314            .into_iter()
315            .find_map(|c| self.stage_outputs.changelogs.get(&c.name))
316            .cloned()
317            .unwrap_or_default();
318        self.template_vars.set("ReleaseNotes", &notes);
319    }
320
321    /// Refresh the `Artifacts` structured template variable from the current
322    /// artifact registry. Should be called before rendering release body and
323    /// announce templates so they can iterate over all artifacts.
324    ///
325    /// Each artifact is serialized as a map with keys: `name`, `path`, `target`,
326    /// `kind`, `crate_name`, and `metadata`.
327    ///
328    /// **Known metadata keys** (populated by individual stages):
329    /// - `format` — archive format (e.g. `"tar.gz"`, `"zip"`), set by archive stage
330    /// - `extra_file` — `"true"` when artifact is an extra file, set by checksum stage
331    /// - `extra_name_template` — name template override for extra files, set by checksum stage
332    /// - `digest` — docker image digest (e.g. `sha256:abc123...`), set by docker stage
333    /// - `id` — artifact ID from config, set by docker and build stages
334    /// - `binary` — binary name, set by build stage
335    pub fn refresh_artifacts_var(&mut self) {
336        // CSV metadata keys we expose as JSON arrays for template iteration.
337        // Storage remains HashMap<String,String> (flat); only the
338        // template-exposed view is expanded. The
339        // ExtraBinaries / ExtraFiles list semantics.
340        const CSV_LIST_KEYS: &[&str] = &["extra_binaries", "extra_files"];
341        // JSON-encoded list metadata keys: stored as a JSON-array string in
342        // `HashMap<String,String>`, exposed as a real array on the template
343        // side so `{% for p in .Artifacts[0].metadata.Platforms %}` works.
344        // `Platforms` is the platform-list slice on
345        // `DockerImageV2` artifacts.
346        const JSON_LIST_KEYS: &[&str] = &["Platforms"];
347
348        let artifacts_value: Vec<serde_json::Value> = self
349            .artifacts
350            .all()
351            .iter()
352            .map(|a| {
353                // Rebuild metadata map converting known CSV keys into arrays.
354                let mut metadata_map = serde_json::Map::with_capacity(a.metadata.len());
355                for (k, v) in &a.metadata {
356                    if CSV_LIST_KEYS.contains(&k.as_str()) {
357                        let items: Vec<serde_json::Value> = if v.is_empty() {
358                            Vec::new()
359                        } else {
360                            v.split(',')
361                                .map(|s| serde_json::Value::String(s.to_string()))
362                                .collect()
363                        };
364                        metadata_map.insert(k.clone(), serde_json::Value::Array(items));
365                    } else if JSON_LIST_KEYS.contains(&k.as_str()) {
366                        // Decode JSON-array string into a real Value::Array;
367                        // a malformed value falls back to the raw string so
368                        // custom publishers can still inspect it.
369                        let parsed = serde_json::from_str::<serde_json::Value>(v)
370                            .unwrap_or_else(|_| serde_json::Value::String(v.clone()));
371                        metadata_map.insert(k.clone(), parsed);
372                    } else {
373                        metadata_map.insert(k.clone(), serde_json::Value::String(v.clone()));
374                    }
375                }
376                serde_json::json!({
377                    "name": a.name,
378                    "path": a.path.to_string_lossy(),
379                    "target": a.target.as_deref().unwrap_or(""),
380                    "kind": a.kind.as_str(),
381                    "crate_name": a.crate_name,
382                    "metadata": serde_json::Value::Object(metadata_map),
383                })
384            })
385            .collect();
386        self.template_vars
387            .set_structured("Artifacts", serde_json::Value::Array(artifacts_value));
388    }
389
390    /// Populate the `Metadata` structured template variable from config.metadata.
391    ///
392    /// Exposes the project metadata block as a nested map with PascalCase keys
393    /// the `.Metadata.*` namespace:
394    /// `Description`, `Homepage`, `Documentation`, `License`, `Repository`,
395    /// `Maintainers`, `ModTimestamp`, `FullDescription` (resolved),
396    /// `CommitAuthor.{Name,Email}`.
397    /// Missing fields default to empty strings / empty arrays.
398    ///
399    /// `full_description` supports `Inline`, `FromFile` (template-rendered
400    /// path, read from disk), and `FromUrl` (template-rendered URL +
401    /// headers, fetched through [`crate::content_source::resolve`] which
402    /// applies retries, body caps, and CR/LF header-injection guards).
403    pub fn populate_metadata_var(&mut self) -> anyhow::Result<()> {
404        // Clone the small scalar fields so we don't hold a borrow on self.config
405        // across the render_template calls below.
406        let (
407            description,
408            homepage,
409            documentation,
410            license,
411            repository,
412            maintainers,
413            mod_timestamp,
414            full_desc_src,
415            commit_author,
416        ) = {
417            let meta = self.config.metadata.as_ref();
418            // Description / homepage / documentation / license resolve through
419            // the project-level fallback: top-level `metadata.*` wins, else the
420            // primary crate's `Cargo.toml`-derived value. This keeps
421            // `{{ Metadata.* }}` single-sourced with the per-publisher
422            // `meta_*_for` resolvers, so dropping a redundant `metadata.license`
423            // (derivable from Cargo.toml) does not silently empty the var.
424            let description = self
425                .config
426                .meta_description_project()
427                .unwrap_or("")
428                .to_string();
429            let homepage = self
430                .config
431                .meta_homepage_project()
432                .unwrap_or("")
433                .to_string();
434            let documentation = self
435                .config
436                .meta_documentation_project()
437                .unwrap_or("")
438                .to_string();
439            let license = self.config.meta_license_project().unwrap_or("").to_string();
440            let repository = self
441                .config
442                .meta_repository_project()
443                .unwrap_or("")
444                .to_string();
445            let maintainers: Vec<String> = meta
446                .and_then(|m| m.maintainers.as_ref())
447                .cloned()
448                .unwrap_or_default();
449            let mod_timestamp = meta
450                .and_then(|m| m.mod_timestamp.as_deref())
451                .unwrap_or("")
452                .to_string();
453            let full_desc_src = meta.and_then(|m| m.full_description.clone());
454            let commit_author = meta.and_then(|m| m.commit_author.clone());
455            (
456                description,
457                homepage,
458                documentation,
459                license,
460                repository,
461                maintainers,
462                mod_timestamp,
463                full_desc_src,
464                commit_author,
465            )
466        };
467
468        // Resolve full_description through the shared ContentSource resolver
469        // so Inline, FromFile (template-rendered path), and FromUrl
470        // (template-rendered URL + headers, retried HTTP fetch with
471        // body cap and CR/LF guard) all behave the same as the release
472        // header/footer fields.
473        let full_description = match full_desc_src {
474            None => String::new(),
475            Some(src) => crate::content_source::resolve(
476                &src,
477                "metadata.full_description",
478                self,
479                &self.logger("metadata"),
480            )?,
481        };
482
483        let commit_author_map = serde_json::json!({
484            "Name": commit_author.as_ref().and_then(|c| c.name.clone()).unwrap_or_default(),
485            "Email": commit_author.as_ref().and_then(|c| c.email.clone()).unwrap_or_default(),
486        });
487
488        let meta_map = serde_json::json!({
489            "Description": description,
490            "Homepage": homepage,
491            "Documentation": documentation,
492            "License": license,
493            "Repository": repository,
494            "Maintainers": maintainers,
495            "ModTimestamp": mod_timestamp,
496            "FullDescription": full_description,
497            "CommitAuthor": commit_author_map,
498        });
499        self.template_vars.set_structured("Metadata", meta_map);
500        Ok(())
501    }
502}
503
504/// Map Rust's `std::env::consts::OS` to Go-compatible GOOS naming.
505/// Templates expect Go runtime names (e.g. "darwin" not "macos").
506pub fn map_os_to_goos(os: &str) -> &str {
507    match os {
508        "macos" => "darwin",
509        other => other, // linux, windows, freebsd, etc. already match
510    }
511}
512
513/// Map Rust's `std::env::consts::ARCH` to Go-compatible GOARCH naming.
514/// Templates expect Go runtime names (e.g. "amd64" not "x86_64").
515///
516/// Delegates to the shared [`crate::target::rust_arch_to_goarch`] table so a
517/// host-derived `{{ .Runtime.Goarch }}` can never disagree with the
518/// triple-derived arch tokens in asset names. `ARCH` doesn't encode
519/// endianness, so the host's own compile-time endianness disambiguates
520/// `powerpc64`/`mips64`. Tokens outside the table (`arm` — GOARCH really is
521/// "arm" — plus exotics) pass through unchanged.
522pub fn map_arch_to_goarch(arch: &str) -> &str {
523    crate::target::rust_arch_to_goarch(arch, cfg!(target_endian = "little")).unwrap_or(arch)
524}