@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.
@@ -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 = '9d17212cc85afbc7cb228fdce2cb436caa29ec2b0ba7c2f3e06b0db0de3b16be';
4
+ export const GATE_SOURCE_TREE_HASH = '28283aa25287ac6172f333c8114de4879c0c14b3c6521908bb0607bb7e55ec6e';
@@ -1 +1 @@
1
- {"version":1,"sourceTreeHash":"9d17212cc85afbc7cb228fdce2cb436caa29ec2b0ba7c2f3e06b0db0de3b16be"}
1
+ {"version":1,"sourceTreeHash":"28283aa25287ac6172f333c8114de4879c0c14b3c6521908bb0607bb7e55ec6e"}
@@ -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 GitHub deletes the `data` source ref after promotion.
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
  /**
@@ -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
- if (isConflictError(error) && attempt < maxRetries) {
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 (isConflictError(error)) {
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.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": {