@fro.bot/wiki-write-core 0.1.1 → 0.1.2
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/dist/gate-contract.js +1 -1
- package/dist/gate-contract.json +1 -1
- package/dist/wiki-ingest.d.ts +20 -1
- package/dist/wiki-ingest.js +83 -4
- package/package.json +1 -1
package/dist/gate-contract.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// Bump deliberately when an intentional consumer-visible gate contract break ships.
|
|
2
2
|
export const GATE_CONTRACT_VERSION = 1;
|
|
3
3
|
// Replaced with the deterministic source-tree hash by the package build.
|
|
4
|
-
export const GATE_SOURCE_TREE_HASH = '
|
|
4
|
+
export const GATE_SOURCE_TREE_HASH = '28283aa25287ac6172f333c8114de4879c0c14b3c6521908bb0607bb7e55ec6e';
|
package/dist/gate-contract.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":1,"sourceTreeHash":"
|
|
1
|
+
{"version":1,"sourceTreeHash":"28283aa25287ac6172f333c8114de4879c0c14b3c6521908bb0607bb7e55ec6e"}
|
package/dist/wiki-ingest.d.ts
CHANGED
|
@@ -55,11 +55,21 @@ export interface CommitWikiChangesParams {
|
|
|
55
55
|
message: string;
|
|
56
56
|
files: Record<string, string>;
|
|
57
57
|
deletedPaths?: readonly string[];
|
|
58
|
+
/**
|
|
59
|
+
* The data-branch commit `files` and `deletedPaths` were derived from: the base of the write guard.
|
|
60
|
+
* Every attempt, the first included, compares each write-set path on the head it reads with the same
|
|
61
|
+
* path in this commit's tree and fails closed if any differs.
|
|
62
|
+
* - A commit SHA: that commit's tree is the base. It is resolved once and never re-read, so the base
|
|
63
|
+
* stays fixed across retries.
|
|
64
|
+
* - Omitted: the head tree of the first successful read in this call is the base, so only changes
|
|
65
|
+
* after that read are caught.
|
|
66
|
+
*/
|
|
67
|
+
baseCommitSha?: string;
|
|
58
68
|
octokit?: OctokitClient;
|
|
59
69
|
maxRetries?: number;
|
|
60
70
|
/**
|
|
61
71
|
* Idempotent data-branch bootstrap. Called before data-branch writes so wiki
|
|
62
|
-
* ingest recovers when
|
|
72
|
+
* ingest recovers when the `data` branch is missing: it recreates the branch from `main`.
|
|
63
73
|
*/
|
|
64
74
|
bootstrapDataBranch?: (params: DataBranchBootstrapParams) => Promise<DataBranchBootstrapResult>;
|
|
65
75
|
}
|
|
@@ -91,6 +101,15 @@ export declare class WikiIngestError extends Error {
|
|
|
91
101
|
});
|
|
92
102
|
}
|
|
93
103
|
export declare function buildWikiIngestChanges(params: BuildWikiIngestChangesParams): BuildWikiIngestChangesResult;
|
|
104
|
+
/**
|
|
105
|
+
* Commits `params.files` (and removes `params.deletedPaths`) as one commit on the data branch.
|
|
106
|
+
*
|
|
107
|
+
* Every attempt is guarded: if any path in the write set (files and deletions) differs on the head read
|
|
108
|
+
* in that attempt from the tree the files were derived from (`baseCommitSha`, or the first head read
|
|
109
|
+
* when it is omitted), another writer changed it, so the write fails with `CONFLICT_EXHAUSTED` and
|
|
110
|
+
* nothing is written. A ref race (`updateRef` 409, or 422 not-a-fast-forward) re-reads the head and
|
|
111
|
+
* replays the same files on it, up to `maxRetries` attempts, under the same guard.
|
|
112
|
+
*/
|
|
94
113
|
export declare function commitWikiChanges(params: CommitWikiChangesParams): Promise<CommitWikiChangesResult>;
|
|
95
114
|
export declare function validateWikilinks(files: Record<string, string>): void;
|
|
96
115
|
/**
|
package/dist/wiki-ingest.js
CHANGED
|
@@ -121,6 +121,15 @@ function collectWikiFrontmatter(files) {
|
|
|
121
121
|
}
|
|
122
122
|
return frontmatter;
|
|
123
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* Commits `params.files` (and removes `params.deletedPaths`) as one commit on the data branch.
|
|
126
|
+
*
|
|
127
|
+
* Every attempt is guarded: if any path in the write set (files and deletions) differs on the head read
|
|
128
|
+
* in that attempt from the tree the files were derived from (`baseCommitSha`, or the first head read
|
|
129
|
+
* when it is omitted), another writer changed it, so the write fails with `CONFLICT_EXHAUSTED` and
|
|
130
|
+
* nothing is written. A ref race (`updateRef` 409, or 422 not-a-fast-forward) re-reads the head and
|
|
131
|
+
* replays the same files on it, up to `maxRetries` attempts, under the same guard.
|
|
132
|
+
*/
|
|
124
133
|
export async function commitWikiChanges(params) {
|
|
125
134
|
const owner = params.owner ?? DEFAULT_OWNER;
|
|
126
135
|
const repo = params.repo ?? DEFAULT_REPO;
|
|
@@ -143,11 +152,30 @@ export async function commitWikiChanges(params) {
|
|
|
143
152
|
if (shouldBootstrapDataBranch) {
|
|
144
153
|
await bootstrapDataBranch();
|
|
145
154
|
}
|
|
155
|
+
// The tree the write set was derived from, once resolved (see `CommitWikiChangesParams.baseCommitSha`).
|
|
156
|
+
let baseTreeSha;
|
|
146
157
|
for (let attempt = 1; attempt <= maxRetries; attempt += 1) {
|
|
158
|
+
// Only an `updateRef` 422 is a ref race ("not a fast forward": the branch moved after this attempt
|
|
159
|
+
// read its tip). A 422 from createBlob, createTree or createCommit is a bad request and stays terminal.
|
|
160
|
+
let updatingRef = false;
|
|
147
161
|
try {
|
|
148
162
|
await assertWritableWikiBranch(octokit, owner, repo, branch);
|
|
149
163
|
const head = await octokit.rest.git.getRef({ owner, repo, ref: `heads/${branch}` });
|
|
150
164
|
const commit = await octokit.rest.git.getCommit({ owner, repo, commit_sha: head.data.object.sha });
|
|
165
|
+
// Resolved on the first attempt and reused: the base must not move while the write retries.
|
|
166
|
+
baseTreeSha ??=
|
|
167
|
+
params.baseCommitSha === undefined
|
|
168
|
+
? commit.data.tree.sha
|
|
169
|
+
: await readCommitTreeSha(octokit, owner, repo, params.baseCommitSha);
|
|
170
|
+
const guardBaseTreeSha = baseTreeSha;
|
|
171
|
+
if (commit.data.tree.sha !== guardBaseTreeSha) {
|
|
172
|
+
await assertWriteSetUnchanged(octokit, owner, repo, {
|
|
173
|
+
baseTreeSha: guardBaseTreeSha,
|
|
174
|
+
headTreeSha: commit.data.tree.sha,
|
|
175
|
+
paths: [...Object.keys(params.files), ...(params.deletedPaths ?? [])],
|
|
176
|
+
location: `${owner}/${repo}@${branch}`,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
151
179
|
const tree = [];
|
|
152
180
|
for (const [path, content] of Object.entries(params.files)) {
|
|
153
181
|
const blob = await octokit.rest.git.createBlob({ owner, repo, content, encoding: 'utf-8' });
|
|
@@ -175,6 +203,7 @@ export async function commitWikiChanges(params) {
|
|
|
175
203
|
tree: createdTree.data.sha,
|
|
176
204
|
parents: [commit.data.sha],
|
|
177
205
|
});
|
|
206
|
+
updatingRef = true;
|
|
178
207
|
await octokit.rest.git.updateRef({
|
|
179
208
|
owner,
|
|
180
209
|
repo,
|
|
@@ -189,11 +218,12 @@ export async function commitWikiChanges(params) {
|
|
|
189
218
|
await bootstrapDataBranch();
|
|
190
219
|
continue;
|
|
191
220
|
}
|
|
192
|
-
|
|
221
|
+
const refRace = isConflictError(error) || (updatingRef && isApiErrorStatus(error, 422));
|
|
222
|
+
if (refRace && attempt < maxRetries) {
|
|
193
223
|
await delayConflictRetry(attempt);
|
|
194
224
|
continue;
|
|
195
225
|
}
|
|
196
|
-
if (
|
|
226
|
+
if (refRace) {
|
|
197
227
|
throw new WikiIngestError({
|
|
198
228
|
code: 'CONFLICT_EXHAUSTED',
|
|
199
229
|
message: `wiki ingest exhausted ${maxRetries} attempt(s) updating ${owner}/${repo}@${branch}`,
|
|
@@ -234,6 +264,47 @@ async function getPresentPathsInTree(octokit, owner, repo, treeSha) {
|
|
|
234
264
|
return new Set();
|
|
235
265
|
return new Set(tree.flatMap(entry => (isRecord(entry) && typeof entry.path === 'string' ? [entry.path] : [])));
|
|
236
266
|
}
|
|
267
|
+
async function readCommitTreeSha(octokit, owner, repo, sha) {
|
|
268
|
+
const commit = await octokit.rest.git.getCommit({ owner, repo, commit_sha: sha });
|
|
269
|
+
return commit.data.tree.sha;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Fail closed unless every path in the write set has the same tree entry (sha, mode, type; absence
|
|
273
|
+
* counts) on the new head as in the tree the files were derived from. A replay onto a head where
|
|
274
|
+
* another writer touched one of them would silently overwrite that writer's change.
|
|
275
|
+
*/
|
|
276
|
+
async function assertWriteSetUnchanged(octokit, owner, repo, params) {
|
|
277
|
+
const refuse = (reason) => {
|
|
278
|
+
throw new WikiIngestError({
|
|
279
|
+
code: 'CONFLICT_EXHAUSTED',
|
|
280
|
+
message: `wiki ingest did not replay onto ${params.location}: ${reason}`,
|
|
281
|
+
remediation: 'Another writer updated the data branch concurrently. Retry the workflow so it rebuilds on the new head.',
|
|
282
|
+
});
|
|
283
|
+
};
|
|
284
|
+
const base = await readTreeEntries(octokit, owner, repo, params.baseTreeSha);
|
|
285
|
+
const head = await readTreeEntries(octokit, owner, repo, params.headTreeSha);
|
|
286
|
+
if (base === undefined || head === undefined)
|
|
287
|
+
return refuse('a tree read was truncated, so the write set cannot be verified');
|
|
288
|
+
if (params.paths.some(path => base.get(path) !== head.get(path))) {
|
|
289
|
+
return refuse('another writer changed a path in the write set');
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
/** Path to `mode type sha`, for every non-directory entry. `undefined` when the read is truncated. */
|
|
293
|
+
async function readTreeEntries(octokit, owner, repo, treeSha) {
|
|
294
|
+
const response = await octokit.rest.git.getTree({ owner, repo, tree_sha: treeSha, recursive: 'true' });
|
|
295
|
+
if (response.data.truncated === true)
|
|
296
|
+
return undefined;
|
|
297
|
+
const tree = response.data.tree;
|
|
298
|
+
const entries = new Map();
|
|
299
|
+
if (!Array.isArray(tree))
|
|
300
|
+
return entries;
|
|
301
|
+
for (const entry of tree) {
|
|
302
|
+
if (!isRecord(entry) || typeof entry.path !== 'string' || entry.type === 'tree')
|
|
303
|
+
continue;
|
|
304
|
+
entries.set(entry.path, `${String(entry.mode)} ${String(entry.type)} ${String(entry.sha)}`);
|
|
305
|
+
}
|
|
306
|
+
return entries;
|
|
307
|
+
}
|
|
237
308
|
async function createOctokitFromEnv() {
|
|
238
309
|
const token = process.env.GITHUB_TOKEN;
|
|
239
310
|
if (token === undefined || token === '') {
|
|
@@ -794,8 +865,15 @@ function formatTimestamp(value) {
|
|
|
794
865
|
const minute = `${value.getUTCMinutes()}`.padStart(2, '0');
|
|
795
866
|
return `${year}-${month}-${day} ${hour}:${minute}`;
|
|
796
867
|
}
|
|
868
|
+
/**
|
|
869
|
+
* Read the existing wiki files from the working tree, and report the data-branch commit they were
|
|
870
|
+
* synced from: `WIKI_INGEST_DATA_TIP_SHA`, when the workflow exports the tip it synced `knowledge/` from.
|
|
871
|
+
* Without it there is no recorded base and the commit guards from its own first read.
|
|
872
|
+
*/
|
|
797
873
|
async function loadExistingWikiFiles() {
|
|
798
874
|
const files = {};
|
|
875
|
+
const recordedTip = process.env.WIKI_INGEST_DATA_TIP_SHA;
|
|
876
|
+
const baseCommitSha = recordedTip === undefined || recordedTip === '' ? undefined : recordedTip;
|
|
799
877
|
for (const path of [INDEX_PATH, LOG_PATH]) {
|
|
800
878
|
try {
|
|
801
879
|
files[path] = await readFile(path, 'utf8');
|
|
@@ -836,7 +914,7 @@ async function loadExistingWikiFiles() {
|
|
|
836
914
|
}
|
|
837
915
|
}
|
|
838
916
|
}
|
|
839
|
-
return files;
|
|
917
|
+
return { files, baseCommitSha };
|
|
840
918
|
}
|
|
841
919
|
async function loadWorkingTreeWikiFiles(paths) {
|
|
842
920
|
const files = {};
|
|
@@ -958,7 +1036,7 @@ export async function runWikiIngestCli(dependencies = {}) {
|
|
|
958
1036
|
const readCorrectionsImpl = dependencies.readCorrections ?? readCorrections;
|
|
959
1037
|
const commitWikiChangesImpl = dependencies.commitWikiChanges ?? commitWikiChanges;
|
|
960
1038
|
const getChangedWikiPathsImpl = dependencies.getChangedWikiPaths ?? getChangedWikiPaths;
|
|
961
|
-
const existingFiles = await loadExistingWikiFiles();
|
|
1039
|
+
const { files: existingFiles, baseCommitSha } = await loadExistingWikiFiles();
|
|
962
1040
|
let correctionRead;
|
|
963
1041
|
try {
|
|
964
1042
|
correctionRead = await readCorrectionsImpl();
|
|
@@ -1063,6 +1141,7 @@ export async function runWikiIngestCli(dependencies = {}) {
|
|
|
1063
1141
|
message,
|
|
1064
1142
|
files: built.files,
|
|
1065
1143
|
deletedPaths: built.deletedPaths,
|
|
1144
|
+
...(baseCommitSha === undefined ? {} : { baseCommitSha }),
|
|
1066
1145
|
});
|
|
1067
1146
|
const pagesChanged = countWikiPages(committedPagePaths);
|
|
1068
1147
|
await emitPagesChanged(pagesChanged);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fro.bot/wiki-write-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Shared validation and commit primitives for Fro Bot wiki writes",
|
|
5
5
|
"homepage": "https://github.com/fro-bot/.github/tree/main/packages/wiki-write-core#readme",
|
|
6
6
|
"bugs": {
|