Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fresh-documentation-inputs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@neuledge/context": patch
---

Allow package builders to store build fingerprints and automatic ingestion revisions in package metadata for registry freshness checks.
18 changes: 14 additions & 4 deletions SERVER_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Returns an empty array `[]` when no packages match. Results are sorted by versio
GET /packages/<registry>/<name>/<version>
```

Check if a package version exists and return its metadata. Used by the publish pipeline for idempotency (skip already-published versions) and by unversioned packages to compare `source_commit`.
Check if a package version exists and return its metadata. The publish pipeline compares `build_fingerprint` to skip packages whose source, resolved definition, and ingestion implementation are unchanged.

**Response `200 OK`:**

Expand All @@ -76,7 +76,9 @@ Check if a package version exists and return its metadata. Used by the publish p
"registry": "npm",
"name": "nextjs",
"version": "15.1.0",
"source_commit": "abc1234"
"source_commit": "abc1234",
"build_fingerprint": "<SHA-256 of build inputs>",
"ingestion_revision": "<automatically generated SHA-256>"
}
```

Expand All @@ -87,7 +89,11 @@ Check if a package version exists and return its metadata. Used by the publish p
| `registry` | string | Package manager |
| `name` | string | Package name |
| `version` | string | Semver version or `"latest"` |
| `source_commit` | string? | Git SHA for unversioned packages |
| `source_commit` | string? | Git commit SHA for versioned or unversioned Git packages |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Data & state · comment: The freshness check depends on the server copying build_fingerprint and ingestion_revision from the uploaded database into metadata on every upload and replacement. This checkout has no server implementation, and the test is a mock; track or verify server adoption before relying on automatic invalidation.


This codebase is managed by Human0.

| `build_fingerprint` | string? | Opaque SHA-256 copied from the uploaded database's `meta` table |
| `ingestion_revision` | string? | Automatic ingestion revision copied from the uploaded database's `meta` table |

Servers supporting automatic freshness checks must extract and persist these optional metadata values on every upload, including replacements, and return them here. The client treats fingerprints as opaque; servers should not recompute them. Older packages and servers can omit these fields: versioned packages retain existence-based skipping, unversioned Git packages retain `source_commit` comparison, and unversioned ZIP packages continue rebuilding. The CLI identifies legacy skips and offers `--force` for migration. Returning the fingerprint after a successful rebuild enables subsequent automatic checks; a server that continues omitting it retains the legacy behavior.

**Response `404 Not Found`:**

Expand Down Expand Up @@ -135,6 +141,8 @@ Upload a new documentation package. Requires a valid API key.

**Response `409 Conflict`** — Package version already exists (optional; servers may also allow overwrites).

The publisher uploads stale or explicitly forced builds to the same registry/name/version. `--force` bypasses client freshness checks only; it does not grant replacement permission or change the API request. If this endpoint rejects replacement with HTTP 409, the publisher reports the conflict and preserves the rebuilt local `.db`. Updating an immutable release requires a server-supported replacement or artifact revision policy; no alternative version is invented by the client.

## Package format (`.db` file)

Packages are SQLite databases with the following schema:
Expand Down Expand Up @@ -178,7 +186,9 @@ CREATE VIRTUAL TABLE chunks_fts USING fts5(
|-----|-------------|
| `description` | Short package description |
| `source_url` | URL of the source repository |
| `source_commit` | Git commit SHA (used for unversioned packages to detect changes) |
| `source_commit` | Actual Git commit SHA used to build the package |
| `build_fingerprint` | Deterministic SHA-256 of resolved package build inputs |
| `ingestion_revision` | SHA-256 generated from ingestion code and locked dependencies at build time |

## Error format

Expand Down
10 changes: 10 additions & 0 deletions packages/context/src/package-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ export interface PackageBuildOptions {
sourceUrl?: string;
/** Git commit SHA used to build this package (for skip-if-unchanged checks) */
sourceCommit?: string;
/** Deterministic registry build inputs, used for publication freshness. */
buildFingerprint?: string;
/** Automatically generated revision of the ingestion implementation. */
ingestionRevision?: string;
}

export interface MarkdownFile {
Expand Down Expand Up @@ -611,6 +615,12 @@ export function buildPackage(
if (options.sourceCommit) {
insertMeta.run("source_commit", options.sourceCommit);
}
if (options.buildFingerprint) {
insertMeta.run("build_fingerprint", options.buildFingerprint);
}
if (options.ingestionRevision) {
insertMeta.run("ingestion_revision", options.ingestionRevision);
}

// Parse and insert chunks
const insertChunk = db.prepare(`
Expand Down
4 changes: 2 additions & 2 deletions packages/registry/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@
".": "./dist/index.js"
},
"scripts": {
"build": "rimraf --glob dist/** && tsc -p tsconfig.build.json",
"build": "rimraf --glob dist/** && node ../../scripts/generate-ingestion-revision.mjs && tsc -p tsconfig.build.json",
"test": "vitest run",
"test:watch": "vitest",
"registry": "tsx src/cli.ts",
"registry": "node ../../scripts/generate-ingestion-revision.mjs && tsx src/cli.ts",
"test-registry": "tsx src/test-registry.ts",
"lint": "npx biome ci --error-on-warnings",
"fix": "npx biome check --write"
Expand Down
67 changes: 58 additions & 9 deletions packages/registry/src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,25 @@
* (clone default branch) definitions. Supports git, zip and HTML index sources.
*/

import { execSync } from "node:child_process";
import { execFileSync } from "node:child_process";
import { join } from "node:path";
import {
type BuildResult,
buildPackage,
cloneRepository,
initDatabase,
readLocalDocsFiles,
} from "@neuledge/context";
import {
constructTag,
isGitVersionEntry,
type PackageDefinition,
resolveUrl,
resolveVersionEntry,
type UnversionedDefinition,
type VersionedDefinition,
} from "./definition.js";
import { createBuildFingerprint, getIngestionRevision } from "./fingerprint.js";
import { excludeFiles } from "./glob.js";
import { downloadHtmlIndex } from "./html-index.js";
import { downloadAndExtractZip } from "./zip.js";
Expand All @@ -44,19 +47,52 @@ export function getHeadCommit(url: string, ref?: string): string {
// Must match the ref the package is built from. Asking for HEAD while building
// a branch compares two unrelated commits, so the skip-if-unchanged check never
// fires and the package is rebuilt and republished on every run.
const output = execSync(`git ls-remote ${url} ${ref ?? "HEAD"}`, {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
}).trim();
const requested = ref ?? "HEAD";
const output = execFileSync(
"git",
["ls-remote", url, requested, `${requested}^{}`],
{
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
},
).trim();

// Format: "<sha>\tHEAD"
const sha = output.split("\t")[0];
const refs = new Map(
output.split("\n").map((line) => {
const [sha, name] = line.split("\t");
return [name, sha];
}),
);
// Match clone --branch: prefer a branch, and peel annotated tags to commits.
const sha =
refs.get(`refs/heads/${requested}`) ??
refs.get(`${requested}^{}`) ??

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maintainability · comment: git ls-remote returns peeled names such as refs/tags/<name>^{}, not a bare <requested>^{} key, so this lookup cannot match. Remove it or handle the actual ref names explicitly.


This codebase is managed by Human0.

refs.get(`refs/tags/${requested}^{}`) ??
refs.get(requested) ??
refs.get(`refs/tags/${requested}`);
if (!sha) {
throw new Error(`Failed to get HEAD commit for ${url}`);
throw new Error(`Git reference ${requested} not found in upstream ${url}`);
}
return sha;
}

function fingerprintOptions(
definition: PackageDefinition,
version: string,
sourceCommit?: string,
) {
const ingestionRevision = getIngestionRevision();
return {
ingestionRevision,
buildFingerprint: createBuildFingerprint(
definition,
version,
sourceCommit,
ingestionRevision,
),
};
}

/**
* Build a .db package for a specific version of a versioned definition.
*/
Expand All @@ -65,6 +101,7 @@ export async function buildFromDefinition(
version: string,
outputDir: string,
): Promise<RegistryBuildResult> {
await initDatabase();
const entry = resolveVersionEntry(definition, version);
if (!entry) {
throw new Error(
Expand Down Expand Up @@ -115,6 +152,7 @@ export async function buildFromDefinition(
version,
description: definition.description,
sourceUrl: definition.repository ?? url,
...fingerprintOptions(definition, version),
});

return {
Expand All @@ -134,6 +172,7 @@ export async function buildUnversioned(
definition: UnversionedDefinition,
outputDir: string,
): Promise<RegistryBuildResult> {
await initDatabase();
const version = "latest";
const { source } = definition;
const safeName = definition.name.replace(/\//g, "-");
Expand All @@ -157,6 +196,7 @@ export async function buildUnversioned(
version,
description: definition.description,
sourceUrl: definition.repository ?? source.url,
...fingerprintOptions(definition, version),
});

return {
Expand All @@ -172,7 +212,7 @@ export async function buildUnversioned(

try {
// Get the commit SHA of the cloned HEAD
const sourceCommit = execSync("git rev-parse HEAD", {
const sourceCommit = execFileSync("git", ["rev-parse", "HEAD"], {
cwd: tempDir,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
Expand Down Expand Up @@ -201,6 +241,7 @@ export async function buildUnversioned(
description: definition.description,
sourceUrl: definition.repository ?? source.url,
sourceCommit,
...fingerprintOptions(definition, version, sourceCommit),
});

return {
Expand Down Expand Up @@ -229,6 +270,11 @@ function buildFromGit(
const { tempDir, cleanup } = cloneRepository(url, tag);

try {
const sourceCommit = execFileSync("git", ["rev-parse", "HEAD"], {
cwd: tempDir,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
}).trim();
// Filter before the emptiness check, so an over-broad exclude_paths fails
// loudly here instead of publishing an empty package.
const files = excludeFiles(
Expand All @@ -246,13 +292,16 @@ function buildFromGit(
version,
description: definition.description,
sourceUrl: definition.repository ?? url,
sourceCommit,
...fingerprintOptions(definition, version, sourceCommit),
});

return {
...result,
name: definition.name,
registry: definition.registry,
version,
sourceCommit,
};
} finally {
cleanup();
Expand Down
Loading
Loading