---
title: 'Build Artifact Repositories'
source: 'https://academia.sh/en/courses/devops-fundamentals/build-artifact-repositories'
course: 'DevOps Culture and Fundamentals'
language: en
updated: '2026-08-23T16:55:08+00:00'
license: 'CC BY-SA 4.0'
---

# Build Artifact Repositories

Versioned binary and package storage is measured: the distinction between an immutable version and a movable tag is built into a repository model, how many deployments a movable tag pointed to a different artifact is counted, and the retention policy is scanned through the rollback window, repository bytes, and the count of unreachable deployments.

The previous lesson produced a single build artifact and measured that its identity is its
content digest. A digest is an identity, but it is not a name: hand-carrying a string of
hexadecimal digits into deployment records is not a sustainable arrangement. And if an artifact is
produced once and deployed everywhere, it has to sit somewhere between the moment it is produced
and the moment it is deployed, and the previous artifact has to still be findable when a rollback
is needed.

This lesson takes up that storage place: the **build artifact repository**. A repository is the
component that stores versioned binaries and packages, names them, and returns them on request.
It is treated here not as a product but as a type; what is measured is what the two naming forms a
repository offers — the immutable version and the movable tag — produce in deployment.

**DC13.** The repository model is limited to two functions: immutable version storage and name
resolution. Access control, replication, and the network layer are outside the model. **DC14.**
The release schedule is built with our own generator; the seed `20260311` is visible, and the
schedule publishes 24 versions at an average of one every four days. **DC15.** Version sizes come
from the generator in the 38–52 MB range; this is not a real measurement, it is the fiction's order
of magnitude.

## What a Repository Stores

A repository stores two separate things: **content** and **name**. The content is the object the
previous lesson measured; the name is the word people write into a deployment record. The
relationship between these two splits in two.

An **immutable version** is a name that, once published, can never be bound to different content.
If `1.5.0` has been published once, `1.5.0` is always the same output. A **movable tag** is a name
that points at a version and can move to a different version over time: `stable`, `latest`,
`release-candidate`, and so on. Both are names, but one corresponds to an identity, the other to a
pointer.

The model below contains both functions and the enforcement of the immutability rule.

```js
// measurement-network/repository.mjs — versioned build artifact repository and release schedule (model)
import { createHash } from "node:crypto";

// Our own generator: linear congruential. The seed belongs to the caller, output is repeatable.
export const generator = (seed) => () =>
  (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648;

export const digest = (text) => createHash("sha256").update(text).digest("hex").slice(0, 12);

export const createRepository = () => {
  const versions = new Map();   // immutable: version -> { digest, bytes, day }
  const tags = new Map();       // movable: tag -> version
  const rejected = [];
  return {
    versions, tags, rejected,
    publish(version, dig, bytes, day) {
      const existing = versions.get(version);
      if (existing && existing.digest !== dig) { rejected.push(version); return "REJECTED"; }
      if (existing) return "SAME";
      versions.set(version, { digest: dig, bytes, day });
      return "PUBLISHED";
    },
    tag: (name, version) => tags.set(name, version),
    resolve(name) {
      const version = tags.get(name) ?? name;
      const record = versions.get(version);
      return record ? { version, digest: record.digest } : null;
    },
  };
};

// n-version release schedule: day gaps and byte sizes come from the generator.
export const schedule = (n, avgDays, seed) => {
  const r = generator(seed);
  const list = [];
  let day = 0;
  for (let i = 1; i <= n; i++) {
    day += 1 + Math.round(r() * (2 * avgDays - 2));
    list.push({
      version: `1.${i}.0`, digest: digest(`1.${i}.0-content`),
      bytes: 38_000_000 + Math.round(r() * 14_000_000), day,
      cleanRun: r() > 0.45,
    });
  }
  return list;
};
```

The `publish` function has three responses, and these three are the whole of the repository's
behavior: a new version is recorded, a republish with the same content is ignored, and the same
name arriving with different content is rejected. The third is the immutability principle's
repository-side counterpart — without the rejection, every digest comparison the previous lesson
measured loses its meaning, because which object the name `1.5.0` points to becomes
time-dependent.

## Immutable Version and Movable Tag

The measurement publishes 24 versions on schedule and services the deployment requests that
arrive in between.

**DC16.** The `latest` tag moves on every publish; the `stable` tag moves only to versions where
the nightly batch job ran clean. **DC17.** The deployment record stores only the **requested
name**, not the resolved version — this is the source of the defect being measured.

```js
// measurement-network/tag.mjs — counts how many separate artifacts a movable tag pointed to across deployments
import { createRepository, schedule } from "./repository.mjs";

const VERSIONS = schedule(24, 4, 20260311);
const repo = createRepository();

// Deployment requests: day and requested name. The name is either a movable tag or an immutable version.
const REQUESTS = [
  [6, "stable"], [11, "stable"], [17, "latest"], [23, "stable"], [29, "stable"],
  [34, "1.5.0"], [41, "stable"], [48, "latest"], [55, "stable"], [66, "1.13.0"],
  [70, "stable"], [79, "stable"], [86, "latest"], [92, "1.21.0"],
];

const records = [];
let next = 0;
for (const [day, name] of REQUESTS) {
  while (next < VERSIONS.length && VERSIONS[next].day <= day) {
    const s = VERSIONS[next++];
    repo.publish(s.version, s.digest, s.bytes, s.day);
    repo.tag("latest", s.version);
    if (s.cleanRun) repo.tag("stable", s.version);
  }
  records.push({ day, name, ...repo.resolve(name) });
}

console.log("day".padEnd(5) + "requested name".padEnd(17) + "resolved version".padEnd(19) + "digest");
for (const r of records) {
  console.log(String(r.day).padEnd(5) + r.name.padEnd(17) + r.version.padEnd(19) + r.digest);
}

const movable = records.filter((r) => r.name === "stable" || r.name === "latest");
const lastSeen = new Map();
let changes = 0;
for (const r of movable) {
  if (lastSeen.has(r.name) && lastSeen.get(r.name) !== r.digest) changes += 1;
  lastSeen.set(r.name, r.digest);
}
const distinct = (name) => new Set(records.filter((r) => r.name === name).map((r) => r.digest)).size;

console.log(`\ntotal deployments ${records.length}: by movable tag ${movable.length}, ` +
  `by immutable version ${records.length - movable.length}`);
console.log(`"stable" was deployed ${records.filter((r) => r.name === "stable").length} times, ` +
  `pointed to ${distinct("stable")} distinct artifacts`);
console.log(`"latest" was deployed ${records.filter((r) => r.name === "latest").length} times, ` +
  `pointed to ${distinct("latest")} distinct artifacts`);
console.log(`same name, different artifact than the previous deployment: ${changes} deployments`);

// Immutability check: attempting to republish a published version under a different digest.
console.log("\nsame version, different digest: " + repo.publish("1.5.0", "ffffffffffff", 4e7, 99));
console.log("same version, same digest     : " + repo.publish("1.5.0", repo.resolve("1.5.0").digest, 4e7, 99));
console.log("rejected republish attempts: " + repo.rejected.length);
```

```
day  requested name   resolved version   digest
6    stable           1.1.0              fdcdc5473178
11   stable           1.2.0              8f4a3006353c
17   latest           1.4.0              d8a7b48a4c8f
23   stable           1.2.0              8f4a3006353c
29   stable           1.6.0              7cd8d49ade60
34   1.5.0            1.5.0              3341780c3d35
41   stable           1.7.0              569d9c794b31
48   latest           1.10.0             4fc0137cb6ae
55   stable           1.10.0             4fc0137cb6ae
66   1.13.0           1.13.0             d85823359ee1
70   stable           1.16.0             cbeaeddcd652
79   stable           1.16.0             cbeaeddcd652
86   latest           1.19.0             337f943ca885
92   1.21.0           1.21.0             d07c4f23ad28

total deployments 14: by movable tag 11, by immutable version 3
"stable" was deployed 8 times, pointed to 6 distinct artifacts
"latest" was deployed 3 times, pointed to 3 distinct artifacts
same name, different artifact than the previous deployment: 7 deployments

same version, different digest: REJECTED
same version, same digest     : SAME
rejected republish attempts: 1
```

The numbers collapse into one sentence: **eleven of fourteen deployments were made to a name, and
that name carried a different artifact than before on seven of them.** The eight deployments made
under the name `stable` carried six separate objects into production; the word written in the
record is the same in all eight.

Two situations in the table deserve a separate reading. Between day 11 and day 23, the `stable`
tag stayed put: the versions published in between did not run the nightly batch job clean, so the
tag did not move, and both deployments got the `1.2.0` artifact. So a movable tag does not always
move; whether it moved is invisible from the side requesting the deployment. Between day 55 and
day 79, the opposite happened: the same name carried the `1.16.0` artifact instead of `1.10.0`.

This is rollback's measurable trap too. If a problem is seen on day 79 and someone asks to go back
to the stable version, the object resolved is not the `1.10.0` deployed on day 55 — it is the
current `1.16.0`. A rollback request produces a forward deployment. For rollback to work, the
requested name must be an **immutable version**; three deployments in the table did that.

**The difference is hidden here in name resolution.** The resolution happens at deployment time
and is not recorded; the deployment record shows only `stable`. Someone looking back later cannot
read, in eleven of the records, which deployment carried which object. The difference is not
merely undeclared — it is not recorded at all. Closing it is a one-line fix too: the resolved
version and digest are written alongside the requested name in the deployment record; then all
eleven ambiguous records become readable.

The output's last three lines show the immutability check. When someone tries to republish the
already-published `1.5.0` under a different digest, the repository returns `REJECTED`; when it
arrives with the same digest, it says `SAME` and does not rewrite anything. This second behavior
keeps the build step from failing if it runs twice — the natural counterpart for a reproducible
build.

## The Input Side and Metadata

A repository does not store only a build step's output; a repository of the same kind also stores
the packages that are the build step's **input**. The name-resolution problem being measured
applies exactly the same on this side. If a build step requests its input package by a movable
name instead of an immutable version — something like "newest compatible" — two runs can produce
different output even with the previous lesson's four breaking sources closed, because the name
has moved to a different object in the time between. This is the **fifth source** that breaks
reproducibility, and unlike all the previous ones, it sits outside the build step, not inside it.
As in the measured ratio: the object had changed on seven of the eleven requests resolved by name;
that same uncertainty carries into the build step on the input side. Closing it means pinning
input versions as versions, not as names.

The second topic is metadata. The gap the previous lesson left was that the digest does not say
which source the output was produced from. The repository is the place that can close this gap:
three fields are added to each version record — the source version the output was produced from,
the build run's identity, and the result of the tests that ran in that run. Once these three
fields are written, the question "which source did the object running in production come from" is
read from the repository; unwritten, the answer lives only in people's memory. It should be noted
that all three fields are written **beside** the output, not **inside** it: writing them inside
would change the digest on every run, exactly as the previous lesson measured, and would break
reproducibility.

## What a Retention Policy Closes

A repository is not unlimited. A retention policy says which versions are kept for how long.
**DC18.** The rule is bound to a number: "keep the last K versions." As K changes, the scan
measures three quantities at once — the repository's bytes, the rollback window, and how many of
the previous measurement's deployments would have their target deleted.

```js
// measurement-network/retention.mjs — scans the retention policy: window, bytes, and unreachable deployments
import { schedule } from "./repository.mjs";

const S = schedule(24, 4, 20260311);
const last = S[S.length - 1];
// Versions resolved for the 14 deployments in the previous measurement:
const DEPLOYED = ["1.1.0", "1.2.0", "1.4.0", "1.2.0", "1.6.0", "1.5.0", "1.7.0",
  "1.10.0", "1.10.0", "1.13.0", "1.16.0", "1.16.0", "1.19.0", "1.21.0"];

const mb = (b) => (b / 1e6).toFixed(0) + " MB";
console.log("rule".padEnd(18) + "repo".padEnd(10) + "rollback window".padEnd(21) + "unreachable deployments");
for (const k of [3, 5, 8, 12, 18, 24]) {
  const retained = S.slice(-k);
  const bytes = retained.reduce((t, s) => t + s.bytes, 0);
  const names = new Set(retained.map((s) => s.version));
  const unreachable = DEPLOYED.filter((v) => !names.has(v)).length;
  console.log(`last ${k} versions`.padEnd(18) + mb(bytes).padEnd(10) +
    `${last.day - retained[0].day} days`.padEnd(21) + `${unreachable}/${DEPLOYED.length}`);
}

console.log("");
for (const interval of [4, 2]) {
  const sched = schedule(24, interval, 20260311);
  console.log(`release interval ~${interval} days: 24 versions spread across ${sched[23].day} days, ` +
    `"last 8 versions" window ${sched[23].day - sched[16].day} days`);
}

const total = S.reduce((t, s) => t + s.bytes, 0);
console.log(`\nwith no rule: ${S.length} versions ${mb(total)}; annual growth at this rate ` +
  mb((total / last.day) * 365));
```

```
rule              repo      rollback window      unreachable deployments
last 3 versions   129 MB    4 days               14/14
last 5 versions   214 MB    11 days              13/14
last 8 versions   352 MB    23 days              12/14
last 12 versions  535 MB    37 days              9/14
last 18 versions  805 MB    69 days              6/14
last 24 versions  1073 MB   97 days              0/14

release interval ~4 days: 24 versions spread across 99 days, "last 8 versions" window 23 days
release interval ~2 days: 24 versions spread across 47 days, "last 8 versions" window 11 days

with no rule: 24 versions 1073 MB; annual growth at this rate 3955 MB
```

The scan turns the retention policy into a trade-off table. The "last eight versions" rule keeps
the repository at 352 MB but drops the rollback window to 23 days: wanting to go back to a version
older than 23 days finds the object no longer there. Under that same rule, the artifact carried by
twelve of the fourteen measured deployments has been deleted — so even if the answer to "which
version were we running" is known, that version cannot be brought back. Extending the window to 69
days requires the repository to grow to 805 MB, more than double.

A number-bound rule hides a dependency: **the window depends on release frequency.** The same
"last eight versions" rule gives a 23-day window for a team releasing every four days and an
11-day window for a team releasing every two days. The rule's text has not changed; the rule's
meaning has. Increasing deployment frequency — improving one delivery metric — cuts the rollback
window in half without anyone changing anything; this is an example of a decision that improves
one metric moving another, measured elsewhere. If the window is meant to be independent of
frequency, the rule is bound to a duration ("versions older than ninety days are deleted") or the
two conditions are written together.

Even when no rule is set, there is a number: at this rate the repository grows by about 3955 MB a
year. Unlimited retention is not a decision — it is a deferred one; and when it is deferred, deletion
usually starts on the day there is no room left, which is exactly when the rollback window is
needed most.

## Summary

- A build artifact repository stores two things: content and name. The name comes in two forms —
  a version that never rebinds once it is bound, and a tag that can move to another version over
  time.
- The immutability check is enforced in the repository: republishing the same version under
  different content is rejected, republishing under the same content is ignored.
- In the measurement, 11 of 14 deployments were made to a movable name, and that name carried a
  different artifact than before on 7 of them; the 8 deployments made under the name `stable`
  deployed 6 separate objects.
- The difference is hidden in name resolution at deployment time and cannot be read back later
  because it is not recorded. Writing the resolved version and digest into the record makes 11
  ambiguous records readable; for rollback to work, the requested name has to be an immutable
  version.
- A retention policy fixes three quantities at once: "last 8 versions" means a 352 MB repository,
  a 23-day rollback window, and 12 of 14 deployments having their target deleted. A number-bound
  rule's window shortens as release frequency rises; when frequency doubles, the window drops
  from 23 days to 11.

## Next Step

The output sitting in the repository is now single and named, but the previous lesson left a debt
at its end: the twelve environment-varying values were pulled out of the output and placed into
"the environment manifest." Not all of these values are the same kind. The billing gateway's
address and the key used to connect to that gateway are read from the same place, but one can be
written to a log and the other cannot; one can appear in a deployment record and the other cannot.
The next lesson draws and measures that boundary: how many of the values coming from the
environment count as configuration, how many count as secret, how many separate ways can a secret
leak out, and how is the leak's closure shown with a check?
