@fro.bot/wiki-write-core 0.1.0 → 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/README.md +42 -0
- 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/README.md
CHANGED
|
@@ -38,3 +38,45 @@ The package exports `GATE_CONTRACT_VERSION` and `GATE_SOURCE_TREE_HASH` from its
|
|
|
38
38
|
## Finding kinds
|
|
39
39
|
|
|
40
40
|
`WikiLintFindingKind` includes `correction-eroded` for a blocking active-correction failure and `correction-needs-reconfirmation` for an advisory lifecycle or formatting-only change. Both retain the existing `{kind, path, message, target?}` runtime shape and fingerprint derivation. Consumers with exhaustive matches must handle both kinds.
|
|
41
|
+
|
|
42
|
+
## Releasing
|
|
43
|
+
|
|
44
|
+
A merge to `main` that changes `packages/wiki-write-core/` releases the package through `.github/workflows/release-wiki-write-core.yaml`. Versions are the `wiki-write-core-v<version>` tags (for example `wiki-write-core-v0.1.1`), and each tag has a GitHub release.
|
|
45
|
+
|
|
46
|
+
Only commits that touch `packages/wiki-write-core/` are analyzed. The commit type decides the release:
|
|
47
|
+
|
|
48
|
+
| Commit | Release |
|
|
49
|
+
| ----------------------------------------------------------------------------------------------- | ------- |
|
|
50
|
+
| Breaking change (`!` or a `BREAKING CHANGE:` footer) | minor |
|
|
51
|
+
| `feat` | minor |
|
|
52
|
+
| `fix`, `build`, `docs(readme)` | patch |
|
|
53
|
+
| `build(dev)`, other `docs`, `chore`, `ci`, `test`, `refactor`, `skip`, non-conventional commits | none |
|
|
54
|
+
|
|
55
|
+
- A scoped suppression wins over a breaking marker: `build(dev)!:` releases nothing.
|
|
56
|
+
- While the package is 0.x it never releases a major version; a breaking change is a minor bump.
|
|
57
|
+
- A commit containing `[skip release]` (or `[release skip]`) is ignored by the release analysis.
|
|
58
|
+
|
|
59
|
+
### Publishing
|
|
60
|
+
|
|
61
|
+
The `publish` job uses npm trusted publishing (OIDC) from `release-wiki-write-core.yaml` in the `release` environment, and publishes with provenance. There is no npm token. It publishes the committed `dist/`, which the `build` job proves equals a fresh build.
|
|
62
|
+
|
|
63
|
+
Verify a release:
|
|
64
|
+
|
|
65
|
+
- `npm view @fro.bot/wiki-write-core dist-tags.latest` returns the released version.
|
|
66
|
+
- `npm view @fro.bot/wiki-write-core@<version> --json` lists the provenance attestation under `dist.attestations`.
|
|
67
|
+
- The version page on npmjs.com shows the provenance badge, linking to `fro-bot/.github` and `release-wiki-write-core.yaml`.
|
|
68
|
+
|
|
69
|
+
### Failure recovery
|
|
70
|
+
|
|
71
|
+
Start from the registry and tag state, not from the run's conclusion: a green run can release nothing.
|
|
72
|
+
|
|
73
|
+
| State | Recovery |
|
|
74
|
+
| --- | --- |
|
|
75
|
+
| No tag, no npm version | Fix the cause, then run the workflow with `workflow_dispatch` on `main` |
|
|
76
|
+
| Green run, package commits since the last tag, no release (`main` moved past the checkout, or the push run was skipped by `[skip ci]`) | `workflow_dispatch` on `main` |
|
|
77
|
+
| Tag present, version neither live nor staged on npm | Delete the tag and its GitHub release if one exists, then `workflow_dispatch` on `main`. Do this before merging any other package change |
|
|
78
|
+
| Tag and GitHub release present, npm skipped the publish (for example `private` in the manifest) | Fix the manifest, delete the tag and GitHub release, then `workflow_dispatch` on `main` |
|
|
79
|
+
| Version staged on npm instead of live | Promote it on npm, then set the trusted publisher to allow `npm publish` |
|
|
80
|
+
| OIDC publish fails (expired, misconfigured or stage-only trusted publisher) | Delete and recreate the trusted publisher: owner `fro-bot`, repo `.github`, workflow `release-wiki-write-core.yaml`, environment `release`, allowed action `npm publish`. Then resolve the tag as in the third row |
|
|
81
|
+
| Tag and version live, no GitHub release | Create the GitHub release from the tag. Never delete the tag |
|
|
82
|
+
| Bad version published | Deprecate it and ship a fix release. Never delete the tag |
|
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": {
|