Etapa G Fase 2: tag→SHA — hammer pin (sello inmutable, ADR 0006)

Los imports github salen con commit=tag flotante (v1.1.0); el pin lo resuelve al SHA inmutable
⇒ el laboratorio se vuelve determinista al estilo Nix (origen anclado a un punto fijo).

- hammer-cli: `hammer pin <recipe>` (in-place o --out). Usa `git ls-remote` (host-agnóstico, sin
  API ni tokens ni rate-limits), prefiere el commit dereferenciado `^{}` para tags anotados.
  Reescritura DIRIGIDA de la línea `commit = "<tag>"` (preserva comentarios/formato; no toca
  version u otras que casen). No-op si ya es SHA (idempotente) o tarball (ya anclado por sha256).
  is_git_sha (40 hex sha1 / 64 hex sha256). +1 test.
- scripts/pin-recipes.sh: ancla en lote (recipes/*.toml).
- Validado real: sd v1.1.0 → 4a7b216552d6… (git ls-remote), idempotente. 31 suites verde.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 08:50:14 -04:00
co-authored by Claude Opus 4.8
parent a157b8c455
commit 1180bd9154
2 changed files with 129 additions and 0 deletions
+107
View File
@@ -275,6 +275,16 @@ enum Cmd {
#[arg(long, short)]
out: Option<PathBuf>,
},
/// [Etapa G Fase 2] Ancla el `commit` de una receta git: si es un TAG flotante (v1.0.0),
/// lo resuelve al SHA inmutable vía `git ls-remote` (host-agnóstico, sin API ni tokens) y
/// reescribe la receta. El sello determinista al estilo Nix (ADR 0006). No-op si ya es un SHA.
Pin {
/// Receta a anclar (`recipes/foo.toml`).
recipe: PathBuf,
/// Salida. Sin esto, reescribe la receta IN-PLACE.
#[arg(long, short)]
out: Option<PathBuf>,
},
/// [Etapa F] Inspecciona un repositorio de paquetes.
Repo {
#[command(subcommand)]
@@ -840,6 +850,7 @@ fn main() -> anyhow::Result<()> {
Cmd::Installed { db } => run_installed_list(&db)?,
Cmd::ImportNix { file, out } => run_import_nix(&file, out.as_deref())?,
Cmd::ImportAlpine { file, out } => run_import_alpine(&file, out.as_deref())?,
Cmd::Pin { recipe, out } => run_pin(&recipe, out.as_deref())?,
Cmd::Repo { sub } => match sub {
RepoCmd::List { repo } => run_repo_list(&repo)?,
RepoCmd::Sign { repo, key, by } => run_repo_sign(&repo, &key, by.as_deref())?,
@@ -1633,6 +1644,87 @@ fn run_import_nix(file: &str, out: Option<&std::path::Path>) -> anyhow::Result<(
Ok(())
}
/// `true` si `s` ya es un SHA de git (40 hex sha1 o 64 hex sha256) ⇒ ya está anclado.
fn is_git_sha(s: &str) -> bool {
(s.len() == 40 || s.len() == 64) && s.bytes().all(|b| b.is_ascii_hexdigit())
}
/// Resuelve un tag/ref a su SHA de commit inmutable vía `git ls-remote` (cualquier host git, sin
/// API ni tokens). Para tags ANOTADOS prefiere el commit dereferenciado (`^{}`); para ligeros, el
/// ref directo. Devuelve error si el tag no existe en el remoto.
fn resolve_git_sha(repo: &str, tag: &str) -> anyhow::Result<String> {
let out = std::process::Command::new("git")
.args([
"ls-remote",
repo,
&format!("refs/tags/{tag}"),
&format!("refs/tags/{tag}^{{}}"),
tag,
])
.output()
.map_err(|e| anyhow::anyhow!("git ls-remote: {e}"))?;
if !out.status.success() {
anyhow::bail!("git ls-remote {repo} {tag}: {}", String::from_utf8_lossy(&out.stderr).trim());
}
let text = String::from_utf8_lossy(&out.stdout);
// Líneas `<sha>\t<ref>`. Preferimos la dereferenciada `^{}` (commit real del tag anotado).
let mut plain: Option<String> = None;
for line in text.lines() {
let mut it = line.split('\t');
let (Some(sha), Some(rf)) = (it.next(), it.next()) else { continue };
if rf.ends_with("^{}") {
return Ok(sha.to_string());
}
plain.get_or_insert_with(|| sha.to_string());
}
plain.ok_or_else(|| anyhow::anyhow!("'{tag}' no existe como tag/ref en {repo}"))
}
/// Ancla el `commit` de una receta git: tag flotante → SHA inmutable (Etapa G Fase 2).
fn run_pin(recipe_path: &std::path::Path, out: Option<&std::path::Path>) -> anyhow::Result<()> {
let text = std::fs::read_to_string(recipe_path)
.map_err(|e| anyhow::anyhow!("leyendo {}: {e}", recipe_path.display()))?;
let recipe = hammer_core::Recipe::from_toml(&text)
.map_err(|e| anyhow::anyhow!("{}: {e}", recipe_path.display()))?;
let (repo, commit) = match recipe.source.kind() {
Ok(hammer_core::SourceKind::Git { repo, commit }) => (repo.to_string(), commit.to_string()),
Ok(hammer_core::SourceKind::Tarball { .. }) => {
eprintln!("{}: tarball (ya anclado por sha256) — nada que pinear", recipe.name);
return Ok(());
}
Err(e) => anyhow::bail!("{}: {e}", recipe.name),
};
if is_git_sha(&commit) {
eprintln!("{}: commit ya es SHA ({}) — ya anclado", recipe.name, &commit[..commit.len().min(12)]);
return Ok(());
}
let sha = resolve_git_sha(&repo, &commit)?;
// Reescritura dirigida: la línea `commit = "<tag>"` (no tocamos version u otras que casen).
let mut replaced = false;
let new_text: String = text
.lines()
.map(|l| {
if !replaced && l.trim_start().starts_with("commit") && l.contains(&format!("\"{commit}\"")) {
replaced = true;
l.replace(&format!("\"{commit}\""), &format!("\"{sha}\""))
} else {
l.to_string()
}
})
.collect::<Vec<_>>()
.join("\n");
let new_text = if text.ends_with('\n') { format!("{new_text}\n") } else { new_text };
if !replaced {
anyhow::bail!("no encontré la línea `commit = \"{commit}\"` para reescribir");
}
let dest = out.unwrap_or(recipe_path);
std::fs::write(dest, new_text)?;
eprintln!("{}: {commit}{sha} (anclado en {})", recipe.name, dest.display());
Ok(())
}
/// Importa una receta desde un APKBUILD de Alpine (Etapa G).
fn run_import_alpine(file: &str, out: Option<&std::path::Path>) -> anyhow::Result<()> {
let text = if file == "-" {
@@ -2566,6 +2658,21 @@ fn parse_swap(s: &str) -> anyhow::Result<hammer_bootstrap::ToolchainSwap> {
})
}
#[cfg(test)]
mod pin_tests {
use super::*;
#[test]
fn is_git_sha_detecta_sha_vs_tag() {
assert!(is_git_sha("4a7b216552d64134c0fa17a59b9d557d89019f0f")); // sha1 40 hex
assert!(is_git_sha(&"a".repeat(64))); // sha256 64 hex
assert!(!is_git_sha("v1.1.0")); // tag
assert!(!is_git_sha("main")); // branch
assert!(!is_git_sha("4a7b21")); // corto
assert!(!is_git_sha(&"z".repeat(40))); // 40 chars no-hex
}
}
#[cfg(test)]
mod swap_tests {
use super::*;
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
# pin-recipes.sh <recipe.toml...> — Etapa G Fase 2 (sello inmutable): ancla en LOTE el `commit`
# de cada receta git, resolviendo tags flotantes → SHA vía `git ls-remote` (ADR 0006). Los tarball
# (ya anclados por sha256) y las que ya tienen SHA se saltean. El laboratorio se vuelve determinista
# al estilo Nix: cada origen queda fijado a un punto inmutable de la historia.
#
# Ej: scripts/pin-recipes.sh recipes/*.toml | find recipes -name '*.toml' | xargs scripts/pin-recipes.sh
set -eu
HAMMER="${HAMMER:-cargo run -q -p hammer-cli --}"
[ "$#" -gt 0 ] || { echo "uso: pin-recipes.sh <recipe.toml...>" >&2; exit 1; }
pinned=0; skipped=0; failed=""
for r in "$@"; do
if $HAMMER pin "$r" 2>&1 | grep -q '→'; then
pinned=$((pinned + 1))
elif $HAMMER pin "$r" >/dev/null 2>&1; then
skipped=$((skipped + 1)) # ya anclado / tarball
else
failed="$failed $(basename "$r")"
fi
done
echo "pin: $pinned anclados, $skipped salteados (ya-SHA/tarball).${failed:+ FALLARON:$failed}"