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:
@@ -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::*;
|
||||
|
||||
Executable
+22
@@ -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}"
|
||||
Reference in New Issue
Block a user