Skip to content
academia.sh

Lesson 09 / 12

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.

Contents

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.

// 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.

// 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.

// 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?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close