@policyengine/calibration-tree 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +58 -2
- package/package.json +73 -4
- package/src/calibration-build-comparison.ts +77 -0
- package/src/calibration-comparison-blob.ts +367 -0
- package/src/calibration-comparison-bundle.ts +149 -0
- package/src/calibration-explorer.ts +202 -0
- package/src/calibration-release-locator.ts +353 -0
- package/src/calibration-tree-artifact.ts +1974 -0
- package/src/calibration-tree-blob.ts +403 -0
- package/src/calibration-tree-bundle.ts +569 -0
- package/src/calibration-tree-manifest.ts +349 -0
- package/src/calibration-tree-publication.ts +399 -0
- package/src/calibration-tree-worker.ts +637 -0
- package/src/calibration-tree.ts +1284 -0
- package/src/chronicle-metadata.ts +42 -0
- package/src/countries.ts +355 -0
- package/src/geography-labels.ts +59 -0
- package/src/hierarchy-catalog-reader.ts +297 -0
- package/src/hierarchy-target-reader.ts +243 -0
- package/src/historical-weight-summary.ts +53 -0
- package/src/latest-artifact.ts +3767 -0
- package/src/legacy-target-reader.ts +337 -0
- package/src/program-label.ts +64 -0
- package/src/publish-calibration-tree.ts +1984 -0
- package/src/reforms.ts +224 -0
- package/src/release-cut.ts +32 -0
- package/src/release-record.ts +277 -0
- package/src/source-label.ts +5 -0
- package/src/source-labels.ts +71 -0
- package/src/staging-artifact.ts +1411 -0
- package/src/staging-contract.ts +916 -0
- package/src/structured-dimension-reader.ts +137 -0
- package/src/structured-target-reader.ts +76 -0
- package/src/target-change.ts +421 -0
- package/src/target-loss-attribution-manifest.ts +436 -0
- package/src/target-loss-attribution.ts +1012 -0
- package/src/target-representation.ts +69 -0
- package/src/target-surface-matcher.ts +266 -0
- package/types/next-fetch-cache.d.ts +8 -0
|
@@ -0,0 +1,1984 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CALIBRATION_TREE_SCHEMA_VERSION,
|
|
3
|
+
type CalibrationTreeSourceArtifact,
|
|
4
|
+
} from "./calibration-tree-artifact";
|
|
5
|
+
import {
|
|
6
|
+
buildCalibrationTreeBundle,
|
|
7
|
+
type CalibrationTreeBundle,
|
|
8
|
+
type CalibrationTreeBundleFile,
|
|
9
|
+
} from "./calibration-tree-bundle";
|
|
10
|
+
import {
|
|
11
|
+
auditCalibrationTreeBuild,
|
|
12
|
+
listCalibrationTreeBlobs,
|
|
13
|
+
readCalibrationTreeManifest,
|
|
14
|
+
updateCalibrationTreeManifest,
|
|
15
|
+
uploadCalibrationTreeBundle,
|
|
16
|
+
type CalibrationTreeBuildAudit,
|
|
17
|
+
} from "./calibration-tree-blob";
|
|
18
|
+
import type { ListBlobResultBlob } from "@vercel/blob";
|
|
19
|
+
import {
|
|
20
|
+
releaseDirectoryOf,
|
|
21
|
+
type CalibrationTreeManifest,
|
|
22
|
+
type CalibrationTreeManifestEntry,
|
|
23
|
+
} from "./calibration-tree-manifest";
|
|
24
|
+
import { createHash } from "node:crypto";
|
|
25
|
+
import {
|
|
26
|
+
CalibrationReleaseNotFoundError,
|
|
27
|
+
isReleaseId,
|
|
28
|
+
resolveHfBuildFolderSha,
|
|
29
|
+
resolveHfCommitPublishedAt,
|
|
30
|
+
resolveHfReleaseDirectorySha,
|
|
31
|
+
resolveHfRepositoryCommitPublishedAt,
|
|
32
|
+
resolveHfRevisionSha,
|
|
33
|
+
} from "./calibration-release-locator";
|
|
34
|
+
import {
|
|
35
|
+
countryPointerPath,
|
|
36
|
+
countryRegistration,
|
|
37
|
+
isCountry,
|
|
38
|
+
selectableCountries,
|
|
39
|
+
type MicrocosmCountry,
|
|
40
|
+
} from "./countries";
|
|
41
|
+
import { parseReleaseCutTag } from "./release-cut";
|
|
42
|
+
import {
|
|
43
|
+
RELEASE_MANIFEST_NAME,
|
|
44
|
+
RELEASES_RECORD_PATH,
|
|
45
|
+
currentDefaultBuild,
|
|
46
|
+
isBuildFolderId,
|
|
47
|
+
isReleaseDirectory,
|
|
48
|
+
recordedBuilds,
|
|
49
|
+
releaseManifestNamesBuild,
|
|
50
|
+
releasesRecordFromResponse,
|
|
51
|
+
type RecordedBuild,
|
|
52
|
+
type ReleasesRecord,
|
|
53
|
+
} from "./release-record";
|
|
54
|
+
import {
|
|
55
|
+
buildCalibration,
|
|
56
|
+
loadReleases,
|
|
57
|
+
microcosmRepo,
|
|
58
|
+
microcosmRevision,
|
|
59
|
+
} from "./latest-artifact";
|
|
60
|
+
import {
|
|
61
|
+
loadStagingRuns,
|
|
62
|
+
resolveStagingCalibrationSource,
|
|
63
|
+
resolveStagingRevisionSha,
|
|
64
|
+
stagingRepository,
|
|
65
|
+
} from "./staging-artifact";
|
|
66
|
+
|
|
67
|
+
type JsonObject = Record<string, unknown>;
|
|
68
|
+
|
|
69
|
+
export interface PublisherOptions {
|
|
70
|
+
country: MicrocosmCountry;
|
|
71
|
+
mode:
|
|
72
|
+
| "latest"
|
|
73
|
+
| "release"
|
|
74
|
+
| "backfill"
|
|
75
|
+
| "staging-finalized"
|
|
76
|
+
| "reconcile-releases"
|
|
77
|
+
| "reconcile-staging"
|
|
78
|
+
| "migrate-release-timestamps";
|
|
79
|
+
releaseId?: string;
|
|
80
|
+
hfCommitSha?: string;
|
|
81
|
+
dryRun?: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface UpstreamLatest {
|
|
85
|
+
releaseId: string;
|
|
86
|
+
/** The `releases/` folder holding its files: releaseId unless a cut tag. */
|
|
87
|
+
releaseDirectory: string;
|
|
88
|
+
hfCommitSha: string;
|
|
89
|
+
updatedAt: string | null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface FetchedArtifact {
|
|
93
|
+
json: JsonObject;
|
|
94
|
+
source: CalibrationTreeSourceArtifact;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const DEFAULT_MAX_RAW_BYTES = 100_000_000;
|
|
98
|
+
const FINAL_STAGING_STATUSES = new Set(["passed", "published", "completed"]);
|
|
99
|
+
|
|
100
|
+
export class CalibrationTreeArtifactSizeError extends Error {}
|
|
101
|
+
|
|
102
|
+
export interface ReconciliationOutcome {
|
|
103
|
+
sourceId: string;
|
|
104
|
+
// `failed`: the source could not be published or repaired; `reason` gives
|
|
105
|
+
// the error. A failed release gets no new manifest entry unless the
|
|
106
|
+
// manifest write was stored and only its read-back check failed; an entry
|
|
107
|
+
// it already had is left as it was, and files uploaded before the error
|
|
108
|
+
// stay under its build ID. A failed staging run can already have its
|
|
109
|
+
// entry, because staging writes the entry before it audits the upload.
|
|
110
|
+
status: "complete" | "published" | "repaired" | "ineligible" | "failed";
|
|
111
|
+
buildArtifactId: string | null;
|
|
112
|
+
reason: string | null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface ReconciliationReport {
|
|
116
|
+
country: MicrocosmCountry;
|
|
117
|
+
sourceKind: "releases" | "staging";
|
|
118
|
+
dryRun: boolean;
|
|
119
|
+
outcomes: ReconciliationOutcome[];
|
|
120
|
+
/**
|
|
121
|
+
* Release reconciliation only: why the latest-release selection could not
|
|
122
|
+
* be read or updated. Absent when it was updated. In a dry run, present
|
|
123
|
+
* only when the upstream pointer cannot be read or names a release that
|
|
124
|
+
* failed in this run.
|
|
125
|
+
*/
|
|
126
|
+
latestReleaseFailure?: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function sha256Text(value: string): string {
|
|
130
|
+
return createHash("sha256").update(value, "utf8").digest("hex");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function object(value: unknown, label: string): JsonObject {
|
|
134
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
135
|
+
throw new Error(`${label} must be a JSON object.`);
|
|
136
|
+
}
|
|
137
|
+
return value as JsonObject;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function releaseId(value: string | undefined): string {
|
|
141
|
+
const result = value?.trim() ?? "";
|
|
142
|
+
if (!isReleaseId(result)) {
|
|
143
|
+
throw new Error("A valid immutable release id is required.");
|
|
144
|
+
}
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// A release id (a release folder or a tag) or a build folder,
|
|
149
|
+
// `<release>/<build>`. Both are interpolated into authenticated Hugging Face
|
|
150
|
+
// URLs.
|
|
151
|
+
function releaseOrBuildFolderId(value: string | undefined): string {
|
|
152
|
+
const result = value?.trim() ?? "";
|
|
153
|
+
if (!isReleaseDirectory(result) || result === "latest") {
|
|
154
|
+
throw new Error("A valid immutable release id is required.");
|
|
155
|
+
}
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function exactCommitSha(value: string | undefined, label: string): string {
|
|
160
|
+
const result = value?.trim().toLowerCase() ?? "";
|
|
161
|
+
if (!/^[0-9a-f]{40,64}$/.test(result)) {
|
|
162
|
+
throw new Error(`${label} must be an exact Hugging Face commit SHA.`);
|
|
163
|
+
}
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function hfHeaders(): HeadersInit | undefined {
|
|
168
|
+
const token = process.env.HF_TOKEN ?? process.env.HUGGINGFACE_TOKEN;
|
|
169
|
+
return token ? { Authorization: `Bearer ${token}` } : undefined;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function sourceUrl(
|
|
173
|
+
country: MicrocosmCountry,
|
|
174
|
+
revision: string,
|
|
175
|
+
path: string,
|
|
176
|
+
): string {
|
|
177
|
+
return `https://huggingface.co/datasets/${microcosmRepo(country)}/resolve/${revision}/${path}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function repoSourceUrl(repo: string, revision: string, path: string): string {
|
|
181
|
+
return `https://huggingface.co/datasets/${repo}/resolve/${revision}/${path}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function fetchJsonArtifactFromRepo(
|
|
185
|
+
repo: string,
|
|
186
|
+
revision: string,
|
|
187
|
+
path: string,
|
|
188
|
+
required: boolean,
|
|
189
|
+
): Promise<FetchedArtifact | null> {
|
|
190
|
+
const response = await fetch(repoSourceUrl(repo, revision, path), {
|
|
191
|
+
headers: hfHeaders(),
|
|
192
|
+
cache: "no-store",
|
|
193
|
+
signal: AbortSignal.timeout(120_000),
|
|
194
|
+
});
|
|
195
|
+
if (response.status === 404 && !required) return null;
|
|
196
|
+
if (!response.ok) {
|
|
197
|
+
throw new Error(`Hugging Face returned ${response.status} for ${path}.`);
|
|
198
|
+
}
|
|
199
|
+
const text = await response.text();
|
|
200
|
+
return {
|
|
201
|
+
json: object(JSON.parse(text), path),
|
|
202
|
+
source: { path, sha256: sha256Text(text) },
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function fetchJsonArtifact(
|
|
207
|
+
country: MicrocosmCountry,
|
|
208
|
+
revision: string,
|
|
209
|
+
path: string,
|
|
210
|
+
required: boolean,
|
|
211
|
+
): Promise<FetchedArtifact | null> {
|
|
212
|
+
return fetchJsonArtifactFromRepo(
|
|
213
|
+
microcosmRepo(country),
|
|
214
|
+
revision,
|
|
215
|
+
path,
|
|
216
|
+
required,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function parsePublisherOptions(argv: string[]): PublisherOptions {
|
|
221
|
+
const values = new Map<string, string>();
|
|
222
|
+
const flags = new Set<string>();
|
|
223
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
224
|
+
const argument = argv[index];
|
|
225
|
+
if (
|
|
226
|
+
argument === "--latest" ||
|
|
227
|
+
argument === "--backfill" ||
|
|
228
|
+
argument === "--staging-finalized" ||
|
|
229
|
+
argument === "--reconcile-releases" ||
|
|
230
|
+
argument === "--reconcile-staging" ||
|
|
231
|
+
argument === "--migrate-release-timestamps" ||
|
|
232
|
+
argument === "--dry-run"
|
|
233
|
+
) {
|
|
234
|
+
flags.add(argument);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (["--country", "--release", "--sha"].includes(argument)) {
|
|
238
|
+
const value = argv[index + 1];
|
|
239
|
+
if (!value || value.startsWith("--")) {
|
|
240
|
+
throw new Error(`${argument} requires a value.`);
|
|
241
|
+
}
|
|
242
|
+
values.set(argument, value);
|
|
243
|
+
index += 1;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
throw new Error(`Unknown publisher argument ${argument}.`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const countryValue = values.get("--country") ?? "us";
|
|
250
|
+
if (!isCountry(countryValue) || !selectableCountries().includes(countryValue)) {
|
|
251
|
+
throw new Error(`Unknown calibration country ${countryValue}.`);
|
|
252
|
+
}
|
|
253
|
+
const selectedModes = [
|
|
254
|
+
flags.has("--latest"),
|
|
255
|
+
flags.has("--backfill"),
|
|
256
|
+
flags.has("--staging-finalized"),
|
|
257
|
+
flags.has("--reconcile-releases"),
|
|
258
|
+
flags.has("--reconcile-staging"),
|
|
259
|
+
flags.has("--migrate-release-timestamps"),
|
|
260
|
+
values.has("--release"),
|
|
261
|
+
].filter(Boolean).length;
|
|
262
|
+
if (selectedModes !== 1) {
|
|
263
|
+
throw new Error(
|
|
264
|
+
"Specify exactly one publication mode.",
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
const dryRun = flags.has("--dry-run") || undefined;
|
|
268
|
+
if (
|
|
269
|
+
dryRun &&
|
|
270
|
+
(flags.has("--latest") || values.has("--release"))
|
|
271
|
+
) {
|
|
272
|
+
throw new Error("--dry-run is supported only by reconciliation modes.");
|
|
273
|
+
}
|
|
274
|
+
if (
|
|
275
|
+
values.has("--sha") &&
|
|
276
|
+
(flags.has("--backfill") || flags.has("--reconcile-releases"))
|
|
277
|
+
) {
|
|
278
|
+
throw new Error("--sha is not valid for release-history reconciliation.");
|
|
279
|
+
}
|
|
280
|
+
if (values.has("--sha") && flags.has("--migrate-release-timestamps")) {
|
|
281
|
+
throw new Error(
|
|
282
|
+
"--sha is not valid for the timestamp migration; it reads each entry's own commit.",
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
if (flags.has("--migrate-release-timestamps")) {
|
|
286
|
+
return { country: countryValue, mode: "migrate-release-timestamps", dryRun };
|
|
287
|
+
}
|
|
288
|
+
if (flags.has("--backfill")) {
|
|
289
|
+
return { country: countryValue, mode: "backfill", dryRun };
|
|
290
|
+
}
|
|
291
|
+
if (flags.has("--staging-finalized")) {
|
|
292
|
+
return {
|
|
293
|
+
country: countryValue,
|
|
294
|
+
mode: "staging-finalized",
|
|
295
|
+
dryRun,
|
|
296
|
+
...(values.has("--sha")
|
|
297
|
+
? { hfCommitSha: exactCommitSha(values.get("--sha"), "--sha") }
|
|
298
|
+
: {}),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
if (flags.has("--reconcile-releases")) {
|
|
302
|
+
return {
|
|
303
|
+
country: countryValue,
|
|
304
|
+
mode: "reconcile-releases",
|
|
305
|
+
dryRun,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
if (flags.has("--reconcile-staging")) {
|
|
309
|
+
return {
|
|
310
|
+
country: countryValue,
|
|
311
|
+
mode: "reconcile-staging",
|
|
312
|
+
dryRun,
|
|
313
|
+
...(values.has("--sha")
|
|
314
|
+
? { hfCommitSha: exactCommitSha(values.get("--sha"), "--sha") }
|
|
315
|
+
: {}),
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
if (flags.has("--latest")) {
|
|
319
|
+
return {
|
|
320
|
+
country: countryValue,
|
|
321
|
+
mode: "latest",
|
|
322
|
+
dryRun,
|
|
323
|
+
...(values.has("--sha")
|
|
324
|
+
? { hfCommitSha: exactCommitSha(values.get("--sha"), "--sha") }
|
|
325
|
+
: {}),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
return {
|
|
329
|
+
country: countryValue,
|
|
330
|
+
mode: "release",
|
|
331
|
+
releaseId: releaseOrBuildFolderId(values.get("--release")),
|
|
332
|
+
dryRun,
|
|
333
|
+
...(values.has("--sha")
|
|
334
|
+
? { hfCommitSha: exactCommitSha(values.get("--sha"), "--sha") }
|
|
335
|
+
: {}),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* The country's current release, as its pointer file names it. A tag the
|
|
341
|
+
* pointer names must pass the same check as in reconciliation: its line
|
|
342
|
+
* folder's release_manifest.json names the tag as its cut. A tag that
|
|
343
|
+
* `manifest` already pins at the same commit was checked when it was
|
|
344
|
+
* published and is not probed again.
|
|
345
|
+
*/
|
|
346
|
+
export async function readUpstreamLatest(
|
|
347
|
+
country: MicrocosmCountry,
|
|
348
|
+
expectedSourceSha?: string,
|
|
349
|
+
manifest?: CalibrationTreeManifest,
|
|
350
|
+
): Promise<UpstreamLatest> {
|
|
351
|
+
const sourceSha = expectedSourceSha
|
|
352
|
+
? exactCommitSha(expectedSourceSha, "Source revision")
|
|
353
|
+
: await resolveHfRevisionSha(country, microcosmRevision(country), 0);
|
|
354
|
+
const path = countryPointerPath(country);
|
|
355
|
+
const response = await fetch(
|
|
356
|
+
sourceUrl(country, sourceSha, path),
|
|
357
|
+
{
|
|
358
|
+
headers: hfHeaders(),
|
|
359
|
+
cache: "no-store",
|
|
360
|
+
signal: AbortSignal.timeout(30_000),
|
|
361
|
+
},
|
|
362
|
+
);
|
|
363
|
+
if (!response.ok) {
|
|
364
|
+
throw new Error(`Hugging Face returned ${response.status} for ${path}.`);
|
|
365
|
+
}
|
|
366
|
+
const pointer = object(await response.json(), path);
|
|
367
|
+
const id = releaseId(
|
|
368
|
+
typeof pointer.release_id === "string" ? pointer.release_id : undefined,
|
|
369
|
+
);
|
|
370
|
+
const updatedAt =
|
|
371
|
+
typeof pointer.updated_at === "string" && Number.isFinite(Date.parse(pointer.updated_at))
|
|
372
|
+
? pointer.updated_at
|
|
373
|
+
: null;
|
|
374
|
+
// A per-line pointer (microcosm#823) names the immutable cut tag the
|
|
375
|
+
// line's current release is read at; the release id is the line folder.
|
|
376
|
+
// A country registered with such a pointer file gets its checks whatever
|
|
377
|
+
// the file contains, so a pointer that drops `revision` is refused rather
|
|
378
|
+
// than read as a folder release.
|
|
379
|
+
if (countryRegistration(country).pointer_path != null || pointer.revision !== undefined) {
|
|
380
|
+
if (pointer.schema_version !== 1) {
|
|
381
|
+
throw new Error(`${path} has unsupported schema_version ${String(pointer.schema_version)}.`);
|
|
382
|
+
}
|
|
383
|
+
if (typeof pointer.line !== "string" || `latest-${pointer.line}.json` !== path) {
|
|
384
|
+
throw new Error(`${path} declares line ${String(pointer.line)}.`);
|
|
385
|
+
}
|
|
386
|
+
if (pointer.revision === undefined) {
|
|
387
|
+
throw new Error(`${path} names no revision for ${id}.`);
|
|
388
|
+
}
|
|
389
|
+
const revision = releaseId(
|
|
390
|
+
typeof pointer.revision === "string" ? pointer.revision : undefined,
|
|
391
|
+
);
|
|
392
|
+
if (parseReleaseCutTag(revision)?.releaseId !== id) {
|
|
393
|
+
throw new Error(`${path} names revision ${revision}, which is not a cut of ${id}.`);
|
|
394
|
+
}
|
|
395
|
+
const commit = await resolveHfRevisionSha(country, revision, 0);
|
|
396
|
+
const pinned = manifest
|
|
397
|
+
? manifestBuildForSource(manifest, country, "release", revision)
|
|
398
|
+
: null;
|
|
399
|
+
const directory = pinned && pinned.hfCommitSha === commit
|
|
400
|
+
? releaseDirectoryOf(pinned) ?? revision
|
|
401
|
+
: await releaseDirectoryForTag(country, revision, commit);
|
|
402
|
+
if (directory !== id) {
|
|
403
|
+
throw new Error(
|
|
404
|
+
`${path} names revision ${revision}, whose files are under releases/${directory}/, not releases/${id}/.`,
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
return {
|
|
408
|
+
releaseId: revision,
|
|
409
|
+
releaseDirectory: id,
|
|
410
|
+
hfCommitSha: commit,
|
|
411
|
+
updatedAt,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
return {
|
|
415
|
+
releaseId: id,
|
|
416
|
+
releaseDirectory: id,
|
|
417
|
+
hfCommitSha: await resolveHfRevisionSha(country, id, 0).catch((error) => {
|
|
418
|
+
if (error instanceof CalibrationReleaseNotFoundError) return sourceSha;
|
|
419
|
+
throw error;
|
|
420
|
+
}),
|
|
421
|
+
updatedAt,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
interface ReleaseCandidate {
|
|
426
|
+
releaseId: string;
|
|
427
|
+
/** The `releases/` folder holding its files: releaseId unless a cut tag. */
|
|
428
|
+
releaseDirectory: string;
|
|
429
|
+
hfCommitSha: string;
|
|
430
|
+
/**
|
|
431
|
+
* A build folder's publication time, as `releases.json` records it (null
|
|
432
|
+
* when that cannot be read as a date-time). Absent for release folders and
|
|
433
|
+
* tags, which are dated by their commit.
|
|
434
|
+
*/
|
|
435
|
+
publishedAt?: string | null;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
interface ReleaseTag {
|
|
439
|
+
name: string;
|
|
440
|
+
/** The commit the tag points at. */
|
|
441
|
+
commit: string;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* The repository's release tags, each with the commit it points at, and the
|
|
446
|
+
* error for each tag whose commit could not be read.
|
|
447
|
+
*
|
|
448
|
+
* `/refs` gives an annotated tag's `targetCommit` as the ID of the tag
|
|
449
|
+
* object, not of its commit. Hugging Face serves files at that ID, but its
|
|
450
|
+
* commit log read there starts at the tag's commit, so the build could not
|
|
451
|
+
* be dated, and a build pinned to it would not match one pinned to the
|
|
452
|
+
* commit. Microcosm makes an annotated tag whenever it publishes a release
|
|
453
|
+
* with a tag message (microcosm#1301), so each tag's commit is read from
|
|
454
|
+
* `/revision/<tag>`, which follows an annotated tag to its commit: one
|
|
455
|
+
* request per tag, made concurrently. A failed lookup is returned rather
|
|
456
|
+
* than thrown, so it fails only its own release.
|
|
457
|
+
*/
|
|
458
|
+
async function listReleaseTags(
|
|
459
|
+
country: MicrocosmCountry,
|
|
460
|
+
): Promise<{ tags: ReleaseTag[]; unreadable: Map<string, string> }> {
|
|
461
|
+
const response = await fetch(
|
|
462
|
+
`https://huggingface.co/api/datasets/${microcosmRepo(country)}/refs`,
|
|
463
|
+
{
|
|
464
|
+
headers: hfHeaders(),
|
|
465
|
+
cache: "no-store",
|
|
466
|
+
signal: AbortSignal.timeout(30_000),
|
|
467
|
+
},
|
|
468
|
+
);
|
|
469
|
+
if (!response.ok) {
|
|
470
|
+
throw new Error(`Hugging Face returned ${response.status} while listing release tags.`);
|
|
471
|
+
}
|
|
472
|
+
const payload = object(await response.json(), "Hugging Face refs");
|
|
473
|
+
if (!Array.isArray(payload.tags)) {
|
|
474
|
+
throw new Error("Hugging Face refs response has no tag list.");
|
|
475
|
+
}
|
|
476
|
+
const names = payload.tags.flatMap((value) => {
|
|
477
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
|
|
478
|
+
const { name } = value as JsonObject;
|
|
479
|
+
// A name the revision lookup refuses, such as `latest`, is never a release.
|
|
480
|
+
return isReleaseId(name) ? [name] : [];
|
|
481
|
+
});
|
|
482
|
+
const lookups = await Promise.allSettled(
|
|
483
|
+
names.map((name) => resolveHfRevisionSha(country, name, 0)),
|
|
484
|
+
);
|
|
485
|
+
const tags: ReleaseTag[] = [];
|
|
486
|
+
const unreadable = new Map<string, string>();
|
|
487
|
+
lookups.forEach((lookup, index) => {
|
|
488
|
+
if (lookup.status === "fulfilled") {
|
|
489
|
+
tags.push({ name: names[index], commit: lookup.value });
|
|
490
|
+
} else {
|
|
491
|
+
unreadable.set(
|
|
492
|
+
names[index],
|
|
493
|
+
lookup.reason instanceof Error ? lookup.reason.message : String(lookup.reason),
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
return { tags, unreadable };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function releaseFolderHasDiagnostics(
|
|
501
|
+
country: MicrocosmCountry,
|
|
502
|
+
commit: string,
|
|
503
|
+
directory: string,
|
|
504
|
+
): Promise<boolean> {
|
|
505
|
+
const response = await fetch(
|
|
506
|
+
`https://huggingface.co/api/datasets/${microcosmRepo(country)}/tree/` +
|
|
507
|
+
`${commit}/releases/${encodeURIComponent(directory)}?recursive=false`,
|
|
508
|
+
{
|
|
509
|
+
headers: hfHeaders(),
|
|
510
|
+
cache: "no-store",
|
|
511
|
+
signal: AbortSignal.timeout(30_000),
|
|
512
|
+
},
|
|
513
|
+
);
|
|
514
|
+
if (response.status === 404) return false;
|
|
515
|
+
if (!response.ok) {
|
|
516
|
+
throw new Error(
|
|
517
|
+
`Hugging Face returned ${response.status} while inspecting releases/${directory} at ${commit}.`,
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
const entries = await response.json();
|
|
521
|
+
return Array.isArray(entries) && entries.some((value) => {
|
|
522
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
523
|
+
return (value as JsonObject).path ===
|
|
524
|
+
`releases/${directory}/calibration_diagnostics.json`;
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
type TaggedRelease =
|
|
529
|
+
| { releaseDirectory: string; reason?: never }
|
|
530
|
+
| { releaseDirectory?: never; reason: string };
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Where a tag's release files live. A tag named like its folder is a release
|
|
534
|
+
* (`releases/<tag>/`). A Microcosm cut tag (microcosm#823) keeps its files
|
|
535
|
+
* under its line's folder, `releases/<release id>/`, and counts only when
|
|
536
|
+
* that folder's release_manifest.json names the tag as its cut, so a stray
|
|
537
|
+
* tag can never publish another cut's files under its own name.
|
|
538
|
+
*/
|
|
539
|
+
async function resolveTaggedReleaseDirectory(
|
|
540
|
+
country: MicrocosmCountry,
|
|
541
|
+
tag: ReleaseTag,
|
|
542
|
+
): Promise<TaggedRelease> {
|
|
543
|
+
if (await releaseFolderHasDiagnostics(country, tag.commit, tag.name)) {
|
|
544
|
+
return { releaseDirectory: tag.name };
|
|
545
|
+
}
|
|
546
|
+
const cut = parseReleaseCutTag(tag.name);
|
|
547
|
+
if (!cut) return { reason: "Release has no calibration diagnostics." };
|
|
548
|
+
if (!await releaseFolderHasDiagnostics(country, tag.commit, cut.releaseId)) {
|
|
549
|
+
return {
|
|
550
|
+
reason: `Release cut has no calibration diagnostics under releases/${cut.releaseId}/.`,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
const releaseManifest = await fetchJsonArtifact(
|
|
554
|
+
country,
|
|
555
|
+
tag.commit,
|
|
556
|
+
`releases/${cut.releaseId}/release_manifest.json`,
|
|
557
|
+
false,
|
|
558
|
+
);
|
|
559
|
+
const build = releaseManifest?.json.build;
|
|
560
|
+
const cutTag = build && typeof build === "object" && !Array.isArray(build)
|
|
561
|
+
? (build as JsonObject).cut_tag
|
|
562
|
+
: undefined;
|
|
563
|
+
if (cutTag !== tag.name) {
|
|
564
|
+
return {
|
|
565
|
+
reason: `releases/${cut.releaseId}/release_manifest.json at this tag does not name it as its cut.`,
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
return { releaseDirectory: cut.releaseId };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
async function enumerateReleaseCandidates(
|
|
572
|
+
country: MicrocosmCountry,
|
|
573
|
+
manifest: CalibrationTreeManifest,
|
|
574
|
+
): Promise<{
|
|
575
|
+
candidates: ReleaseCandidate[];
|
|
576
|
+
ineligible: ReconciliationOutcome[];
|
|
577
|
+
/** Releases whose commit could not be read, from their tag or their folder. */
|
|
578
|
+
failed: ReconciliationOutcome[];
|
|
579
|
+
}> {
|
|
580
|
+
const [releases, { tags, unreadable }] = await Promise.all([
|
|
581
|
+
loadReleases(0, country),
|
|
582
|
+
listReleaseTags(country),
|
|
583
|
+
]);
|
|
584
|
+
const tagsByName = new Map(tags.map((tag) => [tag.name, tag]));
|
|
585
|
+
const calibratedFolders = new Set(
|
|
586
|
+
releases
|
|
587
|
+
.filter((release) => release.has_calibration)
|
|
588
|
+
.map((release) => release.release_id),
|
|
589
|
+
);
|
|
590
|
+
const candidates = new Map<string, ReleaseCandidate>();
|
|
591
|
+
const ineligible: ReconciliationOutcome[] = [];
|
|
592
|
+
// A release whose commit cannot be read fails on its own and keeps any
|
|
593
|
+
// build it already has, as a release that cannot be published does.
|
|
594
|
+
const failed: ReconciliationOutcome[] = [];
|
|
595
|
+
const reportFailure = (sourceId: string, reason: string) => failed.push({
|
|
596
|
+
sourceId,
|
|
597
|
+
status: "failed",
|
|
598
|
+
buildArtifactId:
|
|
599
|
+
manifestBuildForSource(manifest, country, "release", sourceId)?.buildArtifactId ?? null,
|
|
600
|
+
reason,
|
|
601
|
+
});
|
|
602
|
+
for (const [name, reason] of unreadable) reportFailure(name, reason);
|
|
603
|
+
|
|
604
|
+
// Tags are resolved first: a verified cut tag makes its line's folder on
|
|
605
|
+
// main a mutable copy of whichever cut was published last.
|
|
606
|
+
const taggedReleases = new Map<string, TaggedRelease>();
|
|
607
|
+
for (const tag of tags) {
|
|
608
|
+
if (calibratedFolders.has(tag.name)) continue;
|
|
609
|
+
// A tag the manifest already pins at this commit keeps its folder; it is
|
|
610
|
+
// not probed again on every reconciliation.
|
|
611
|
+
const known = manifestBuildForSource(manifest, country, "release", tag.name);
|
|
612
|
+
taggedReleases.set(
|
|
613
|
+
tag.name,
|
|
614
|
+
known && known.hfCommitSha === tag.commit
|
|
615
|
+
? { releaseDirectory: releaseDirectoryOf(known) ?? tag.name }
|
|
616
|
+
: await resolveTaggedReleaseDirectory(country, tag),
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
const lineFolders = new Set(
|
|
620
|
+
[...taggedReleases].flatMap(([name, tagged]) =>
|
|
621
|
+
tagged.releaseDirectory && tagged.releaseDirectory !== name
|
|
622
|
+
? [tagged.releaseDirectory]
|
|
623
|
+
: []
|
|
624
|
+
),
|
|
625
|
+
);
|
|
626
|
+
// A tag named as one snapshot of a release series whose commit could not
|
|
627
|
+
// be read may be the series' only such tag, so the series' folder is held
|
|
628
|
+
// back in this run: it is not published from main as a release of its own.
|
|
629
|
+
// Each held-back folder maps to the tags that hold it back.
|
|
630
|
+
const heldBackFolders = new Map<string, string[]>();
|
|
631
|
+
for (const name of unreadable.keys()) {
|
|
632
|
+
const folder = parseReleaseCutTag(name)?.releaseId;
|
|
633
|
+
if (folder) heldBackFolders.set(folder, [...(heldBackFolders.get(folder) ?? []), name]);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
for (const release of releases) {
|
|
637
|
+
// A folder whose tag's commit could not be read is reported as failed,
|
|
638
|
+
// not pinned to another commit.
|
|
639
|
+
if (!release.has_calibration || unreadable.has(release.release_id)) continue;
|
|
640
|
+
const existing = manifestBuildForSource(
|
|
641
|
+
manifest,
|
|
642
|
+
country,
|
|
643
|
+
"release",
|
|
644
|
+
release.release_id,
|
|
645
|
+
);
|
|
646
|
+
const heldBackBy = heldBackFolders.get(release.release_id);
|
|
647
|
+
if (lineFolders.has(release.release_id) || heldBackBy) {
|
|
648
|
+
// The cut tags publish the line. A build already published from the
|
|
649
|
+
// folder stays pinned at its own commit, so it audits and repairs as
|
|
650
|
+
// before instead of following the folder to a newer cut.
|
|
651
|
+
if (existing) {
|
|
652
|
+
candidates.set(release.release_id, {
|
|
653
|
+
releaseId: release.release_id,
|
|
654
|
+
releaseDirectory: releaseDirectoryOf(existing) ?? release.release_id,
|
|
655
|
+
hfCommitSha: existing.hfCommitSha,
|
|
656
|
+
});
|
|
657
|
+
} else {
|
|
658
|
+
ineligible.push({
|
|
659
|
+
sourceId: release.release_id,
|
|
660
|
+
status: "ineligible",
|
|
661
|
+
buildArtifactId: null,
|
|
662
|
+
// A folder that only a failed lookup holds back is not known to
|
|
663
|
+
// be a series' folder, so it gets that reason instead.
|
|
664
|
+
reason: heldBackBy && !lineFolders.has(release.release_id)
|
|
665
|
+
? `Held back in this run because the commit of its release snapshot ` +
|
|
666
|
+
`${heldBackBy.length === 1 ? "tag" : "tags"} ${heldBackBy.join(", ")} ` +
|
|
667
|
+
"could not be read. The next run retries the lookup."
|
|
668
|
+
: "A release line folder; its cut tags are published instead.",
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
// The build pins the tag's commit, or the newest commit behind the
|
|
674
|
+
// release directory when no tag exists. Its publication timestamp is
|
|
675
|
+
// that commit's date, read by publishRelease when a bundle is built.
|
|
676
|
+
let hfCommitSha = tagsByName.get(release.release_id)?.commit;
|
|
677
|
+
if (hfCommitSha == null) {
|
|
678
|
+
try {
|
|
679
|
+
hfCommitSha = await resolveHfReleaseDirectorySha(country, release.release_id, 0);
|
|
680
|
+
} catch (error) {
|
|
681
|
+
reportFailure(release.release_id, error instanceof Error ? error.message : String(error));
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
candidates.set(release.release_id, {
|
|
686
|
+
releaseId: release.release_id,
|
|
687
|
+
releaseDirectory: release.release_id,
|
|
688
|
+
hfCommitSha,
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
for (const [name, tagged] of taggedReleases) {
|
|
692
|
+
if (tagged.releaseDirectory) {
|
|
693
|
+
candidates.set(name, {
|
|
694
|
+
releaseId: name,
|
|
695
|
+
releaseDirectory: tagged.releaseDirectory,
|
|
696
|
+
hfCommitSha: tagsByName.get(name)!.commit,
|
|
697
|
+
});
|
|
698
|
+
} else {
|
|
699
|
+
ineligible.push({
|
|
700
|
+
sourceId: name,
|
|
701
|
+
status: "ineligible",
|
|
702
|
+
buildArtifactId: null,
|
|
703
|
+
reason: tagged.reason ?? null,
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
for (const release of releases) {
|
|
708
|
+
if (
|
|
709
|
+
!release.has_calibration &&
|
|
710
|
+
!unreadable.has(release.release_id) &&
|
|
711
|
+
!ineligible.some((item) => item.sourceId === release.release_id)
|
|
712
|
+
) {
|
|
713
|
+
ineligible.push({
|
|
714
|
+
sourceId: release.release_id,
|
|
715
|
+
status: "ineligible",
|
|
716
|
+
buildArtifactId: null,
|
|
717
|
+
reason: "Release has no calibration diagnostics.",
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return {
|
|
722
|
+
candidates: [...candidates.values()].sort((left, right) =>
|
|
723
|
+
left.releaseId.localeCompare(right.releaseId)
|
|
724
|
+
),
|
|
725
|
+
ineligible,
|
|
726
|
+
failed,
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// -- Build folders and releases.json ----------------------------------------
|
|
731
|
+
//
|
|
732
|
+
// A dataset repository with a root `releases.json` (microcosm-emitter#24)
|
|
733
|
+
// publishes each build in `releases/<release>/<build>/`, never changed after
|
|
734
|
+
// publishing, and lists every build and each release's current build in that
|
|
735
|
+
// record. Its builds are read from the record alone: no tags, no pointer
|
|
736
|
+
// files, no listing of `releases/`. A repository without `releases.json` is
|
|
737
|
+
// read from its tags and pointer files, as before. Which applies is read
|
|
738
|
+
// from the repository at the commit being published, never from the
|
|
739
|
+
// country's registration. A build's release id in the dashboard manifest is
|
|
740
|
+
// its folder, `<release>/<build>`.
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* `releases.json` at an exact commit, checked as the release writer checks
|
|
744
|
+
* it, or null when the repository has none there. Hugging Face's 404 for the
|
|
745
|
+
* file is the only "none"; any other failure, and a record the writer would
|
|
746
|
+
* refuse, throws (releasesRecordFromResponse).
|
|
747
|
+
*/
|
|
748
|
+
async function readReleasesRecord(
|
|
749
|
+
country: MicrocosmCountry,
|
|
750
|
+
commitSha: string,
|
|
751
|
+
): Promise<ReleasesRecord | null> {
|
|
752
|
+
const response = await fetch(sourceUrl(country, commitSha, RELEASES_RECORD_PATH), {
|
|
753
|
+
headers: hfHeaders(),
|
|
754
|
+
cache: "no-store",
|
|
755
|
+
signal: AbortSignal.timeout(30_000),
|
|
756
|
+
});
|
|
757
|
+
return releasesRecordFromResponse(response);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function requireReleasesRecord(
|
|
761
|
+
country: MicrocosmCountry,
|
|
762
|
+
record: ReleasesRecord | null,
|
|
763
|
+
): ReleasesRecord {
|
|
764
|
+
if (!record) {
|
|
765
|
+
throw new Error(`${country}'s dataset repository has no ${RELEASES_RECORD_PATH}.`);
|
|
766
|
+
}
|
|
767
|
+
return record;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// The record's `published_at`, as the dashboard manifest's RFC 3339 form.
|
|
771
|
+
function recordedPublicationTime(publishedAt: string): string | null {
|
|
772
|
+
const instant = Date.parse(publishedAt);
|
|
773
|
+
return Number.isFinite(instant) ? new Date(instant).toISOString() : null;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Where a listed build's files are: its folder at the commit that wrote it.
|
|
778
|
+
* The build counts only when that folder holds calibration diagnostics and
|
|
779
|
+
* its release manifest names the same release and build, so a folder can
|
|
780
|
+
* never publish another build's files under its own name.
|
|
781
|
+
*/
|
|
782
|
+
async function resolveRecordedBuild(
|
|
783
|
+
country: MicrocosmCountry,
|
|
784
|
+
recorded: RecordedBuild,
|
|
785
|
+
revisionSha: string,
|
|
786
|
+
): Promise<{ candidate: ReleaseCandidate; reason?: never } | { candidate?: never; reason: string }> {
|
|
787
|
+
let commit: string;
|
|
788
|
+
try {
|
|
789
|
+
commit = await resolveHfBuildFolderSha(country, recorded.id, revisionSha, 0);
|
|
790
|
+
} catch (error) {
|
|
791
|
+
if (error instanceof CalibrationReleaseNotFoundError) {
|
|
792
|
+
return { reason: `releases/${recorded.id}/ has no calibration diagnostics.` };
|
|
793
|
+
}
|
|
794
|
+
throw error;
|
|
795
|
+
}
|
|
796
|
+
const releaseManifest = await fetchJsonArtifact(
|
|
797
|
+
country,
|
|
798
|
+
commit,
|
|
799
|
+
`releases/${recorded.id}/${RELEASE_MANIFEST_NAME}`,
|
|
800
|
+
false,
|
|
801
|
+
);
|
|
802
|
+
if (!releaseManifestNamesBuild(releaseManifest?.json, recorded.release, recorded.build)) {
|
|
803
|
+
return {
|
|
804
|
+
reason:
|
|
805
|
+
`releases/${recorded.id}/${RELEASE_MANIFEST_NAME} does not name release ` +
|
|
806
|
+
`${recorded.release} and build ${recorded.build}.`,
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
return {
|
|
810
|
+
candidate: {
|
|
811
|
+
releaseId: recorded.id,
|
|
812
|
+
releaseDirectory: recorded.id,
|
|
813
|
+
hfCommitSha: commit,
|
|
814
|
+
publishedAt: recordedPublicationTime(recorded.publishedAt),
|
|
815
|
+
},
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// A build the dashboard manifest already holds keeps the commit and date it
|
|
820
|
+
// was published with; its folder is not listed again.
|
|
821
|
+
function publishedRecordedBuild(
|
|
822
|
+
manifest: CalibrationTreeManifest | undefined,
|
|
823
|
+
country: MicrocosmCountry,
|
|
824
|
+
id: string,
|
|
825
|
+
): ReleaseCandidate | null {
|
|
826
|
+
const existing = manifest ? manifestBuildForSource(manifest, country, "release", id) : null;
|
|
827
|
+
return existing
|
|
828
|
+
? {
|
|
829
|
+
releaseId: id,
|
|
830
|
+
releaseDirectory: id,
|
|
831
|
+
hfCommitSha: existing.hfCommitSha,
|
|
832
|
+
publishedAt: existing.createdAt,
|
|
833
|
+
}
|
|
834
|
+
: null;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
async function enumerateRecordedBuildCandidates(
|
|
838
|
+
country: MicrocosmCountry,
|
|
839
|
+
manifest: CalibrationTreeManifest,
|
|
840
|
+
record: ReleasesRecord,
|
|
841
|
+
headSha: string,
|
|
842
|
+
): Promise<{
|
|
843
|
+
candidates: ReleaseCandidate[];
|
|
844
|
+
ineligible: ReconciliationOutcome[];
|
|
845
|
+
failed: ReconciliationOutcome[];
|
|
846
|
+
}> {
|
|
847
|
+
const candidates: ReleaseCandidate[] = [];
|
|
848
|
+
const ineligible: ReconciliationOutcome[] = [];
|
|
849
|
+
// A build whose folder or release manifest cannot be read fails on its
|
|
850
|
+
// own, as a release whose commit cannot be read does; the other listed
|
|
851
|
+
// builds are still reconciled.
|
|
852
|
+
const failed: ReconciliationOutcome[] = [];
|
|
853
|
+
for (const recorded of recordedBuilds(record)) {
|
|
854
|
+
const published = publishedRecordedBuild(manifest, country, recorded.id);
|
|
855
|
+
if (published) {
|
|
856
|
+
candidates.push(published);
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
let resolved: Awaited<ReturnType<typeof resolveRecordedBuild>>;
|
|
860
|
+
try {
|
|
861
|
+
resolved = await resolveRecordedBuild(country, recorded, headSha);
|
|
862
|
+
} catch (error) {
|
|
863
|
+
failed.push({
|
|
864
|
+
sourceId: recorded.id,
|
|
865
|
+
status: "failed",
|
|
866
|
+
buildArtifactId: null,
|
|
867
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
868
|
+
});
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
if (resolved.candidate) {
|
|
872
|
+
candidates.push(resolved.candidate);
|
|
873
|
+
} else {
|
|
874
|
+
ineligible.push({
|
|
875
|
+
sourceId: recorded.id,
|
|
876
|
+
status: "ineligible",
|
|
877
|
+
buildArtifactId: null,
|
|
878
|
+
reason: resolved.reason,
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
return { candidates, ineligible, failed };
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
async function selectRecordedBuild(
|
|
886
|
+
country: MicrocosmCountry,
|
|
887
|
+
record: ReleasesRecord,
|
|
888
|
+
id: string,
|
|
889
|
+
revisionSha: string,
|
|
890
|
+
manifest?: CalibrationTreeManifest,
|
|
891
|
+
): Promise<Required<ReleaseCandidate>> {
|
|
892
|
+
const recorded = recordedBuilds(record).find((build) => build.id === id);
|
|
893
|
+
if (!recorded) throw new Error(`${RELEASES_RECORD_PATH} does not list build ${id}.`);
|
|
894
|
+
let selected = publishedRecordedBuild(manifest, country, id);
|
|
895
|
+
if (!selected) {
|
|
896
|
+
const resolved = await resolveRecordedBuild(country, recorded, revisionSha);
|
|
897
|
+
if (!resolved.candidate) {
|
|
898
|
+
throw new Error(`Release ${id} cannot be published: ${resolved.reason}`);
|
|
899
|
+
}
|
|
900
|
+
selected = resolved.candidate;
|
|
901
|
+
}
|
|
902
|
+
return { ...selected, publishedAt: selected.publishedAt ?? null };
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function requireCurrentDefaultBuild(record: ReleasesRecord) {
|
|
906
|
+
const current = currentDefaultBuild(record);
|
|
907
|
+
if (!current) {
|
|
908
|
+
throw new Error(`${RELEASES_RECORD_PATH} names no current build of its default release.`);
|
|
909
|
+
}
|
|
910
|
+
return current;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* The country's current build, as `releases.json` at `expectedSourceSha`
|
|
915
|
+
* (or at the head of the configured revision) names it: the current build
|
|
916
|
+
* of the record's default release, pinned to the commit that wrote its
|
|
917
|
+
* folder. A build `manifest` already holds keeps its published commit.
|
|
918
|
+
*/
|
|
919
|
+
export async function readRecordedCurrentBuild(
|
|
920
|
+
country: MicrocosmCountry,
|
|
921
|
+
expectedSourceSha?: string,
|
|
922
|
+
manifest?: CalibrationTreeManifest,
|
|
923
|
+
): Promise<Required<ReleaseCandidate>> {
|
|
924
|
+
const revisionSha = await publicationRevisionSha(country, expectedSourceSha);
|
|
925
|
+
const record = requireReleasesRecord(country, await readReleasesRecord(country, revisionSha));
|
|
926
|
+
return recordedCurrentBuild(country, record, revisionSha, manifest);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function recordedCurrentBuild(
|
|
930
|
+
country: MicrocosmCountry,
|
|
931
|
+
record: ReleasesRecord,
|
|
932
|
+
revisionSha: string,
|
|
933
|
+
manifest?: CalibrationTreeManifest,
|
|
934
|
+
): Promise<Required<ReleaseCandidate>> {
|
|
935
|
+
return selectRecordedBuild(
|
|
936
|
+
country,
|
|
937
|
+
record,
|
|
938
|
+
requireCurrentDefaultBuild(record).id,
|
|
939
|
+
revisionSha,
|
|
940
|
+
manifest,
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// The exact commit a publication reads: the one given, or the head of the
|
|
945
|
+
// country's configured revision.
|
|
946
|
+
async function publicationRevisionSha(
|
|
947
|
+
country: MicrocosmCountry,
|
|
948
|
+
expectedSourceSha?: string,
|
|
949
|
+
): Promise<string> {
|
|
950
|
+
return expectedSourceSha
|
|
951
|
+
? exactCommitSha(expectedSourceSha, "Source revision")
|
|
952
|
+
: resolveHfRevisionSha(country, microcosmRevision(country), 0);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/** The folder a tag publishes from, for the single-release modes. */
|
|
956
|
+
async function releaseDirectoryForTag(
|
|
957
|
+
country: MicrocosmCountry,
|
|
958
|
+
id: string,
|
|
959
|
+
commit: string,
|
|
960
|
+
): Promise<string> {
|
|
961
|
+
const tagged = await resolveTaggedReleaseDirectory(country, {
|
|
962
|
+
name: id,
|
|
963
|
+
commit,
|
|
964
|
+
});
|
|
965
|
+
if (!tagged.releaseDirectory) {
|
|
966
|
+
throw new Error(`Release ${id} cannot be published: ${tagged.reason}`);
|
|
967
|
+
}
|
|
968
|
+
return tagged.releaseDirectory;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
export function manifestBuildForSource(
|
|
972
|
+
manifest: CalibrationTreeManifest,
|
|
973
|
+
country: MicrocosmCountry,
|
|
974
|
+
kind: "release" | "staging",
|
|
975
|
+
sourceId: string,
|
|
976
|
+
): CalibrationTreeManifestEntry | null {
|
|
977
|
+
const matches = (manifest.countries[country]?.builds ?? []).filter((entry) =>
|
|
978
|
+
entry.kind === kind &&
|
|
979
|
+
(kind === "release"
|
|
980
|
+
? entry.releaseId === sourceId
|
|
981
|
+
: entry.stagingRunId === sourceId)
|
|
982
|
+
);
|
|
983
|
+
if (matches.length > 1) {
|
|
984
|
+
throw new Error(`Calibration source ${sourceId} has multiple manifest entries.`);
|
|
985
|
+
}
|
|
986
|
+
return matches[0] ?? null;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
export function enforceConfiguredSizeLimits(file: CalibrationTreeBundleFile): {
|
|
990
|
+
rawBytes: number;
|
|
991
|
+
gzipBytes: number;
|
|
992
|
+
} {
|
|
993
|
+
const { rawBytes, gzipBytes } = file;
|
|
994
|
+
const limits = [
|
|
995
|
+
["CALIBRATION_TREE_MAX_RAW_BYTES", rawBytes, DEFAULT_MAX_RAW_BYTES],
|
|
996
|
+
["CALIBRATION_TREE_MAX_GZIP_BYTES", gzipBytes],
|
|
997
|
+
] as const;
|
|
998
|
+
for (const [name, actual, defaultLimit] of limits) {
|
|
999
|
+
const configured = process.env[name]?.trim();
|
|
1000
|
+
const limit = configured ? Number(configured) : defaultLimit;
|
|
1001
|
+
if (limit == null) continue;
|
|
1002
|
+
if (!Number.isSafeInteger(limit) || limit <= 0) {
|
|
1003
|
+
throw new Error(`${name} must be a positive integer.`);
|
|
1004
|
+
}
|
|
1005
|
+
if (actual > limit) {
|
|
1006
|
+
throw new CalibrationTreeArtifactSizeError(
|
|
1007
|
+
`${file.path} exceeded ${name}: ${actual} bytes is greater than ${limit}.`,
|
|
1008
|
+
);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
return { rawBytes, gzipBytes };
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
export async function resolvePublicationSourceSha(
|
|
1015
|
+
country: MicrocosmCountry,
|
|
1016
|
+
id: string,
|
|
1017
|
+
expectedSha: string | undefined,
|
|
1018
|
+
allowReleaseDirectoryLookup: boolean,
|
|
1019
|
+
): Promise<string> {
|
|
1020
|
+
try {
|
|
1021
|
+
return await resolveHfRevisionSha(country, id, 0);
|
|
1022
|
+
} catch (error) {
|
|
1023
|
+
if (error instanceof CalibrationReleaseNotFoundError && expectedSha) {
|
|
1024
|
+
return exactCommitSha(expectedSha, "Fallback source revision");
|
|
1025
|
+
}
|
|
1026
|
+
if (
|
|
1027
|
+
error instanceof CalibrationReleaseNotFoundError &&
|
|
1028
|
+
allowReleaseDirectoryLookup
|
|
1029
|
+
) {
|
|
1030
|
+
return resolveHfReleaseDirectorySha(country, id, 0);
|
|
1031
|
+
}
|
|
1032
|
+
throw error;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
interface PublishReleaseOptions {
|
|
1037
|
+
country: MicrocosmCountry;
|
|
1038
|
+
/** The build's release id: its folder name, or a Microcosm cut tag. */
|
|
1039
|
+
id: string;
|
|
1040
|
+
/** The `releases/` folder holding its files; defaults to id. */
|
|
1041
|
+
releaseDirectory?: string;
|
|
1042
|
+
expectedSha: string | undefined;
|
|
1043
|
+
blobToken: string;
|
|
1044
|
+
allowMissingReleaseTag?: boolean;
|
|
1045
|
+
useExpectedShaExactly?: boolean;
|
|
1046
|
+
buildArtifactId?: string;
|
|
1047
|
+
dryRun?: boolean;
|
|
1048
|
+
expectedIndexSha256?: string;
|
|
1049
|
+
indexCreatedAt?: string | null;
|
|
1050
|
+
uploadBundle: CalibrationManifestStore["uploadBundle"];
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
async function publishRelease(options: PublishReleaseOptions) {
|
|
1054
|
+
const {
|
|
1055
|
+
country,
|
|
1056
|
+
id,
|
|
1057
|
+
expectedSha,
|
|
1058
|
+
blobToken,
|
|
1059
|
+
allowMissingReleaseTag = false,
|
|
1060
|
+
useExpectedShaExactly = false,
|
|
1061
|
+
buildArtifactId,
|
|
1062
|
+
dryRun = false,
|
|
1063
|
+
expectedIndexSha256,
|
|
1064
|
+
indexCreatedAt,
|
|
1065
|
+
uploadBundle,
|
|
1066
|
+
} = options;
|
|
1067
|
+
// The folder is interpolated into authenticated Hugging Face URLs.
|
|
1068
|
+
const releaseDirectory = releaseOrBuildFolderId(options.releaseDirectory ?? id);
|
|
1069
|
+
const resolvedSha = useExpectedShaExactly && expectedSha
|
|
1070
|
+
? exactCommitSha(expectedSha, "Exact source revision")
|
|
1071
|
+
: await resolvePublicationSourceSha(
|
|
1072
|
+
country,
|
|
1073
|
+
id,
|
|
1074
|
+
expectedSha,
|
|
1075
|
+
allowMissingReleaseTag,
|
|
1076
|
+
);
|
|
1077
|
+
if (expectedSha && expectedSha.toLowerCase() !== resolvedSha) {
|
|
1078
|
+
throw new Error(
|
|
1079
|
+
`Webhook commit ${expectedSha} does not match release ${id} commit ${resolvedSha}.`,
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
// A new publication stamps the index with the resolved commit's date, the
|
|
1083
|
+
// one publication timestamp. A repair of an existing build reproduces the
|
|
1084
|
+
// immutable index instead, so it carries the stored entry's createdAt
|
|
1085
|
+
// (possibly null) and the rebuilt digest matches expectedIndexSha256.
|
|
1086
|
+
const publishedAt = indexCreatedAt === undefined
|
|
1087
|
+
? await resolveHfCommitPublishedAt(country, resolvedSha, 0)
|
|
1088
|
+
: indexCreatedAt;
|
|
1089
|
+
const prefix = `releases/${releaseDirectory}`;
|
|
1090
|
+
const [diagnostics, buildManifest, releaseManifest, demographics] =
|
|
1091
|
+
await Promise.all([
|
|
1092
|
+
fetchJsonArtifact(
|
|
1093
|
+
country,
|
|
1094
|
+
resolvedSha,
|
|
1095
|
+
`${prefix}/calibration_diagnostics.json`,
|
|
1096
|
+
true,
|
|
1097
|
+
),
|
|
1098
|
+
fetchJsonArtifact(country, resolvedSha, `${prefix}/build_manifest.json`, false),
|
|
1099
|
+
fetchJsonArtifact(country, resolvedSha, `${prefix}/release_manifest.json`, false),
|
|
1100
|
+
fetchJsonArtifact(country, resolvedSha, `${prefix}/demographics.json`, false),
|
|
1101
|
+
]);
|
|
1102
|
+
if (!diagnostics) throw new Error("Calibration diagnostics are missing.");
|
|
1103
|
+
|
|
1104
|
+
const calibration = buildCalibration(
|
|
1105
|
+
diagnostics.json,
|
|
1106
|
+
id,
|
|
1107
|
+
null,
|
|
1108
|
+
buildManifest?.json ?? {},
|
|
1109
|
+
releaseManifest?.json ?? {},
|
|
1110
|
+
demographics?.json ?? {},
|
|
1111
|
+
country,
|
|
1112
|
+
"huggingface_immutable",
|
|
1113
|
+
diagnostics.source.sha256,
|
|
1114
|
+
resolvedSha,
|
|
1115
|
+
);
|
|
1116
|
+
const bundle = buildCalibrationTreeBundle({
|
|
1117
|
+
country,
|
|
1118
|
+
...(buildArtifactId ? { buildArtifactId } : {}),
|
|
1119
|
+
createdAt: publishedAt,
|
|
1120
|
+
releaseId: id,
|
|
1121
|
+
hfRepo: microcosmRepo(country),
|
|
1122
|
+
hfCommitSha: resolvedSha,
|
|
1123
|
+
sourceArtifacts: {
|
|
1124
|
+
calibrationDiagnostics: diagnostics.source,
|
|
1125
|
+
buildManifest: buildManifest?.source ?? null,
|
|
1126
|
+
releaseManifest: releaseManifest?.source ?? null,
|
|
1127
|
+
demographics: demographics?.source ?? null,
|
|
1128
|
+
},
|
|
1129
|
+
rows: calibration.rows,
|
|
1130
|
+
calibrationProvenance: calibration.calibration_provenance,
|
|
1131
|
+
lossAttributionAvailable:
|
|
1132
|
+
calibration.target_loss_attribution.status !== "unavailable",
|
|
1133
|
+
comparison: {
|
|
1134
|
+
// A tag's diagnostics name its folder, and a build folder's may name
|
|
1135
|
+
// its release; like the release reader, keep the tag or the build
|
|
1136
|
+
// folder so two builds of one release compare as different builds.
|
|
1137
|
+
// A folder release keeps the id its diagnostics carry, as before.
|
|
1138
|
+
releaseId: releaseDirectory === id && !isBuildFolderId(id)
|
|
1139
|
+
? calibration.release_id
|
|
1140
|
+
: id,
|
|
1141
|
+
calibrationProvenance: calibration.calibration_provenance,
|
|
1142
|
+
status: calibration.target_loss_attribution.status,
|
|
1143
|
+
aggregate: calibration.target_loss_attribution.aggregate,
|
|
1144
|
+
cap: calibration.target_loss_attribution.cap,
|
|
1145
|
+
basisIdentifier: calibration.target_loss_attribution.basis_identifier,
|
|
1146
|
+
targetRepresentation: calibration.target_schema.target_representation,
|
|
1147
|
+
},
|
|
1148
|
+
});
|
|
1149
|
+
const sizes = Object.fromEntries(
|
|
1150
|
+
bundle.files.map((file) => [file.part, enforceConfiguredSizeLimits(file)]),
|
|
1151
|
+
);
|
|
1152
|
+
const builtIndex = bundle.files.find((candidate) => candidate.part === "index");
|
|
1153
|
+
if (!builtIndex) throw new Error("Calibration tree bundle has no index.");
|
|
1154
|
+
if (expectedIndexSha256 && builtIndex.sha256 !== expectedIndexSha256) {
|
|
1155
|
+
throw new Error(
|
|
1156
|
+
`Rebuilt calibration index differs from immutable manifest entry ${buildArtifactId}.`,
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
const stored = dryRun
|
|
1160
|
+
? {
|
|
1161
|
+
files: bundle.files.map((file) => ({
|
|
1162
|
+
pathname: file.path,
|
|
1163
|
+
sha256: file.sha256,
|
|
1164
|
+
bytes: file.rawBytes,
|
|
1165
|
+
created: false,
|
|
1166
|
+
})),
|
|
1167
|
+
index: (() => {
|
|
1168
|
+
return {
|
|
1169
|
+
pathname: builtIndex.path,
|
|
1170
|
+
sha256: builtIndex.sha256,
|
|
1171
|
+
bytes: builtIndex.rawBytes,
|
|
1172
|
+
created: false,
|
|
1173
|
+
};
|
|
1174
|
+
})(),
|
|
1175
|
+
}
|
|
1176
|
+
: await uploadBundle(bundle);
|
|
1177
|
+
return { bundle, stored, sizes, releaseDirectory };
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
async function promoteIfCurrent(
|
|
1181
|
+
country: MicrocosmCountry,
|
|
1182
|
+
candidate: Awaited<ReturnType<typeof publishRelease>>,
|
|
1183
|
+
entry: CalibrationTreeManifestEntry,
|
|
1184
|
+
store: CalibrationManifestStore,
|
|
1185
|
+
): Promise<boolean> {
|
|
1186
|
+
const headSha = await publicationRevisionSha(country);
|
|
1187
|
+
const record = await readReleasesRecord(country, headSha);
|
|
1188
|
+
const published = candidate.bundle.index.build;
|
|
1189
|
+
let latest: { releaseId: string; hfCommitSha: string };
|
|
1190
|
+
if (record) {
|
|
1191
|
+
// Names first: another build's folder is never read for this check.
|
|
1192
|
+
const current = currentDefaultBuild(record);
|
|
1193
|
+
if (!current || current.id !== published.releaseId) return false;
|
|
1194
|
+
try {
|
|
1195
|
+
latest = await selectRecordedBuild(country, record, current.id, headSha);
|
|
1196
|
+
} catch (error) {
|
|
1197
|
+
// The build is registered; the next reconciliation moves the latest
|
|
1198
|
+
// release once the current build's folder can be read.
|
|
1199
|
+
console.warn(
|
|
1200
|
+
`The latest release was not moved to ${current.id}: ` +
|
|
1201
|
+
`${error instanceof Error ? error.message : String(error)}`,
|
|
1202
|
+
);
|
|
1203
|
+
return false;
|
|
1204
|
+
}
|
|
1205
|
+
} else {
|
|
1206
|
+
latest = await readUpstreamLatest(country, headSha);
|
|
1207
|
+
}
|
|
1208
|
+
if (latest.releaseId !== published.releaseId || latest.hfCommitSha !== published.hfCommitSha) {
|
|
1209
|
+
return false;
|
|
1210
|
+
}
|
|
1211
|
+
await store.updateManifest(entry, true);
|
|
1212
|
+
return true;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
function releaseManifestEntry(
|
|
1216
|
+
published: Awaited<ReturnType<typeof publishRelease>>,
|
|
1217
|
+
): CalibrationTreeManifestEntry {
|
|
1218
|
+
const build = published.bundle.index.build;
|
|
1219
|
+
if (!build.releaseId || !build.hfRepo || !build.hfCommitSha) {
|
|
1220
|
+
throw new Error("Published release build is missing release provenance.");
|
|
1221
|
+
}
|
|
1222
|
+
return {
|
|
1223
|
+
buildArtifactId: build.buildArtifactId,
|
|
1224
|
+
kind: "release",
|
|
1225
|
+
sourceId: build.sourceId,
|
|
1226
|
+
label: build.label,
|
|
1227
|
+
releaseId: build.releaseId,
|
|
1228
|
+
// Recorded only for a cut, so a folder-named entry stays as it was.
|
|
1229
|
+
...(published.releaseDirectory !== build.releaseId
|
|
1230
|
+
? { releaseDirectory: published.releaseDirectory }
|
|
1231
|
+
: {}),
|
|
1232
|
+
stagingRunId: null,
|
|
1233
|
+
hfRepo: build.hfRepo,
|
|
1234
|
+
hfCommitSha: build.hfCommitSha,
|
|
1235
|
+
treeSchemaVersion: CALIBRATION_TREE_SCHEMA_VERSION,
|
|
1236
|
+
indexSha256: published.stored.index.sha256,
|
|
1237
|
+
indexBytes: published.stored.index.bytes,
|
|
1238
|
+
createdAt: build.createdAt,
|
|
1239
|
+
updatedAt: build.createdAt,
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/** The manifest and bundle store the release modes read and write. */
|
|
1244
|
+
export interface CalibrationManifestStore {
|
|
1245
|
+
readManifest(): Promise<CalibrationTreeManifest>;
|
|
1246
|
+
listBlobs(): Promise<Map<string, ListBlobResultBlob>>;
|
|
1247
|
+
audit(
|
|
1248
|
+
entry: CalibrationTreeManifestEntry,
|
|
1249
|
+
blobs: Map<string, ListBlobResultBlob>,
|
|
1250
|
+
): Promise<CalibrationTreeBuildAudit>;
|
|
1251
|
+
updateManifest(
|
|
1252
|
+
entry: CalibrationTreeManifestEntry,
|
|
1253
|
+
makeLatest?: boolean,
|
|
1254
|
+
): Promise<CalibrationTreeManifest>;
|
|
1255
|
+
/**
|
|
1256
|
+
* Uploads a built bundle. Required: the store is the only route by which
|
|
1257
|
+
* a publication writes to Blob, so a store in a test never falls back to
|
|
1258
|
+
* the real upload.
|
|
1259
|
+
*/
|
|
1260
|
+
uploadBundle(
|
|
1261
|
+
bundle: CalibrationTreeBundle,
|
|
1262
|
+
): ReturnType<typeof uploadCalibrationTreeBundle>;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
function blobCalibrationManifestStore(
|
|
1266
|
+
country: MicrocosmCountry,
|
|
1267
|
+
blobToken: string,
|
|
1268
|
+
): CalibrationManifestStore {
|
|
1269
|
+
return {
|
|
1270
|
+
uploadBundle: (bundle) => uploadCalibrationTreeBundle({ bundle, token: blobToken }),
|
|
1271
|
+
readManifest: async () =>
|
|
1272
|
+
(await readCalibrationTreeManifest({ token: blobToken, consistent: true })).manifest,
|
|
1273
|
+
listBlobs: () => listCalibrationTreeBlobs({ country, token: blobToken }),
|
|
1274
|
+
audit: (entry, blobs) =>
|
|
1275
|
+
auditCalibrationTreeBuild({ country, entry, blobs, token: blobToken }),
|
|
1276
|
+
updateManifest: (entry, makeLatest) =>
|
|
1277
|
+
updateCalibrationTreeManifest({
|
|
1278
|
+
country,
|
|
1279
|
+
entry,
|
|
1280
|
+
token: blobToken,
|
|
1281
|
+
...(makeLatest ? { makeLatest } : {}),
|
|
1282
|
+
}),
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
async function registerReleaseBuild(
|
|
1287
|
+
published: Awaited<ReturnType<typeof publishRelease>>,
|
|
1288
|
+
store: CalibrationManifestStore,
|
|
1289
|
+
): Promise<CalibrationTreeManifestEntry> {
|
|
1290
|
+
const entry = releaseManifestEntry(published);
|
|
1291
|
+
await store.updateManifest(entry);
|
|
1292
|
+
return entry;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
type ResolvedStagingSource = NonNullable<
|
|
1296
|
+
Awaited<ReturnType<typeof resolveStagingCalibrationSource>>
|
|
1297
|
+
>;
|
|
1298
|
+
|
|
1299
|
+
async function publishStagingSource(options: {
|
|
1300
|
+
country: MicrocosmCountry;
|
|
1301
|
+
runId: string;
|
|
1302
|
+
source: ResolvedStagingSource;
|
|
1303
|
+
repository: { repo: string };
|
|
1304
|
+
revisionSha: string;
|
|
1305
|
+
uploadBundle: CalibrationManifestStore["uploadBundle"];
|
|
1306
|
+
existing: CalibrationTreeManifestEntry | null;
|
|
1307
|
+
existingIndex: Awaited<ReturnType<typeof auditCalibrationTreeBuild>>["index"];
|
|
1308
|
+
dryRun: boolean;
|
|
1309
|
+
}) {
|
|
1310
|
+
const {
|
|
1311
|
+
country,
|
|
1312
|
+
runId,
|
|
1313
|
+
source,
|
|
1314
|
+
repository,
|
|
1315
|
+
revisionSha,
|
|
1316
|
+
uploadBundle,
|
|
1317
|
+
existing,
|
|
1318
|
+
existingIndex,
|
|
1319
|
+
dryRun,
|
|
1320
|
+
} = options;
|
|
1321
|
+
const { calibration } = source;
|
|
1322
|
+
const candidateReleaseId = source.candidateReleaseId;
|
|
1323
|
+
const bundle = buildCalibrationTreeBundle({
|
|
1324
|
+
country,
|
|
1325
|
+
...(existing ? { buildArtifactId: existing.buildArtifactId } : {}),
|
|
1326
|
+
buildKind: "staging",
|
|
1327
|
+
sourceId: runId,
|
|
1328
|
+
label: existingIndex?.build.label ?? candidateReleaseId,
|
|
1329
|
+
createdAt: existingIndex?.build.createdAt ?? source.updatedAt,
|
|
1330
|
+
releaseId: existingIndex?.build.releaseId ?? candidateReleaseId,
|
|
1331
|
+
hfRepo: existing?.hfRepo ?? repository.repo,
|
|
1332
|
+
hfCommitSha: existing?.hfCommitSha ?? revisionSha,
|
|
1333
|
+
sourceArtifacts: existingIndex?.build.sourceArtifacts ?? {
|
|
1334
|
+
...source.sourceArtifacts,
|
|
1335
|
+
demographics: null,
|
|
1336
|
+
},
|
|
1337
|
+
rows: calibration.rows,
|
|
1338
|
+
calibrationProvenance: calibration.calibration_provenance,
|
|
1339
|
+
lossAttributionAvailable:
|
|
1340
|
+
calibration.target_loss_attribution.status !== "unavailable",
|
|
1341
|
+
comparison: {
|
|
1342
|
+
releaseId: calibration.release_id,
|
|
1343
|
+
calibrationProvenance: calibration.calibration_provenance,
|
|
1344
|
+
status: calibration.target_loss_attribution.status,
|
|
1345
|
+
aggregate: calibration.target_loss_attribution.aggregate,
|
|
1346
|
+
cap: calibration.target_loss_attribution.cap,
|
|
1347
|
+
basisIdentifier: calibration.target_loss_attribution.basis_identifier,
|
|
1348
|
+
targetRepresentation: calibration.target_schema.target_representation,
|
|
1349
|
+
},
|
|
1350
|
+
});
|
|
1351
|
+
for (const file of bundle.files) enforceConfiguredSizeLimits(file);
|
|
1352
|
+
const indexFile = bundle.files.find((file) => file.part === "index");
|
|
1353
|
+
if (!indexFile) throw new Error("Calibration tree bundle has no index.");
|
|
1354
|
+
if (existing && indexFile.sha256 !== existing.indexSha256) {
|
|
1355
|
+
throw new Error(
|
|
1356
|
+
`Rebuilt staging index differs from immutable manifest entry ${existing.buildArtifactId}.`,
|
|
1357
|
+
);
|
|
1358
|
+
}
|
|
1359
|
+
const stored = dryRun
|
|
1360
|
+
? {
|
|
1361
|
+
files: bundle.files.map((file) => ({
|
|
1362
|
+
pathname: file.path,
|
|
1363
|
+
sha256: file.sha256,
|
|
1364
|
+
bytes: file.rawBytes,
|
|
1365
|
+
created: false,
|
|
1366
|
+
})),
|
|
1367
|
+
index: {
|
|
1368
|
+
pathname: indexFile.path,
|
|
1369
|
+
sha256: indexFile.sha256,
|
|
1370
|
+
bytes: indexFile.rawBytes,
|
|
1371
|
+
created: false,
|
|
1372
|
+
},
|
|
1373
|
+
}
|
|
1374
|
+
: await uploadBundle(bundle);
|
|
1375
|
+
const entry: CalibrationTreeManifestEntry = existing ?? {
|
|
1376
|
+
buildArtifactId: bundle.index.buildArtifactId,
|
|
1377
|
+
kind: "staging",
|
|
1378
|
+
sourceId: runId,
|
|
1379
|
+
label: candidateReleaseId,
|
|
1380
|
+
releaseId: null,
|
|
1381
|
+
stagingRunId: runId,
|
|
1382
|
+
hfRepo: repository.repo,
|
|
1383
|
+
hfCommitSha: revisionSha,
|
|
1384
|
+
treeSchemaVersion: CALIBRATION_TREE_SCHEMA_VERSION,
|
|
1385
|
+
indexSha256: stored.index.sha256,
|
|
1386
|
+
indexBytes: stored.index.bytes,
|
|
1387
|
+
createdAt: source.updatedAt,
|
|
1388
|
+
updatedAt: source.updatedAt,
|
|
1389
|
+
};
|
|
1390
|
+
return { bundle, stored, entry };
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
export async function reconcileStagingBuilds(
|
|
1394
|
+
country: MicrocosmCountry,
|
|
1395
|
+
expectedSha: string | undefined,
|
|
1396
|
+
blobToken: string,
|
|
1397
|
+
dryRun: boolean,
|
|
1398
|
+
store: CalibrationManifestStore = blobCalibrationManifestStore(country, blobToken),
|
|
1399
|
+
): Promise<ReconciliationReport> {
|
|
1400
|
+
const repository = stagingRepository(country);
|
|
1401
|
+
if (!repository) {
|
|
1402
|
+
throw new Error(`Staging is not configured for ${country}.`);
|
|
1403
|
+
}
|
|
1404
|
+
const revisionSha = expectedSha
|
|
1405
|
+
? exactCommitSha(expectedSha, "Staging source revision")
|
|
1406
|
+
: await resolveStagingRevisionSha(country);
|
|
1407
|
+
const runs = await loadStagingRuns(0, country, revisionSha);
|
|
1408
|
+
if (!runs.available) {
|
|
1409
|
+
throw new Error(runs.detail ?? "The staging repository is unavailable.");
|
|
1410
|
+
}
|
|
1411
|
+
if (runs.incompatible_runs.length > 0) {
|
|
1412
|
+
throw new Error(
|
|
1413
|
+
`Staging inventory contains incompatible runs: ${runs.incompatible_runs
|
|
1414
|
+
.map((run) => run.run_id)
|
|
1415
|
+
.join(", ")}.`,
|
|
1416
|
+
);
|
|
1417
|
+
}
|
|
1418
|
+
const finalized = runs.runs.filter((run) =>
|
|
1419
|
+
FINAL_STAGING_STATUSES.has(run.status ?? ""),
|
|
1420
|
+
);
|
|
1421
|
+
const uploadBundle = store.uploadBundle;
|
|
1422
|
+
let manifest = await store.readManifest();
|
|
1423
|
+
let blobs = await store.listBlobs();
|
|
1424
|
+
const outcomes: ReconciliationOutcome[] = [];
|
|
1425
|
+
|
|
1426
|
+
for (const run of finalized) {
|
|
1427
|
+
const existing = manifestBuildForSource(
|
|
1428
|
+
manifest,
|
|
1429
|
+
country,
|
|
1430
|
+
"staging",
|
|
1431
|
+
run.run_id,
|
|
1432
|
+
);
|
|
1433
|
+
// A run whose build cannot be published or repaired is reported with
|
|
1434
|
+
// the reason, and the runs after it are still reconciled: stopping here
|
|
1435
|
+
// would leave every later run unpublished on every later invocation.
|
|
1436
|
+
// `runPublisher` exits with an error once the report is printed.
|
|
1437
|
+
let buildArtifactId = existing?.buildArtifactId ?? null;
|
|
1438
|
+
try {
|
|
1439
|
+
const audit = existing ? await store.audit(existing, blobs) : null;
|
|
1440
|
+
if (existing && audit?.complete) {
|
|
1441
|
+
outcomes.push({
|
|
1442
|
+
sourceId: run.run_id,
|
|
1443
|
+
status: "complete",
|
|
1444
|
+
buildArtifactId: existing.buildArtifactId,
|
|
1445
|
+
reason: null,
|
|
1446
|
+
});
|
|
1447
|
+
continue;
|
|
1448
|
+
}
|
|
1449
|
+
if (audit && !audit.repairable) {
|
|
1450
|
+
throw new Error(
|
|
1451
|
+
`Staging build ${run.run_id} is corrupt and cannot be repaired automatically: ` +
|
|
1452
|
+
audit.reasons.join("; "),
|
|
1453
|
+
);
|
|
1454
|
+
}
|
|
1455
|
+
const source = await resolveStagingCalibrationSource(
|
|
1456
|
+
run.run_id,
|
|
1457
|
+
0,
|
|
1458
|
+
country,
|
|
1459
|
+
existing?.hfCommitSha ?? revisionSha,
|
|
1460
|
+
);
|
|
1461
|
+
if (!source) {
|
|
1462
|
+
if (existing) {
|
|
1463
|
+
throw new Error(
|
|
1464
|
+
`Incomplete staging build ${run.run_id} no longer has calibration diagnostics.`,
|
|
1465
|
+
);
|
|
1466
|
+
}
|
|
1467
|
+
outcomes.push({
|
|
1468
|
+
sourceId: run.run_id,
|
|
1469
|
+
status: "ineligible",
|
|
1470
|
+
buildArtifactId: null,
|
|
1471
|
+
reason: "Finalized staging run has no calibration diagnostics.",
|
|
1472
|
+
});
|
|
1473
|
+
continue;
|
|
1474
|
+
}
|
|
1475
|
+
const published = await publishStagingSource({
|
|
1476
|
+
country,
|
|
1477
|
+
runId: run.run_id,
|
|
1478
|
+
source,
|
|
1479
|
+
repository,
|
|
1480
|
+
revisionSha: existing?.hfCommitSha ?? revisionSha,
|
|
1481
|
+
uploadBundle,
|
|
1482
|
+
existing,
|
|
1483
|
+
existingIndex: audit?.index ?? null,
|
|
1484
|
+
dryRun,
|
|
1485
|
+
});
|
|
1486
|
+
buildArtifactId = published.entry.buildArtifactId;
|
|
1487
|
+
if (!dryRun) {
|
|
1488
|
+
manifest = await store.updateManifest(published.entry);
|
|
1489
|
+
blobs = await store.listBlobs();
|
|
1490
|
+
const verified = await store.audit(published.entry, blobs);
|
|
1491
|
+
if (!verified.complete) {
|
|
1492
|
+
throw new Error(
|
|
1493
|
+
`Staging build ${run.run_id} remains incomplete: ${verified.reasons.join("; ")}`,
|
|
1494
|
+
);
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
outcomes.push({
|
|
1498
|
+
sourceId: run.run_id,
|
|
1499
|
+
status: existing ? "repaired" : "published",
|
|
1500
|
+
buildArtifactId: published.entry.buildArtifactId,
|
|
1501
|
+
reason: dryRun ? "Dry run; no Blob writes performed." : null,
|
|
1502
|
+
});
|
|
1503
|
+
} catch (error) {
|
|
1504
|
+
outcomes.push({
|
|
1505
|
+
sourceId: run.run_id,
|
|
1506
|
+
status: "failed",
|
|
1507
|
+
buildArtifactId,
|
|
1508
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
return { country, sourceKind: "staging", dryRun, outcomes };
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
export async function reconcileReleaseBuilds(
|
|
1516
|
+
country: MicrocosmCountry,
|
|
1517
|
+
blobToken: string,
|
|
1518
|
+
dryRun: boolean,
|
|
1519
|
+
store: CalibrationManifestStore = blobCalibrationManifestStore(country, blobToken),
|
|
1520
|
+
): Promise<ReconciliationReport> {
|
|
1521
|
+
let manifest = await store.readManifest();
|
|
1522
|
+
// releases.json at main's head decides the inventory. A repository with it
|
|
1523
|
+
// reconciles every build it lists; entries published from tags or release
|
|
1524
|
+
// folders stay in the manifest as they are, still readable, and are not
|
|
1525
|
+
// reconciled again. A repository without it reconciles its release folders
|
|
1526
|
+
// and tags. A record that cannot be read or is refused stops the run here,
|
|
1527
|
+
// before anything is published or the latest release is moved.
|
|
1528
|
+
const headSha = await publicationRevisionSha(country);
|
|
1529
|
+
const record = await readReleasesRecord(country, headSha);
|
|
1530
|
+
const inventory = record
|
|
1531
|
+
? await enumerateRecordedBuildCandidates(country, manifest, record, headSha)
|
|
1532
|
+
: await enumerateReleaseCandidates(country, manifest);
|
|
1533
|
+
let blobs = await store.listBlobs();
|
|
1534
|
+
const outcomes = [...inventory.ineligible, ...inventory.failed];
|
|
1535
|
+
|
|
1536
|
+
for (const candidate of inventory.candidates) {
|
|
1537
|
+
const existing = manifestBuildForSource(
|
|
1538
|
+
manifest,
|
|
1539
|
+
country,
|
|
1540
|
+
"release",
|
|
1541
|
+
candidate.releaseId,
|
|
1542
|
+
);
|
|
1543
|
+
// A release whose build cannot be published or repaired is reported with
|
|
1544
|
+
// the reason, and the releases after it are still reconciled: stopping
|
|
1545
|
+
// here would leave every later release, and the latest-release
|
|
1546
|
+
// selection, unreconciled on every later invocation. `runPublisher`
|
|
1547
|
+
// exits with an error once the report is printed. The manifest write is
|
|
1548
|
+
// a release's last step, after its files are uploaded and audited, so a
|
|
1549
|
+
// release that fails gets no new manifest entry (unless that write's own
|
|
1550
|
+
// read-back check fails after the write was stored); a release that
|
|
1551
|
+
// already had an entry keeps it unchanged. The latest-release selection
|
|
1552
|
+
// is never moved to a failed release.
|
|
1553
|
+
try {
|
|
1554
|
+
if (existing && existing.hfCommitSha !== candidate.hfCommitSha) {
|
|
1555
|
+
throw new Error(
|
|
1556
|
+
`Released dataset ${candidate.releaseId} changed from ${existing.hfCommitSha} ` +
|
|
1557
|
+
`to ${candidate.hfCommitSha}.`,
|
|
1558
|
+
);
|
|
1559
|
+
}
|
|
1560
|
+
if (existing && releaseDirectoryOf(existing) !== candidate.releaseDirectory) {
|
|
1561
|
+
throw new Error(
|
|
1562
|
+
`Released dataset ${candidate.releaseId} moved from releases/${releaseDirectoryOf(existing)}/ ` +
|
|
1563
|
+
`to releases/${candidate.releaseDirectory}/.`,
|
|
1564
|
+
);
|
|
1565
|
+
}
|
|
1566
|
+
const audit = existing ? await store.audit(existing, blobs) : null;
|
|
1567
|
+
if (existing && audit?.complete) {
|
|
1568
|
+
// Reconciliation validates and restores stored parts only; a stored
|
|
1569
|
+
// entry's metadata is the timestamp migration's business.
|
|
1570
|
+
outcomes.push({
|
|
1571
|
+
sourceId: candidate.releaseId,
|
|
1572
|
+
status: "complete",
|
|
1573
|
+
buildArtifactId: existing.buildArtifactId,
|
|
1574
|
+
reason: null,
|
|
1575
|
+
});
|
|
1576
|
+
continue;
|
|
1577
|
+
}
|
|
1578
|
+
if (audit && !audit.repairable) {
|
|
1579
|
+
throw new Error(
|
|
1580
|
+
`Release build ${candidate.releaseId} is corrupt and cannot be repaired automatically: ` +
|
|
1581
|
+
audit.reasons.join("; "),
|
|
1582
|
+
);
|
|
1583
|
+
}
|
|
1584
|
+
const published = await publishRelease({
|
|
1585
|
+
country,
|
|
1586
|
+
id: candidate.releaseId,
|
|
1587
|
+
releaseDirectory: candidate.releaseDirectory,
|
|
1588
|
+
expectedSha: existing?.hfCommitSha ?? candidate.hfCommitSha,
|
|
1589
|
+
blobToken,
|
|
1590
|
+
useExpectedShaExactly: true,
|
|
1591
|
+
buildArtifactId: existing?.buildArtifactId,
|
|
1592
|
+
dryRun,
|
|
1593
|
+
expectedIndexSha256: existing?.indexSha256,
|
|
1594
|
+
// A build folder is dated by releases.json; anything else by its commit.
|
|
1595
|
+
indexCreatedAt: existing ? existing.createdAt : candidate.publishedAt,
|
|
1596
|
+
uploadBundle: store.uploadBundle,
|
|
1597
|
+
});
|
|
1598
|
+
const entry = existing ?? releaseManifestEntry(published);
|
|
1599
|
+
if (!dryRun) {
|
|
1600
|
+
// The uploaded files are audited before the manifest names them, so
|
|
1601
|
+
// an incomplete upload never becomes a manifest entry. The audit
|
|
1602
|
+
// reads the build's own index and the Blob listing, not the
|
|
1603
|
+
// manifest.
|
|
1604
|
+
blobs = await store.listBlobs();
|
|
1605
|
+
const verified = await store.audit(entry, blobs);
|
|
1606
|
+
if (!verified.complete) {
|
|
1607
|
+
throw new Error(
|
|
1608
|
+
`Release build ${candidate.releaseId} remains incomplete: ${verified.reasons.join("; ")}`,
|
|
1609
|
+
);
|
|
1610
|
+
}
|
|
1611
|
+
manifest = await store.updateManifest(entry);
|
|
1612
|
+
}
|
|
1613
|
+
outcomes.push({
|
|
1614
|
+
sourceId: candidate.releaseId,
|
|
1615
|
+
status: existing ? "repaired" : "published",
|
|
1616
|
+
buildArtifactId: entry.buildArtifactId,
|
|
1617
|
+
reason: dryRun ? "Dry run; no Blob writes performed." : null,
|
|
1618
|
+
});
|
|
1619
|
+
} catch (error) {
|
|
1620
|
+
outcomes.push({
|
|
1621
|
+
sourceId: candidate.releaseId,
|
|
1622
|
+
status: "failed",
|
|
1623
|
+
buildArtifactId: existing?.buildArtifactId ?? null,
|
|
1624
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
1625
|
+
});
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
const failedReleaseIds = new Set(
|
|
1630
|
+
outcomes.filter((outcome) => outcome.status === "failed").map((outcome) => outcome.sourceId),
|
|
1631
|
+
);
|
|
1632
|
+
const latestReleaseFailure = await selectLatestRelease(
|
|
1633
|
+
country,
|
|
1634
|
+
manifest,
|
|
1635
|
+
failedReleaseIds,
|
|
1636
|
+
dryRun,
|
|
1637
|
+
store,
|
|
1638
|
+
record
|
|
1639
|
+
? async () =>
|
|
1640
|
+
recordedLatest(record, inventory.candidates, [...inventory.ineligible, ...inventory.failed])
|
|
1641
|
+
: (current) => readUpstreamLatest(country, headSha, current),
|
|
1642
|
+
);
|
|
1643
|
+
return {
|
|
1644
|
+
country,
|
|
1645
|
+
sourceKind: "releases",
|
|
1646
|
+
dryRun,
|
|
1647
|
+
outcomes,
|
|
1648
|
+
...(latestReleaseFailure == null ? {} : { latestReleaseFailure }),
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
/**
|
|
1653
|
+
* Point the country's latest-release selection at the release `readLatest`
|
|
1654
|
+
* names (the upstream pointer's release, or releases.json's current build of
|
|
1655
|
+
* its default release), once that release is completely published. Returns
|
|
1656
|
+
* why the selection could not be read or updated, or null. A release that
|
|
1657
|
+
* failed in this reconciliation is never selected, so the selection keeps
|
|
1658
|
+
* naming the build it named before. A dry run reads the latest release and
|
|
1659
|
+
* reports one that failed, but neither checks nor changes the selection
|
|
1660
|
+
* otherwise.
|
|
1661
|
+
*/
|
|
1662
|
+
async function selectLatestRelease(
|
|
1663
|
+
country: MicrocosmCountry,
|
|
1664
|
+
manifest: CalibrationTreeManifest,
|
|
1665
|
+
failedReleaseIds: ReadonlySet<string>,
|
|
1666
|
+
dryRun: boolean,
|
|
1667
|
+
store: CalibrationManifestStore,
|
|
1668
|
+
readLatest: (
|
|
1669
|
+
manifest: CalibrationTreeManifest,
|
|
1670
|
+
) => Promise<{ releaseId: string; hfCommitSha: string }>,
|
|
1671
|
+
): Promise<string | null> {
|
|
1672
|
+
try {
|
|
1673
|
+
const latest = await readLatest(manifest);
|
|
1674
|
+
// Checked before the dry-run return, so a dry run reports a failed
|
|
1675
|
+
// latest release exactly as a run that writes does.
|
|
1676
|
+
if (failedReleaseIds.has(latest.releaseId)) {
|
|
1677
|
+
return `Latest release ${latest.releaseId} failed to reconcile, so the latest-release selection was not changed.`;
|
|
1678
|
+
}
|
|
1679
|
+
// A dry run writes no manifest entries, so it cannot check whether the
|
|
1680
|
+
// release is completely published.
|
|
1681
|
+
if (dryRun) return null;
|
|
1682
|
+
const latestEntry = manifestBuildForSource(
|
|
1683
|
+
manifest,
|
|
1684
|
+
country,
|
|
1685
|
+
"release",
|
|
1686
|
+
latest.releaseId,
|
|
1687
|
+
);
|
|
1688
|
+
if (!latestEntry || latestEntry.hfCommitSha !== latest.hfCommitSha) {
|
|
1689
|
+
return `Latest release ${latest.releaseId}@${latest.hfCommitSha} is not completely published.`;
|
|
1690
|
+
}
|
|
1691
|
+
await store.updateManifest(latestEntry, true);
|
|
1692
|
+
return null;
|
|
1693
|
+
} catch (error) {
|
|
1694
|
+
return error instanceof Error ? error.message : String(error);
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
// The latest release for a repository with releases.json: the record's
|
|
1699
|
+
// current build of its default release, at the commit reconciliation pinned
|
|
1700
|
+
// it to. A current build that was not reconciled gives its reason: why it is
|
|
1701
|
+
// ineligible, or why its folder or release manifest could not be read.
|
|
1702
|
+
function recordedLatest(
|
|
1703
|
+
record: ReleasesRecord,
|
|
1704
|
+
candidates: readonly ReleaseCandidate[],
|
|
1705
|
+
notReconciled: readonly ReconciliationOutcome[],
|
|
1706
|
+
): { releaseId: string; hfCommitSha: string } {
|
|
1707
|
+
const current = requireCurrentDefaultBuild(record);
|
|
1708
|
+
const candidate = candidates.find((entry) => entry.releaseId === current.id);
|
|
1709
|
+
if (!candidate) {
|
|
1710
|
+
const outcome = notReconciled.find((entry) => entry.sourceId === current.id);
|
|
1711
|
+
const reason = outcome?.reason ?? "releases.json lists it, but reconciliation found no candidate.";
|
|
1712
|
+
throw new Error(
|
|
1713
|
+
outcome?.status === "failed"
|
|
1714
|
+
? `Latest release ${current.id} failed to reconcile, so the latest-release selection ` +
|
|
1715
|
+
`was not changed: ${reason}`
|
|
1716
|
+
: `Latest release ${current.id} cannot be published: ${reason}`,
|
|
1717
|
+
);
|
|
1718
|
+
}
|
|
1719
|
+
return { releaseId: current.id, hfCommitSha: candidate.hfCommitSha };
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
interface TimestampMigrationOutcome {
|
|
1723
|
+
buildArtifactId: string;
|
|
1724
|
+
releaseId: string;
|
|
1725
|
+
hfRepo: string;
|
|
1726
|
+
hfCommitSha: string;
|
|
1727
|
+
from: string | null;
|
|
1728
|
+
to: string;
|
|
1729
|
+
status: "current" | "planned" | "migrated";
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
interface TimestampMigrationReport {
|
|
1733
|
+
country: MicrocosmCountry;
|
|
1734
|
+
mode: "migrate-release-timestamps";
|
|
1735
|
+
dryRun: boolean;
|
|
1736
|
+
outcomes: TimestampMigrationOutcome[];
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
/**
|
|
1740
|
+
* Set each stored release entry's updatedAt to its pinned commit's date.
|
|
1741
|
+
*
|
|
1742
|
+
* A one-time metadata migration for entries written by earlier publishers,
|
|
1743
|
+
* which dated a release from its id or tag name and wrote the 1970
|
|
1744
|
+
* placeholder when that failed. It reads only the manifest: each release
|
|
1745
|
+
* entry's own hfRepo and hfCommitSha name the authoritative commit, whose
|
|
1746
|
+
* date becomes updatedAt; the current deployment's repository configuration
|
|
1747
|
+
* plays no part. Nothing else moves: createdAt stays as stored (it is
|
|
1748
|
+
* sealed in the immutable index, null on legacy builds), and the build id,
|
|
1749
|
+
* index digest, shard descriptors and the latest-release selection are
|
|
1750
|
+
* preserved. Staging entries keep their telemetry time. Idempotent; a dry
|
|
1751
|
+
* run lists every old and new value without writing. Remove once the
|
|
1752
|
+
* legacy entries are corrected.
|
|
1753
|
+
*/
|
|
1754
|
+
export async function migrateReleaseTimestamps(
|
|
1755
|
+
country: MicrocosmCountry,
|
|
1756
|
+
blobToken: string,
|
|
1757
|
+
dryRun: boolean,
|
|
1758
|
+
store: CalibrationManifestStore = blobCalibrationManifestStore(country, blobToken),
|
|
1759
|
+
): Promise<TimestampMigrationReport> {
|
|
1760
|
+
const manifest = await store.readManifest();
|
|
1761
|
+
const outcomes: TimestampMigrationOutcome[] = [];
|
|
1762
|
+
for (const entry of manifest.countries[country]?.builds ?? []) {
|
|
1763
|
+
if (entry.kind !== "release" || !entry.releaseId) continue;
|
|
1764
|
+
// A build folder is dated by releases.json, not by the commit that wrote
|
|
1765
|
+
// the folder (a migrated build's folder is written after publication),
|
|
1766
|
+
// and was never stored with the placeholder.
|
|
1767
|
+
if (isBuildFolderId(entry.releaseId)) continue;
|
|
1768
|
+
const publishedAt = await resolveHfRepositoryCommitPublishedAt(
|
|
1769
|
+
entry.hfRepo,
|
|
1770
|
+
entry.hfCommitSha,
|
|
1771
|
+
0,
|
|
1772
|
+
);
|
|
1773
|
+
const outcome = {
|
|
1774
|
+
buildArtifactId: entry.buildArtifactId,
|
|
1775
|
+
releaseId: entry.releaseId,
|
|
1776
|
+
hfRepo: entry.hfRepo,
|
|
1777
|
+
hfCommitSha: entry.hfCommitSha,
|
|
1778
|
+
from: entry.updatedAt,
|
|
1779
|
+
to: publishedAt,
|
|
1780
|
+
};
|
|
1781
|
+
if (entry.updatedAt === publishedAt) {
|
|
1782
|
+
outcomes.push({ ...outcome, status: "current" });
|
|
1783
|
+
continue;
|
|
1784
|
+
}
|
|
1785
|
+
if (!dryRun) {
|
|
1786
|
+
await store.updateManifest({ ...entry, updatedAt: publishedAt });
|
|
1787
|
+
}
|
|
1788
|
+
outcomes.push({ ...outcome, status: dryRun ? "planned" : "migrated" });
|
|
1789
|
+
}
|
|
1790
|
+
return { country, mode: "migrate-release-timestamps", dryRun, outcomes };
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
interface ReleaseSelection {
|
|
1794
|
+
releaseId: string;
|
|
1795
|
+
releaseDirectory: string;
|
|
1796
|
+
hfCommitSha: string | undefined;
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
// `--release <id>`: a Microcosm cut tag publishes from its line's folder at the
|
|
1800
|
+
// tag's commit; any other id publishes from its own folder, as before.
|
|
1801
|
+
async function requestedReleaseSelection(
|
|
1802
|
+
country: MicrocosmCountry,
|
|
1803
|
+
id: string,
|
|
1804
|
+
hfCommitSha: string | undefined,
|
|
1805
|
+
): Promise<ReleaseSelection> {
|
|
1806
|
+
const selection = { releaseId: id, releaseDirectory: id, hfCommitSha };
|
|
1807
|
+
if (!parseReleaseCutTag(id)) return selection;
|
|
1808
|
+
let commit: string;
|
|
1809
|
+
try {
|
|
1810
|
+
commit = await resolveHfRevisionSha(country, id, 0);
|
|
1811
|
+
} catch (error) {
|
|
1812
|
+
if (error instanceof CalibrationReleaseNotFoundError) return selection;
|
|
1813
|
+
throw error;
|
|
1814
|
+
}
|
|
1815
|
+
return {
|
|
1816
|
+
...selection,
|
|
1817
|
+
releaseDirectory: await releaseDirectoryForTag(country, id, commit),
|
|
1818
|
+
};
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
/**
|
|
1822
|
+
* The error a report's failed outcomes, or a latest-release selection that
|
|
1823
|
+
* could not be updated, end the publisher with, if any.
|
|
1824
|
+
*/
|
|
1825
|
+
export function reconciliationFailureMessage(report: ReconciliationReport): string | null {
|
|
1826
|
+
const failed = report.outcomes.filter((outcome) => outcome.status === "failed");
|
|
1827
|
+
const messages: string[] = [];
|
|
1828
|
+
if (failed.length > 0) {
|
|
1829
|
+
const sources = report.sourceKind === "releases" ? "releases" : "staging sources";
|
|
1830
|
+
messages.push(
|
|
1831
|
+
`${failed.length} of ${report.outcomes.length} ${sources} failed to ` +
|
|
1832
|
+
`reconcile: ${failed.map((outcome) => outcome.sourceId).join(", ")}.`,
|
|
1833
|
+
);
|
|
1834
|
+
}
|
|
1835
|
+
if (report.latestReleaseFailure) {
|
|
1836
|
+
messages.push(`The latest release was not selected: ${report.latestReleaseFailure}`);
|
|
1837
|
+
}
|
|
1838
|
+
if (messages.length === 0) return null;
|
|
1839
|
+
return [...messages, "The printed report gives each reason."].join(" ");
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
export interface PublisherDependencies {
|
|
1843
|
+
/** The manifest and Blob store every release mode reads and writes. */
|
|
1844
|
+
store?: CalibrationManifestStore;
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
// `--latest` and `--release <release>/<build>` for a repository with
|
|
1848
|
+
// releases.json: the build the record at `revisionSha` lists, pinned to the
|
|
1849
|
+
// commit that wrote its folder.
|
|
1850
|
+
async function requestedRecordedBuild(
|
|
1851
|
+
country: MicrocosmCountry,
|
|
1852
|
+
options: PublisherOptions,
|
|
1853
|
+
record: ReleasesRecord,
|
|
1854
|
+
revisionSha: string,
|
|
1855
|
+
): Promise<Required<ReleaseCandidate>> {
|
|
1856
|
+
if (options.mode === "latest") {
|
|
1857
|
+
return recordedCurrentBuild(country, record, revisionSha);
|
|
1858
|
+
}
|
|
1859
|
+
const id = releaseOrBuildFolderId(options.releaseId);
|
|
1860
|
+
if (!isBuildFolderId(id)) {
|
|
1861
|
+
throw new Error(
|
|
1862
|
+
`${country}'s dataset repository has ${RELEASES_RECORD_PATH}; --release takes ` +
|
|
1863
|
+
`<release>/<build>, not ${id}.`,
|
|
1864
|
+
);
|
|
1865
|
+
}
|
|
1866
|
+
return selectRecordedBuild(country, record, id, revisionSha);
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
export async function runPublisher(
|
|
1870
|
+
options: PublisherOptions,
|
|
1871
|
+
dependencies: PublisherDependencies = {},
|
|
1872
|
+
): Promise<void> {
|
|
1873
|
+
const blobToken = process.env.BLOB_READ_WRITE_TOKEN?.trim();
|
|
1874
|
+
if (!blobToken) throw new Error("BLOB_READ_WRITE_TOKEN is required.");
|
|
1875
|
+
const store = dependencies.store ?? blobCalibrationManifestStore(options.country, blobToken);
|
|
1876
|
+
|
|
1877
|
+
if (options.mode === "migrate-release-timestamps") {
|
|
1878
|
+
const report = await migrateReleaseTimestamps(
|
|
1879
|
+
options.country,
|
|
1880
|
+
blobToken,
|
|
1881
|
+
options.dryRun === true,
|
|
1882
|
+
store,
|
|
1883
|
+
);
|
|
1884
|
+
console.log(JSON.stringify(report));
|
|
1885
|
+
return;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
if (
|
|
1889
|
+
options.mode === "staging-finalized" ||
|
|
1890
|
+
options.mode === "reconcile-staging"
|
|
1891
|
+
) {
|
|
1892
|
+
const report = await reconcileStagingBuilds(
|
|
1893
|
+
options.country,
|
|
1894
|
+
options.hfCommitSha,
|
|
1895
|
+
blobToken,
|
|
1896
|
+
options.dryRun === true,
|
|
1897
|
+
store,
|
|
1898
|
+
);
|
|
1899
|
+
console.log(JSON.stringify(report));
|
|
1900
|
+
const failure = reconciliationFailureMessage(report);
|
|
1901
|
+
if (failure) throw new Error(failure);
|
|
1902
|
+
return;
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
if (
|
|
1906
|
+
options.mode === "backfill" ||
|
|
1907
|
+
options.mode === "reconcile-releases"
|
|
1908
|
+
) {
|
|
1909
|
+
const report = await reconcileReleaseBuilds(
|
|
1910
|
+
options.country,
|
|
1911
|
+
blobToken,
|
|
1912
|
+
options.dryRun === true,
|
|
1913
|
+
store,
|
|
1914
|
+
);
|
|
1915
|
+
console.log(JSON.stringify(report));
|
|
1916
|
+
const failure = reconciliationFailureMessage(report);
|
|
1917
|
+
if (failure) throw new Error(failure);
|
|
1918
|
+
return;
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
// releases.json at the commit being published decides how the release is
|
|
1922
|
+
// read: from the record and its build folders, or, where the repository
|
|
1923
|
+
// has none, from its tags and pointer files.
|
|
1924
|
+
const revisionSha = await publicationRevisionSha(options.country, options.hfCommitSha);
|
|
1925
|
+
const record = await readReleasesRecord(options.country, revisionSha);
|
|
1926
|
+
if (!record && options.mode === "release" && isBuildFolderId(options.releaseId)) {
|
|
1927
|
+
throw new Error(
|
|
1928
|
+
`${options.country}'s dataset repository has no ${RELEASES_RECORD_PATH}, so ` +
|
|
1929
|
+
`${options.releaseId} cannot name a build folder.`,
|
|
1930
|
+
);
|
|
1931
|
+
}
|
|
1932
|
+
let published: Awaited<ReturnType<typeof publishRelease>>;
|
|
1933
|
+
if (record) {
|
|
1934
|
+
const selection = await requestedRecordedBuild(options.country, options, record, revisionSha);
|
|
1935
|
+
published = await publishRelease({
|
|
1936
|
+
country: options.country,
|
|
1937
|
+
id: selection.releaseId,
|
|
1938
|
+
releaseDirectory: selection.releaseDirectory,
|
|
1939
|
+
expectedSha: selection.hfCommitSha,
|
|
1940
|
+
blobToken,
|
|
1941
|
+
useExpectedShaExactly: true,
|
|
1942
|
+
indexCreatedAt: selection.publishedAt,
|
|
1943
|
+
uploadBundle: store.uploadBundle,
|
|
1944
|
+
});
|
|
1945
|
+
} else {
|
|
1946
|
+
// The pointer file is read at the commit releases.json was looked for.
|
|
1947
|
+
const selection: ReleaseSelection = options.mode === "latest"
|
|
1948
|
+
? await readUpstreamLatest(options.country, revisionSha)
|
|
1949
|
+
: await requestedReleaseSelection(
|
|
1950
|
+
options.country,
|
|
1951
|
+
releaseId(options.releaseId),
|
|
1952
|
+
options.hfCommitSha,
|
|
1953
|
+
);
|
|
1954
|
+
published = await publishRelease({
|
|
1955
|
+
country: options.country,
|
|
1956
|
+
id: selection.releaseId,
|
|
1957
|
+
releaseDirectory: selection.releaseDirectory,
|
|
1958
|
+
expectedSha: selection.hfCommitSha,
|
|
1959
|
+
blobToken,
|
|
1960
|
+
allowMissingReleaseTag: options.mode === "latest",
|
|
1961
|
+
uploadBundle: store.uploadBundle,
|
|
1962
|
+
});
|
|
1963
|
+
}
|
|
1964
|
+
const entry = await registerReleaseBuild(published, store);
|
|
1965
|
+
const promoted = await promoteIfCurrent(options.country, published, entry, store);
|
|
1966
|
+
console.log(JSON.stringify({
|
|
1967
|
+
country: options.country,
|
|
1968
|
+
releaseId: published.bundle.index.build.releaseId,
|
|
1969
|
+
hfCommitSha: published.bundle.index.build.hfCommitSha,
|
|
1970
|
+
buildArtifactId: published.bundle.index.buildArtifactId,
|
|
1971
|
+
files: published.stored.files,
|
|
1972
|
+
sizes: published.sizes,
|
|
1973
|
+
promoted,
|
|
1974
|
+
}));
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
if (import.meta.main) {
|
|
1978
|
+
try {
|
|
1979
|
+
await runPublisher(parsePublisherOptions(Bun.argv.slice(2)));
|
|
1980
|
+
} catch (error) {
|
|
1981
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
1982
|
+
process.exitCode = 1;
|
|
1983
|
+
}
|
|
1984
|
+
}
|