Ship It: Publishing, Model Cards, and Reproducibility (Fine-Tuning in Production, Module 14)

Module 14 of 15 14 min read Lab code ↗

This is Module 14 of Fine-Tuning in Production, a free 15-module course that takes one open model from raw data to a deployed, published tool-calling specialist on the Hugging Face Hub. Start at Module 1 or browse the full syllabus.

You have a folder of weights that works. Now prove it.

After Module 13, Anvil exists in three forms — a LoRA adapter, a merged full-precision model, and a quantized GGUF — and all three serve locally. So you do the natural thing: you tell a recruiter, or you post “I fine-tuned Qwen3-4B into a tool-calling specialist.” The first reply is always the same: “Show me.”

And here is where a zip of safetensors proves nothing. Which base did it start from? What was it trained on? Is it actually better than its base, or just different? Is the result reproducible, or did you get lucky on one seed? A model with no model card is an orphan binary — a .safetensors file with no provenance, no claim, and no way for anyone but you to run it.

This module turns Anvil into a public, dated, sourced, reproducible artifact. You push it to the Hub with a card that declares its base (base_model:), shows the base-vs-fine-tuned table from Module 7, says how to call it — and you pin the recipe so anyone can replay it. This is the module where the work of the previous thirteen becomes showable. There is no certificate at the end of this course (decision F10); your published model, with its card, is the credential.

In this module

You’ll learn how to:

  1. Publish Anvil to the Hugging Face Hub — adapter, merged model, and GGUF — each in its own repo following the frozen anvil-qwen3-4b-<stage> naming convention, always pushing model + tokenizer + card together.
  2. Write a complete model card: the YAML front matter (with base_model: so the Hub auto-displays the adapter/merge/quantized lineage), plus what-it-is, intended use, dataset, license, limitations — and the base-vs-fine-tuned eval table from Module 7.
  3. Explain what base_model: does — one metadata field, and the Hub infers adapter / finetune / quantized from your repo and cross-links every Anvil repo back to Qwen/Qwen3-4B-Base.
  4. Make the recipe reproducible: fixed seeds, a committed uv.lock, pinned dataset and base-model revisions — and an offline CI smoke-test that runs the offline suite on a nano-model so the labs don’t rot (Module 14’s own tests build the card and check the manifest; the trainer-replay tests live in the earlier modules’ suites).
  5. Defend the portfolio claim — argue why a model on the Hub is not a portfolio piece by itself; the card (base-vs-tuned eval + a reproducible recipe) is what makes it credible.

You’ll build: Anvil goes live. hub.py builds a full model card with build_model_card and pushes the adapter, the merged model, and the GGUF to the Hub under anvil-qwen3-4b-<stage> repos, each declaring base_model: Qwen/Qwen3-4B-Base and embedding your Module-7 base-vs-fine-tuned table. You’ll pin the recipe (seeds, uv.lock, dataset/base revisions), capture a reproducibility_manifest, and wire an offline CI smoke-test so the pipeline stays re-runnable. By the end, your fine-tuned tool-calling specialist is a public, reproducible, interview-ready artifact.

Theory areas covered:

  • T12 — Publishing, model cards & reproducibility — 4% of the course. Module 14 owns T12; it’s reinforced once more at the capstone (Module 15).

This module reuses three areas you already own: T7 (the base-vs-fine-tuned eval table from Module 7, embedded verbatim — we publish it, we don’t re-implement it), T11 (the merged model and GGUF from Module 12, the artifacts we ship), and T4 (the LoRA adapter from Module 4, the third repo).

Prerequisites: Modules 1–13 — the pinned stack; Anvil trained in QLoRA (M5) and aligned with DPO (M8); eval.py/compare_base_vs_finetuned (M7); merge.py/quantize.py (M12), which produced the merged model and the GGUF; serve.py (M13). For the lab: a Hugging Face account and an HF_TOKEN with write access in your environment (.env / a secret) — that token is the only asset you need. No GPU: publishing is I/O, not compute. This is the free path par excellence.

Where you are

Anvil’s pipeline, with this module marked:

  • M1–M2 — baseline + data (chat template, tools=).
  • M3–M6 — SFT, LoRA, QLoRA (fits a free 16 GB T4), Unsloth/Axolotl.
  • M7 — task-grounded eval (tool_call_valid_json, function_name_accuracy, argument_match) — the table we publish here.
  • M8–M10 — preference alignment (DPO/ORPO/KTO/SimPO) and GRPO + verifiable rewards.
  • M11–M13 — merge (mergekit), quantize (GGUF/AWQ/GPTQ), serve (vLLM/Ollama/TGI).
  • 👉 M14 — publishing, model cards, reproducibility. Anvil stops being a folder of weights on your disk and becomes a public, reproducible repo on the Hub.
  • M15 — capstone: replay the whole pipeline end to end and ship Anvil v1.0.

The one-line before/after for Anvil: it goes from “trained, aligned, merged, quantized, and served — but only on your disk” to “public on the Hub: adapter + merged + GGUF, each with a model card and a base_model: lineage, and a reproducible recipe.” The hub.py publishing infrastructure you freeze here — build_model_card, the anvil-qwen3-4b-<stage> naming convention, the push_model helper — is exactly what the capstone (M15) replays to cut the versioned release v1.0.

Pushing to the Hub: push_to_hub for adapter, merged model, and GGUF

The Hub is two things at once: a git-LFS host for model weights, and a website that reads metadata and draws relationships for you. Publishing well means using both. Start with the act of uploading.

The canonical gesture

In transformers and peft, any model or tokenizer object carries a push_to_hub method. The one-liner you’ll see in every tutorial is:

model.push_to_hub("dupuis1212/anvil-qwen3-4b-toolcalling", private=True)
tokenizer.push_to_hub("dupuis1212/anvil-qwen3-4b-toolcalling", private=True)

push_to_hub creates the repo if it doesn’t exist, then commits the files: the safetensors weights, config.json, and the tokenizer files. The non-obvious part — the part this module hammers — is that you always push the tokenizer with the model. A repo of weights with no tokenizer is unusable: nobody can run from_pretrained on it and get a working model, because the tokenizer encodes the chat template Anvil was trained against. This is the single most common way a published model ends up an orphan binary.

The private=True flag matters too. Push private first. Upload, open the repo page, eyeball the rendered card and the lineage graph, then flip to public from the settings. Flipping a broken card public costs you more than the thirty seconds of review.

How Anvil actually pushes: one place that talks to the Hub

Tutorials reach for model.push_to_hub because it’s terse. In Anvil, the upload lives in exactly one module — anvil/hub.py — which builds the card as a string, then uses the lower-level HfApi to create the repo and upload a whole folder. Doing it through HfApi (rather than model.push_to_hub) is deliberate: it lets the same helper publish a PEFT adapter folder, a merged-model folder, or a folder of .gguf files — whatever is on disk — and it keeps the token-handling honest. Here is the frozen helper:

def push_model(model_dir: str, repo_id: str, *, card: str | None = None, private: bool = False) -> str:
    """Upload a model/adapter folder to the Hub and write its model card (``live``: needs HF_TOKEN)."""
    import os
    import pathlib

    from huggingface_hub import HfApi

    api = HfApi(token=os.environ["HF_TOKEN"])
    api.create_repo(repo_id, exist_ok=True, private=private)
    if card:
        pathlib.Path(model_dir, "README.md").write_text(card)
    api.upload_folder(folder_path=model_dir, repo_id=repo_id)
    return f"https://huggingface.co/{repo_id}"

Read off the three things that matter. The token comes from os.environ["HF_TOKEN"], never an argument and never hard-coded — and it never appears in a code block you’d publish. The card is written as README.md into the folder before the upload (more on why below — the card is the README). And upload_folder ships the whole directory, so a PEFT adapter goes up as adapter_model.safetensors + adapter_config.json (a few MB), while a merged model goes up as full weights — the helper doesn’t care which, because the folder already knows what it is. As of huggingface_hub >=1.0,<2, June 2026, verify the HfApi.create_repo/upload_folder signature against your installed wheel — the Hub API moves.

Iterating on a published card: create_pr=True

Once a repo is public, you rarely want to commit straight to main. The push_to_hub family — and the lower-level upload calls — accept create_pr=True, which opens a pull request on the repo instead of pushing to main. Use it to review a card change before it goes live, or to contribute a fix to a repo you don’t own. It’s the “clean” way to iterate on a published card: open the PR, look at the diff on the Hub, merge from the UI. Verify the exact flag name against your installed huggingface_hub, June 2026.

Three artifacts, three repos

Anvil’s three forms map to three repos, following the frozen convention anvil-qwen3-4b-<stage> (06-FIL-ROUGE-SPEC §2):

ArtifactRepoWhat’s in itlibrary_name
LoRA/QLoRA adapter (M4/M5)anvil-qwen3-4b-sft (and -dpo after alignment)adapter_model.safetensors + adapter_config.json — a few MBpeft
Merged full-precision model (M12)anvil-qwen3-4b-toolcalling (the main repo)full safetensors weights — servable directlytransformers
Quantized GGUF (M12)anvil-qwen3-4b-ggufthe .gguf file(s) for Ollama / llama.cpp— (Hub infers quantized from the .gguf + base_model:)

The adapter is tiny and links back to its base; the merged model is the headline artifact; the GGUF is the deployment build. Each is a separate repo with its own card — but, as you’ll see next, one metadata field ties them all back to Qwen/Qwen3-4B-Base.

flowchart TD
    BASE["Qwen/Qwen3-4B-Base<br/>(Apache-2.0)"]
    SFT["anvil-qwen3-4b-sft / -dpo"]
    MERGED["anvil-qwen3-4b-toolcalling"]
    GGUF["anvil-qwen3-4b-gguf"]
    BASE -->|"base_model: → adapter"| SFT
    BASE -->|"base_model: → finetune"| MERGED
    BASE -->|"base_model: → quantized"| GGUF
    NOTE["one metadata field (base_model:)<br/>makes the Hub draw all these arrows"]:::note -.-> BASE
    classDef note fill:#fff,stroke:#bbb,stroke-dasharray:5 5,color:#888;

A word on license — you can republish freely

Why is this clean at all? Because of decision F3: Anvil’s base is Qwen/Qwen3-4B-Base, which is Apache-2.0. Apache-2.0 has no naming clause (unlike Llama’s “Built with Llama” requirement and 700M-MAU trigger) and no custom terms (unlike Gemma 3’s use restrictions). You can republish your derivative — adapter, merge, GGUF — with no strings attached, and you declare license: apache-2.0 in the card in full knowledge of what you’re inheriting. If you’d swapped in a Llama or Gemma base, this section would be a legal minefield instead of a one-liner. Choosing an Apache-2.0 base in Module 3 is what makes shipping painless in Module 14.

The model card: README + YAML, and what base_model: does

Anatomy: the card is the README

A model card is the README.md of the repo. It has two parts: a YAML front matter block (structured metadata the Hub parses and renders) followed by free Markdown (the prose a human reads). When push_model writes README.md into the folder before uploading, that file is the card. Keep the two halves straight — the YAML drives the Hub’s UI (the “base model” widget, the license tag, the dataset link); the Markdown drives human trust (what it is, how to use it, what it can’t do).

Build the card programmatically, never by concatenating ad-hoc strings in a notebook — the same discipline as Module 2’s “never hand-roll the chat template.” Anvil’s build_model_card is a pure function that returns the full card as a string, so it’s testable offline with no network:

def build_model_card(repo_id: str, *, comparison: dict | None = None, stage: str = "sft",
                     base_model: str = BASE_MODEL, dataset: str = DATASET,
                     library: str = "peft") -> str:
    """Build Anvil's full model card (YAML front matter + body). Pure string — testable offline."""
    front_matter = "\n".join([
        "---",
        f"base_model: {base_model}",
        "license: apache-2.0",
        f"library_name: {library}",
        f"datasets:\n  - {dataset}",
        "pipeline_tag: text-generation",
        "tags:",
        "  - tool-calling",
        "  - function-calling",
        "  - qwen3",
        "  - fine-tuning-in-production",
        f"  - anvil-{stage}",
        "---",
    ])
    ...

BASE_MODEL is "Qwen/Qwen3-4B-Base" and DATASET is "argilla/Synth-APIGen-v0.1", both pinned as module-level constants in hub.py so they never drift. Pass library="peft" when carding the adapter, library="transformers" for the merged model.

The YAML front matter, field by field

FieldWhereWhy it mattersRequired for a credible card?
base_model: Qwen/Qwen3-4B-BaseYAMLThe Hub infers and displays the lineage (adapter/finetune/quantized) and cross-links to the base. The central field of this module.Non-negotiable
license: apache-2.0YAMLThe Hub shows the license tag; it tells users they can reuse the model. Clean because the base is Apache-2.0.Yes
library_nameYAMLpeft → Hub shows “adapter”; transformers → loadable with from_pretrained.Yes
datasets:YAMLLinks the training set; the Hub renders a dataset card link.Yes
pipeline_tag: text-generationYAMLSlots the model into the right Hub category + the inference widget.Recommended
tags:YAMLDiscoverability (tool-calling, qwen3, …) — the acquisition channel.Recommended
What it is / intended useMarkdownHuman context: a tool-calling specialist, not a general chatbot.Yes
Dataset + license proseMarkdownProvenance a human can read; honest sourcing.Yes
LimitationsMarkdownHonest scope — small model, limited tool domain.Yes
Base-vs-fine-tuned eval tableMarkdownThe proof the model beats its base. The heart of the portfolio claim.Non-negotiable

Two rows carry the credibility: the declared base (base_model:) and the eval table. Everything else is good hygiene; those two are the argument.

A note on base_model_relation:. Some huggingface_hub versions accept an explicit base_model_relation: field (adapter / finetune / quantized) to override the Hub’s inference. As of June 2026, verify whether your installed version exposes it before adding it. In practice Anvil’s cards don’t set it — with library_name: peft on the adapter and transformers on the merged model, the Hub infers the relationship correctly from base_model: alone. Don’t assert a YAML field you haven’t verified exists.

The Markdown body

build_model_card injects the rest: a “what it is” intro, the base/data/stage/license bullets, the eval section (when a comparison is passed), a Usage block, and Limitations. The Usage block teaches the right idiom — apply_chat_template with tools=, exactly as Module 2 froze it — so a user can run the model correctly:

tok = AutoTokenizer.from_pretrained("{repo_id}")
model = AutoModelForCausalLM.from_pretrained("{repo_id}")
messages = [{"role": "user", "content": "What's the weather in Paris?"}]
tools = [{"type": "function", "function": {"name": "get_weather", ...}}]
prompt = tok.apply_chat_template(messages, tools=tools, add_generation_prompt=True, tokenize=False)

The Limitations section says the quiet part out loud: Anvil is a specialist tuned for tool-calling, not open-ended chat; it inherits the base model’s knowledge cutoff; and — the renvoi to Module 1 — fine-tuning changes behavior, not facts, so don’t expect it to know things the base didn’t. A card that only lists strengths is a sales sheet, not a model card.

⚠️ A note on the dataset license. Anvil trains on argilla/Synth-APIGen-v0.1, which is Apache-2.0 and not gated (verified on the Hub, June 2026), so declaring it in datasets: and redistributing the subsample is clean. Always re-check a dataset’s license on its Hub page before you declare it in a card. If a dataset’s license forbids redistribution, cite the source and ship a reformatting script instead of the raw data — and never declare a license you haven’t confirmed.

The base-vs-fine-tuned eval table: what makes the card credible

This is the heart of the module. What separates Anvil from “a folder of weights” is proof that it beats its base on the task. You don’t re-run the eval here and you don’t re-implement it — you reuse compare_base_vs_finetuned from Module 7 (frozen) and publish its result in the card.

hub.py renders that result into a Markdown table with a small, dependency-free helper:

def _render_eval_table(comparison: dict) -> str:
    """Render compare_base_vs_finetuned() output as a markdown table for the card."""
    rows = ["| Metric | Base | Anvil (fine-tuned) |", "|---|---|---|"]
    keys = [k for k in ("valid_json", "fn_acc", "arg_match", "abstention") if k in comparison["base"]]
    for k in keys:
        b, f = comparison["base"].get(k), comparison["finetuned"].get(k)
        fmt = lambda v: "—" if v is None else f"{v:.3f}"
        rows.append(f"| {k} | {fmt(b)} | {fmt(f)} |")
    return "\n".join(rows)

It reads the {"base": {...}, "finetuned": {...}} dict that compare_base_vs_finetuned returns and renders four metrics: valid_json (does it emit a parseable call), fn_acc (right tool name), arg_match (arguments match), and abstention (does it correctly not call a tool on negatives). Here is the shape it produces — values shown are on my run, measured on the held-out split; never invent precise numbers:

MetricBaseAnvil (fine-tuned)
valid_json0.1000.980
fn_acc0.1200.930
arg_match0.0500.880
abstention0.4000.850

The story this tells: the base model rarely emits valid JSON or the right tool name (it has the chat template but was never trained to use it reliably — the gap Module 3 closed); Anvil climbs hard on the task, with healthy abstention on negatives. Be honest about what doesn’t improve. If a metric regresses or barely moves, show it — a card that reports only gains is suspect, and a careful reader trusts the one that admits a weakness.

And state provenance: which held-out split (never seen, M2/M7), which harness (eval.py, plus lighteval/lm-eval for the anti-forgetting probe, M7), and at what date/stack version the numbers were produced. An eval with no provenance isn’t reproducible — and a number you can’t reproduce isn’t proof.

⚠️ Common misconception: “I pushed my model to the Hub, so I have a portfolio piece.”

False. A repo of weights with no card is an orphan binary: nobody knows which base it started from, what it was trained on, or whether it’s better than its base. Uploading is necessary, not sufficient.

What makes the portfolio piece is the model card — specifically three things: (a) the declared base (base_model:) that places the work in a lineage; (b) the base-vs-fine-tuned table that proves a gain on the task; and (c) a reproducible recipe (next section) so the claim isn’t a one-seed fluke. The model is the conclusion; the card is the argument. This is the load-bearing message of the module — and the reason decision F10 makes the published, carded model your credential, not the weights alone.

Reproducibility and the CI smoke-test

A fine-tuning run has to be re-runnable, by you in six months and by a stranger today (decision F14). “It worked on my machine” is not a recipe. Reproducibility is the difference between a card that claims a result and a card whose result a reader can regenerate.

What you pin

What you pinHowWhy
Seedsset_seed(...) + seed=0 on SFTConfig/DPOConfig/GRPOConfigWithout a seed, two runs diverge: adapter init, data shuffle, dropout all use RNG.
The whole dependency treecommit uv.lockPins transitively — not just pyproject.toml’s direct pins. uv sync rebuilds the exact env. The parry to stack churn.
Dataset revisionload_dataset(name, revision="<commit-sha>")A dataset on the Hub moves; pinning the revision freezes the data.
Base-model revisionfrom_pretrained(MODEL_ID, revision="<sha>")Same reason — the base can be re-uploaded.
Hyperparams + commandfrozen in config.py / lab.mdAlready pinned across the course; the run is a single command.

hub.py captures the live state of the pinnable stack in a manifest you can drop next to the card:

def reproducibility_manifest(seed: int = 0) -> dict:
    """The facts that make a published run reproducible: pinned stack + seed + dataset (decision F14)."""
    import importlib
    versions = {}
    for lib in ("trl", "peft", "transformers", "datasets", "accelerate", "torch"):
        try:
            versions[lib] = importlib.import_module(lib).__version__
        except Exception:
            versions[lib] = "not-installed"
    return {"seed": seed, "base_model": BASE_MODEL, "dataset": DATASET, "versions": versions}

One honest caveat: bit-for-bit determinism on a GPU is not guaranteed. CUDA kernels are non-deterministic in places, so even with a fixed seed two runs can differ in the last decimals. The seed makes the recipe reproducible in intent and order of magnitude — same data order, same init, same ballpark result — not necessarily bit-exact. Don’t oversell it; say what’s true.

flowchart LR
    SEED["fixed seeds"] --> PIPE
    LOCK["uv.lock<br/>(transitive pins)"] --> PIPE
    DREV["pinned dataset revision"] --> PIPE
    BREV["pinned base_model revision"] --> PIPE
    PIPE["re-runnable pipeline"] --> CI["CI: offline smoke suite<br/>(nano-model; M14 = card + manifest)"]
    CI --> OK["✅ green = the recipe still runs"]
    NOTE["pin everything that can drift;<br/>let CI prove it still runs"]:::note -.-> PIPE
    classDef note fill:#fff,stroke:#bbb,stroke-dasharray:5 5,color:#888;

The CI smoke-test (decision F14)

Pinning freezes the recipe; CI proves it still runs. A GitHub Action runs on push and on a monthly cron, does uv sync, then runs the whole course’s offline smoke suite — every module’s tests, uv run pytest $(for n in 01 02 ... 15; do echo module-$n/tests; done). That suite runs the real trainers (SFTTrainer/DPOTrainer/GRPOTrainer) for a couple of steps on a nano-model (the 160 KB tiny Qwen3 fixture in tests/fixtures/tiny-qwen3, which carries Qwen3-4B-Base’s exact tool-calling chat template), offline, no GPU, no network, deterministic with a fixed seed — that data → get_model_and_tokenizer → a few real training steps → eval-metrics replay lives in the earlier modules’ tests (e.g. M5/M7). Module 14’s own offline tests don’t re-train anything: they build the card and check the manifest (see below).

Its job is to catch a breaking change in the stack — a TRL idiom removed, a push_to_hub signature that shifts — silently, before a reader hits a broken lab (the stack churns weekly). What it does not do is just as important: it does not train the real Anvil, does not call the Hub, does not exercise bitsandbytes/CUDA, and does not need an HF_TOKEN. It validates that the recipe still runs, not that the model is good — that’s the Module-7 eval’s job. For Module 14 specifically, the offline tests build the card and assert it carries base_model: Qwen/Qwen3-4B-Base, the license, and the rendered eval table, and that the manifest pins the stack — all with no network:

def test_model_card_links_base_and_license():
    card = hub.build_model_card("dupuis1212/anvil-qwen3-4b-sft", stage="sft")
    assert "base_model: Qwen/Qwen3-4B-Base" in card
    assert "license: apache-2.0" in card
    assert "apply_chat_template" in card           # usage shows the right idiom
    assert "anvil-sft" in card

A green CI is not a claim that Anvil reproduces bit-for-bit. It guarantees that the recipe still executes on the pinned stack. That’s the realistic, honest level of reproducibility for a free course — and it’s already rare in the one-off tutorials this course competes with.

Build it: ship Anvil to the Hub

Goal: publish Anvil — adapter, merged model, and GGUF — to the Hub under anvil-qwen3-4b-<stage> repos, each with a full model card (base_model: + the Module-7 base-vs-fine-tuned table), pin the recipe, and wire an offline CI smoke-test.

What you’ll see: running uv run python -m anvil.hub (with an HF_TOKEN) prints the three repo URLs; each repo page renders the card (the “base model” widget, the license, the datasets link, the eval table) and the lineage (the Hub shows -toolcalling as a finetune of Qwen/Qwen3-4B-Base, -sft as an adapter, -gguf as quantized). The offline test suite is green with no GPU and no network.

The full, tested code lives in finetune-prod-labs/module-14. Its smoke test runs offline — no GPU, no network, no token — so you can verify the card-building and reproducibility plumbing before you push anything.

Step 1 — Wire your token. Set HF_TOKEN (scope write) in .env (never commit it; never paste it in a code block). Run huggingface-cli whoami to confirm your identity, and check the push_model/HfApi signatures against your installed huggingface_hub (a volatile fact — see Common pitfalls).

Step 2 — Build the card offline. Call build_model_card for each artifact, passing the Module-7 comparison dict to embed the eval table:

from anvil import hub

card = hub.build_model_card(
    "dupuis1212/anvil-qwen3-4b-toolcalling",
    comparison=comparison,      # from compare_base_vs_finetuned (M7) — measured, not invented
    stage="dpo",
    library="transformers",
)
print(card)                     # eyeball the YAML + body before you push

Step 3 — Branch the M7 eval into the card. The comparison dict comes straight from compare_base_vs_finetuned (M7, frozen) — {"base": {...}, "finetuned": {...}}. _render_eval_table turns it into the Markdown table. Don’t re-implement the eval; reuse it.

Step 4 — Push the three artifacts. Always push model + tokenizer + card (the card is written into the folder as README.md by push_model):

hub.push_model("outputs/anvil-sft",      "dupuis1212/anvil-qwen3-4b-sft",         card=adapter_card, private=True)
hub.push_model("outputs/anvil-merged",   "dupuis1212/anvil-qwen3-4b-toolcalling", card=merged_card,  private=True)
hub.push_model("outputs/anvil-gguf",     "dupuis1212/anvil-qwen3-4b-gguf",        card=gguf_card,    private=True)

Open each repo page, check the rendered card and the lineage graph, then flip to public. For a later card fix, push with create_pr=True to open a PR instead of committing to main.

Step 5 — Pin the recipe. Commit uv.lock; the seeds are already fixed in config.py (seed=0 on every config). Pin the dataset and base-model revisions (revision="<sha>") and record the stack with hub.reproducibility_manifest().

Step 6 — Wire the CI. Add .github/workflows/smoke.yml: a GitHub Action that, on push and on a monthly cron, runs uv sync then the whole course’s offline suite — offline, no GPU, no network, no HF_TOKEN:

on:
  push:
  schedule:
    - cron: "0 6 1 * *"      # monthly: catch stack churn before a reader does
jobs:
  smoke:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v5
      - run: uv sync
      - run: uv run pytest $(for n in 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15; do echo module-$n/tests; done) -q

Step 7 — Cost. Note the wall-clock (upload only) in lab.md’s final cost line.

Try it yourself:

  1. Add a structured model-index: block to the YAML (the eval metrics as data, not just a Markdown table) and see how the Hub renders it — verify the exact schema against the Hub model-card docs at generation time.
  2. Push a card update with create_pr=True to open a PR on the repo instead of committing to main, then merge it from the Hub UI.

What this lab does NOT do. No re-training — you publish the existing artifacts (adapter M4/M5, merged M12, GGUF M12); Module 14’s own offline tests just build the card and check the manifest (the trainer-replay-on-a-nano-model tests live in the earlier modules’ suites). No re-implementing the eval (M7) — you call compare_base_vs_finetuned and publish its table. No merge/quantize (M12) or serving (M13) re-taught. Not the versioned v1.0 release or the end-to-end debrief (M15 — you lay the publishing infrastructure; M15 replays it). No GPU, no paid service. The HF_TOKEN never appears hard-coded in the article, the lab, or any code block.

In production

Outside the lab, publishing becomes a release process. You version models (Hub tags v1.0, v1.1 — what M15 does for Anvil) so a consumer can pin a known-good revision. You gate the release behind the eval: a model only ships if it beats the base and doesn’t regress too far, and CI can block a push when a metric drops below a threshold. You keep large artifacts out of git — weights live on the Hub, not in the code repo; the labs repo commits only code plus the small dataset fixture. You pin huggingface_hub, because the push_to_hub/card API moves. And you track lineage: a production model must be able to answer “which base, which dataset (revision), which code commit, which seed?” — which is exactly what base_model: + uv.lock + pinned revisions + the reproducibility_manifest encode.

Access control is part of the process too: you can publish gated (an access form) or fully private for an internal model, then open it deliberately. My own rule, stated as opinion: I push private first, eyeball the rendered card and the lineage graph on the Hub, then flip to public — flipping a bad card public costs you more than the 30 seconds of review.

The capstone (M15) replays this whole pipeline and ships Anvil’s versioned release v1.0, with a real debrief of the traps I hit training and publishing it (decision F13).

Concept check

Why this matters. This module owns T12. The skills it anchors: what makes a published model reproducible (fixed seeds + a committed uv.lock + pinned dataset/base revisions + an offline CI smoke-test, with the honest caveat that bit-for-bit GPU determinism isn’t guaranteed); the role of base_model: (one YAML field → the Hub infers and displays the adapter/finetune/quantized lineage and cross-links every repo to the base); and why the model alone is not the portfolio — the card (declared base + base-vs-fine-tuned eval + a reproducible recipe) is. This publishing infrastructure is replayed at the capstone (M15) for the versioned v1.0 release.

  1. A colleague clones your labs repo, runs the pipeline, and gets noticeably different eval numbers than your card reports. Which set of fixes makes the run reproducible — and what should you still NOT promise?

    • A. Re-upload the weights at higher precision; promise bit-identical results.
    • B. Fix the seeds, commit uv.lock, pin the dataset and base-model revisions; do NOT promise bit-for-bit GPU determinism (CUDA kernels aren’t fully deterministic).
    • C. Switch to a bigger GPU; promise identical results across hardware.
    • D. Remove the eval table from the card so there’s nothing to reproduce.
  2. You publish the adapter, the merged model, and the GGUF and want all three to link back to Qwen/Qwen3-4B-Base. What does the work, and what does the Hub infer?

    • A. You must re-upload the base weights into each repo so the link resolves.
    • B. You set base_model: Qwen/Qwen3-4B-Base in each card’s YAML; the Hub infers and displays the relationship (adapter / finetune / quantized) and cross-links the repos.
    • C. You add a Markdown sentence “based on Qwen3-4B”; the Hub parses prose.
    • D. You email Hugging Face support to register the lineage manually.
  3. You pushed the merged weights to a public repo with no README. A reviewer says “this isn’t a portfolio piece yet.” Why are they right?

    • A. They’re wrong — weights on the Hub are a portfolio piece by definition.
    • B. A repo of weights with no card is an orphan binary: no declared base, no dataset, no proof it beats its base. The model card (base_model: + base-vs-fine-tuned table + reproducible recipe) is what makes it credible.
    • C. The repo needs to be private first.
    • D. You used the wrong quantization format.
  4. You ran model.push_to_hub("user/anvil-qwen3-4b-toolcalling") and the repo uploaded, but a colleague’s from_pretrained fails to produce a working model. What’s the most likely cause?

    • A. You forgot to push the tokenizer alongside the model — a model without its tokenizer (and chat template) is unusable.
    • B. The repo is private.
    • C. push_to_hub doesn’t support merged models.
    • D. You need to retrain with a different seed.
  5. What does the offline CI smoke-test guarantee — and what does it explicitly NOT guarantee?

    • A. It guarantees the published model is high-quality and beats its base.
    • B. It guarantees the pipeline still runs on the pinned stack (catching a breaking change before a reader), running offline on a nano-model with no GPU, no network, no token; it does NOT verify model quality (that’s the M7 eval), does NOT call the Hub, and does NOT re-train the real Anvil.
    • C. It guarantees bit-for-bit reproducibility of Anvil’s weights.
    • D. It guarantees the Hub upload succeeded.

Answers.

  1. B. Reproducibility comes from fixing the seeds, committing uv.lock (transitive pins, so uv sync rebuilds the exact environment), and pinning the dataset and base-model revisions. But you still shouldn’t promise bit-for-bit identical results: GPU kernels aren’t fully deterministic, so the recipe is reproducible in intent and order of magnitude, not necessarily bit-exact. A, C, and D either oversell determinism or hide the claim instead of supporting it.
  2. B. A single YAML field — base_model: Qwen/Qwen3-4B-Base — is all it takes. The Hub infers the relationship from that plus library_name (adapter for peft, finetune for the merged model, quantized for the GGUF) and cross-links every Anvil repo to the base. A is the classic misconception (you never re-upload the base); C and D misunderstand how the Hub reads metadata.
  3. B. Uploading weights is necessary but not sufficient. Without a card there’s no declared base, no dataset, and — critically — no base-vs-fine-tuned table proving the model is better than its base. The card is the argument; the weights are the conclusion. This is the load-bearing message of the module (decision F10).
  4. A. Always push model + tokenizer + card. The tokenizer carries the chat template Anvil was trained against; without it, from_pretrained can’t reconstruct a usable model. Privacy (B) wouldn’t break from_pretrained for someone with access; C is false (push_to_hub/HfApi handle full models and adapters alike); D is irrelevant.
  5. B. The CI smoke-test re-runs the pipeline on the tiny in-repo model — offline, no GPU, no network, no HF_TOKEN — to prove the recipe still executes on the pinned stack, catching stack churn before a reader does. It does NOT judge model quality (the M7 eval does that), does NOT call the Hub, and does NOT re-train the real Anvil. A, C, and D claim guarantees the smoke test never makes.

Common pitfalls.

  • Pushing the model without the tokenizer or card. A repo of weights alone is unusable (no chat template) and invisible (no lineage). Always ship model + tokenizer + card. This is the single most common way to end up with an orphan binary.
  • “A model on the Hub is a portfolio piece.” No — without a card (declared base + base-vs-fine-tuned eval + reproducible recipe), it’s an orphan binary. This is the conceptual trap of the module.
  • Forgetting base_model: (or mis-filling it), and skipping the lockfile. Without base_model: the Hub draws no lineage and the adapter doesn’t link to its base. The reproducibility corollary: not committing uv.lock or not pinning the dataset revision means “it worked on my machine” at the next stack churn.
  • Reciting a push_to_hub / HfApi / model-card signature from memory or an old tutorial. The Hub API moves: repo_id positional, card-data fields, the create_pr flag. Verify against the installed huggingface_hub (>=1.0,<2, June 2026), and don’t assert a base_model_relation: field you haven’t confirmed exists.

Key takeaways

  • push_to_hub (and HfApi.upload_folder) pushes model + tokenizer + card — never the weights alone. A repo with no tokenizer is unusable; a repo with no card is an orphan binary.
  • An adapter, a merged model, and a GGUF are three repos following the frozen anvil-qwen3-4b-<stage> convention (-sft/-dpo, -toolcalling, -gguf).
  • base_model: in the YAML is the one field that draws the whole lineage — the Hub infers and displays adapter/finetune/quantized and cross-links every Anvil repo back to Qwen/Qwen3-4B-Base.
  • A model card = README + YAML, with usage/dataset/license/limitations plus the base-vs-fine-tuned eval table built from Module 7’s compare_base_vs_finetuned.
  • A model on the Hub is NOT a portfolio piece by itself — the card is (declared base + base-vs-tuned eval + a reproducible recipe). The model is the conclusion; the card is the argument.
  • Reproducibility = fixed seeds + committed uv.lock + pinned dataset/base revisions + an offline CI smoke-test — with the honest caveat that bit-for-bit GPU determinism isn’t guaranteed.
  • Qwen3-4B-Base is Apache-2.0, so you republish your derivative freely (license: apache-2.0) — no naming clause, no custom terms. Verify a dataset’s license before declaring it.

What’s next

Anvil is public now — adapter, merged model, and GGUF, each with a model card and a base_model: lineage, and a pinned, CI-guarded recipe. But can you run the whole thing, end to end, in one go? Module 15 replays the entire pipeline (data → SFT → QLoRA → DPO → eval → merge → quantize → serve → publish) as one reproducible run, ships Anvil v1.0, and I debrief the real traps I hit training and publishing it. It’s where the fil rouge closes.

Want the full fine-tuning concept-check bank and news of future courses in your inbox? Subscribe here — it’s free, like everything in this series.

Links: Course index · ← Module 13: Serve It — vLLM, Ollama, and TGI · Module 15: Capstone — Anvil, End to End → · Lab code for this module · Anvil on the Hub

References