@starci/hfs 4.7.44 → 4.7.46

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.
Files changed (47) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/lint/run.mjs +46 -10
  3. package/package.json +4 -1
  4. package/release/candidate-ci.mjs +7 -7
  5. package/release/push.mjs +3 -3
  6. package/runtime/config.example.yaml +9 -10
  7. package/runtime/engine/config.mjs +3 -3
  8. package/runtime/engine/release-config.mjs +5 -5
  9. package/runtime/knowledge/grammars/offset-pop/playbook.yaml +1 -1
  10. package/runtime/knowledge/grammars/starci/playbook.yaml +27 -3
  11. package/runtime/knowledge/hfs/canon-pins.yaml +14 -7
  12. package/runtime/knowledge/hfs/ci-standard.yaml +1 -1
  13. package/runtime/knowledge/hfs/fe-stack.yaml +12 -1
  14. package/runtime/knowledge/hfs/rules.yaml +41 -6
  15. package/runtime/knowledge/hfs/slots.yaml +5 -5
  16. package/runtime/knowledge/patterns/be/realtime.yaml +72 -12
  17. package/runtime/modules/kernel/command-policy.yaml +5 -5
  18. package/runtime/modules/kernel/failure-codes.yaml +42 -20
  19. package/runtime/modules/kernel/removed-vocabulary.yaml +33 -7
  20. package/runtime/packages/cli/src/catalog.generated.mjs +90 -37
  21. package/runtime/scripts/api/git/commit-tree.mjs +5 -0
  22. package/runtime/scripts/api/git/update-ref.mjs +8 -0
  23. package/runtime/scripts/api/git/write-tree.mjs +5 -0
  24. package/runtime/scripts/guards/command-policy.mjs +4 -4
  25. package/runtime/scripts/guards/push-scan.mjs +128 -0
  26. package/runtime/scripts/guards/release-push-gate.mjs +31 -29
  27. package/runtime/scripts/hfs/architecture/index.mjs +2 -1
  28. package/runtime/scripts/hfs/architecture/type-context.mjs +38 -23
  29. package/runtime/scripts/hfs/example-secrets.mjs +61 -8
  30. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +5 -5
  31. package/runtime/scripts/hfs/runtime-rules/git-triggers.mjs +61 -0
  32. package/runtime/scripts/hfs/runtime-rules/hook-shape.mjs +4 -6
  33. package/runtime/scripts/hfs/typescript-programs.mjs +30 -3
  34. package/runtime/scripts/machine/home.mjs +2 -2
  35. package/templates/app/ci-workflows/github/workflows/ci.yml +2 -2
  36. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +2 -2
  37. package/templates/app/hooks/husky/pre-push +3 -56
  38. package/templates/be/inbox/platform/inbox/inbox.port.ts.tpl +1 -3
  39. package/templates/be/patterns/idempotency/platform/inbox/inbox.port.ts.tpl +1 -3
  40. package/templates/be/patterns/realtime/realtime-config.mapper.ts.tpl +32 -0
  41. package/runtime/scripts/api/fs/write-json-file.mjs +0 -10
  42. package/runtime/scripts/api/git/merge-base-query.mjs +0 -5
  43. package/runtime/scripts/gates/commit-proof.mjs +0 -53
  44. package/runtime/scripts/guards/release-definition.mjs +0 -204
  45. package/runtime/scripts/guards/release-record.mjs +0 -58
  46. package/runtime/scripts/guards/release-suite-mode.mjs +0 -15
  47. package/runtime/scripts/hfs/runtime-rules/release-notes.mjs +0 -44
@@ -1,53 +0,0 @@
1
- // commit-proof.mjs — locations and file IO for a commit's verification and release evidence. Each producer owns its schema.
2
- import fs from 'node:fs';
3
- import path from 'node:path';
4
- import { runtimeStateDir, starciLocalRoot } from '../../engine/runtime-root.mjs';
5
- import { sha256 } from '../../engine/digest.mjs';
6
- import { readJsonFile } from '../lib/json.mjs';
7
- import { sameResolvedPath } from '../lib/path-key.mjs';
8
- import { writeJsonFile } from '../api/fs/write-json-file.mjs';
9
-
10
- const RELEASE_KINDS = Object.freeze({ l4: 'l4', affected: 'affected', rows: 'l4-rows', ci: 'ci', report: 'affected-report' });
11
- const SHORT = 12;
12
- /** The local git note namespace used to write and read land proof. */
13
- export const LAND_NOTE_REF = 'land';
14
-
15
- /** The directory shared by release proofs of every worktree of a repository. */
16
- const proofDirOf = (commonDir) => path.join(commonDir, 'starci-release');
17
-
18
- /** The location of an affected, verify, deploy/check or release receipt; existing references keep their established paths. */
19
- export function proofFileOf({ kind, sha, base, root, env, commonDir }) {
20
- if (kind === 'verify') return path.join(runtimeStateDir(root), 'verify', `${sha}.json`);
21
- if (kind === 'affected-receipt') return path.join(runtimeStateDir(root), 'affected', `${String(base).slice(0, SHORT)}-${String(sha).slice(0, SHORT)}.json`);
22
- if (kind === 'deploy') return path.join(starciLocalRoot(env), 'deploy', 'receipts', `${sha}.json`);
23
- if (!Object.hasOwn(RELEASE_KINDS, kind)) throw new Error(`unknown commit proof kind: ${kind}`);
24
- return path.join(proofDirOf(commonDir), `${sha}.${RELEASE_KINDS[kind]}.json`);
25
- }
26
-
27
- /** Every release proof of one kind under the git common directory, or [] when the directory is absent. */
28
- export function proofFilesOf({ commonDir, kind }) {
29
- if (!Object.hasOwn(RELEASE_KINDS, kind)) throw new Error(`unknown release proof kind: ${kind}`);
30
- const folder = proofDirOf(commonDir);
31
- const suffix = `.${RELEASE_KINDS[kind]}.json`;
32
- return fs.existsSync(folder) ? fs.readdirSync(folder).filter((name) => name.endsWith(suffix)).map((name) => path.join(folder, name)) : [];
33
- }
34
-
35
- /** Writes one receipt atomically; a reader sees a complete JSON document. Returns its file. */
36
- export function writeProofFile(file, record) {
37
- const tmp = `${file}.${process.pid}.tmp`;
38
- writeJsonFile(tmp, record);
39
- fs.renameSync(tmp, file);
40
- return file;
41
- }
42
-
43
- /** A proof file's parsed document, or null when it is missing, unreadable or malformed; its producer validates the schema. */
44
- export const readProofFile = (file) => readJsonFile(file, null);
45
-
46
- /** The digest of the schema's ordered binding fields. */
47
- export const proofDigest = (fields) => sha256(fields.join('\n'));
48
-
49
- /** Whether a receipt binds the requested commit and tree. */
50
- export const proofBinds = (record, { sha, tree }) => record?.sha === sha && record?.tree === tree;
51
-
52
- /** Whether the specs judged this source root; a missing root never supplies proof for another checkout. */
53
- export const judgedIn = (recorded, root) => typeof recorded === 'string' && recorded !== '' && sameResolvedPath(recorded, root);
@@ -1,204 +0,0 @@
1
- // release-definition.mjs - what makes a commit of the runtime repository a RELEASE commit, the one definition shared by the
2
- // pre-push hook (scripts/guards/release-push-gate.mjs) and the release cut (scripts/supervisor/release-cut.mjs). The remote
3
- // main of the runtime moves exactly when a release is cut, so a push of main or of a v* tag is judged against this list:
4
- // RELEASE_VERSION_UNCHANGED package.json `version` at the pushed commit differs from the version at the remote main's head
5
- // RELEASE_VERSION_NOT_NEXT that version is the next release after the highest v* tag (scripts/lib/release-version.mjs, owner ruling release-waves):
6
- // the next wave alpha.N.M+1 of its line or the next line alpha.N+1, never the legacy -fix form
7
- // RELEASE_TAG_VERSION the release tag is v<version> of the pushed commit
8
- // RELEASE_CHANGELOG_SECTION CHANGELOG.md holds `## [<version>] - <date>` (a dated heading) with no unfinished marker (R222)
9
- // RELEASE_TAG_MISSING an ANNOTATED tag v<version> points at the pushed commit
10
- // RELEASE_RECEIPT_MISSING the L4 record of exactly this commit (scripts/guards/release-record.mjs, written by the cut) names this tag
11
- // RELEASE_RECEIPT_INCOMPLETE that record holds a green row for each of RECEIPT_STEPS (the package clean-test, the scaffold e2e and the checks) (RAN on this commit or reused from another with the identical input digest, the record's `reusedFrom` names it; owner ruling speed-flow, 2026-10-25)
12
- // RELEASE_SUITE_MODE the record says `suite: ci` (the Linux parity delegated to CI) while the checked-out config says `local`, or it lists no delegated linux-parity: a record never claims the mode by
13
- // itself (scripts/guards/release-suite-mode.mjs); both modes ask for the same root rows, the runtime has no spec suite to delegate (owner ruling no-runtime-specs)
14
- // Each finding names what is missing and the command that produces it. Pure over the git reads; no bypass.
15
- import { catFile } from '../api/git/cat-file.mjs';
16
- import { revParseQuery } from '../api/git/rev-parse-query.mjs';
17
- import { show } from '../api/git/show.mjs';
18
- import { tag as gitTag } from '../api/git/tag.mjs';
19
- import { mergeBaseQuery } from '../api/git/merge-base-query.mjs';
20
- import { pathKey, realPath } from '../lib/path-key.mjs';
21
- import { changelogSection, releaseNotesFindings } from '../hfs/runtime-rules/release-notes.mjs';
22
- import { nextReleases, releaseVersionRefusal, taggedVersions } from '../lib/release-version.mjs';
23
- import { readL4Record } from './release-record.mjs';
24
- import { suiteModeOf } from './release-suite-mode.mjs';
25
-
26
- /** The rows the L4 record must hold green, run on the commit or reused from an identical-digest row of another, in either suite mode: the package clean-test, the scaffold e2e and the checks (the runtime's real tests). */
27
- export const RECEIPT_STEPS = Object.freeze(['npm run test:packages', 'npm run test:e2e', 'npm run check']);
28
- const CUT_COMMAND = (tag) => `starci release cut --tag ${tag}`;
29
- const REGEX_SPECIALS = /[.*+?^$()|[\]{}\\]/g;
30
- const HEADING_HEAD = String.raw`^## \[`;
31
- const DATED_HEADING_TAIL = String.raw`\] — \d{4}-\d{2}-\d{2}\s*$`;
32
- const DATED_HEADING = (version) => new RegExp([HEADING_HEAD, version.replaceAll(REGEX_SPECIALS, String.raw`\$&`), DATED_HEADING_TAIL].join(''), 'mu');
33
-
34
- const finding = (code, missing, fix) => ({ code, missing, fix });
35
- const gitText = (verb, args, cwd) => {
36
- const r = verb(args, { cwd, timeout: 60_000 });
37
- return r.status === 0 ? String(r.stdout ?? '') : null;
38
- };
39
-
40
- /** The package.json version of `commit`, or null when the commit or its manifest cannot be read. */
41
- export function versionAt(cwd, commit) {
42
- const text = gitText(show, [`${commit}:package.json`], cwd);
43
- try { return text === null ? null : JSON.parse(text).version ?? null; } catch { return null; }
44
- }
45
-
46
- const releaseUnknown = (why) => ({ ok: false, status: 'unknown', why });
47
- const ineligibleRelease = (explicit, why) => explicit ? releaseUnknown(why) : { ok: true, candidate: null };
48
-
49
- /** A runtime Git root's nearest annotated, manifest-bound release; enclosing application history is inapplicable. */
50
- export function releaseTagOf({ repo, tag = null, excludeHead = false }) {
51
- const history = runtimeGitHistory(repo);
52
- if (!history.ok) return history;
53
- const listed = tag === null ? gitText(gitTag, ['--merged', 'HEAD', '--list', 'v*'], repo) : tag;
54
- if (listed === null) return releaseUnknown('release tags are unreadable');
55
- const candidates = [];
56
- for (const named of listed.split(/\r?\n/).filter(Boolean)) {
57
- const result = releaseCandidate({ repo, named, current: history.head, explicit: tag !== null, excludeHead });
58
- if (!result.ok) return result;
59
- if (result.candidate) candidates.push(result.candidate);
60
- }
61
- return nearestRelease(repo, candidates);
62
- }
63
-
64
- /** Runtime history exists only at its own Git top-level with a readable HEAD and full ancestry. */
65
- function runtimeGitHistory(repo) {
66
- const top = revParseQuery(['--show-toplevel'], { cwd: repo });
67
- if (top.status !== 0) {
68
- if (/not a git repository/i.test(String(top.stderr ?? ''))) return { ok: false, status: 'no-repository', why: 'release history requires a Git repository' };
69
- return releaseUnknown('release repository identity is unreadable');
70
- }
71
- const gitRoot = String(top.stdout ?? '').trim();
72
- if (!gitRoot) return releaseUnknown('release repository root is unreadable');
73
- if (pathKey(realPath(gitRoot)) !== pathKey(realPath(repo))) return { ok: false, status: 'no-runtime-repository', why: 'the runtime copy is inside an enclosing Git repository; its history belongs to that repository' };
74
- const head = gitText(revParseQuery, ['HEAD'], repo)?.trim();
75
- if (!head) return releaseUnknown('release history HEAD is unreadable');
76
- if (gitText(revParseQuery, ['--is-shallow-repository'], repo)?.trim() !== 'false') return releaseUnknown('release history is shallow or unreadable; fetch complete release history');
77
- return { ok: true, head };
78
- }
79
-
80
- /** A candidate binds annotation, peeled commit, manifest spelling and ancestry; default discovery omits ineligible tags. */
81
- function releaseCandidate({ repo, named, current, explicit, excludeHead }) {
82
- const kind = gitText(catFile, ['-t', `refs/tags/${named}`], repo)?.trim();
83
- const head = gitText(revParseQuery, [`refs/tags/${named}^{commit}`], repo)?.trim();
84
- if (!kind || !head) return releaseUnknown(`release tag ${named} is unreadable`);
85
- if (kind !== 'tag') return ineligibleRelease(explicit, `release tag ${named} is not annotated`);
86
- const version = versionAt(repo, head);
87
- if (!version) return releaseUnknown(`release manifest of ${named} is unreadable`);
88
- if (named !== `v${version}`) return ineligibleRelease(explicit, `release tag ${named} differs from its manifest version ${version}`);
89
- const reachable = releaseAncestor(repo, head, current);
90
- if (reachable === null) return releaseUnknown(`release ancestry of ${named} is unreadable`);
91
- if (!reachable) return ineligibleRelease(explicit, `release tag ${named} is not merged in HEAD`);
92
- if (excludeHead && head === current) return { ok: true, candidate: null };
93
- return { ok: true, candidate: { tag: named, head } };
94
- }
95
-
96
- /** Only one unsuperseded release ancestor proves a boundary; incomparable releases hold. */
97
- function nearestRelease(repo, candidates) {
98
- if (!candidates.length) return releaseUnknown('no eligible annotated, manifest-bound release tag is merged in HEAD');
99
- const nearest = [];
100
- for (const candidate of candidates) {
101
- const superseded = candidateSuperseded(repo, candidate, candidates);
102
- if (superseded === null) return releaseUnknown('release ancestry between tags is unreadable');
103
- if (!superseded) nearest.push(candidate);
104
- }
105
- if (nearest.length !== 1) return releaseUnknown(`release history is ambiguous: ${nearest.map((entry) => entry.tag).join(', ')}`);
106
- return { ok: true, ...nearest[0] };
107
- }
108
-
109
- /** A release loses its boundary only to a proven descendant release, never to a date or version sort. */
110
- function candidateSuperseded(repo, candidate, candidates) {
111
- for (const other of candidates) {
112
- if (candidate.head === other.head) continue;
113
- const older = releaseAncestor(repo, candidate.head, other.head);
114
- if (older === null) return null;
115
- if (older) return true;
116
- }
117
- return false;
118
- }
119
-
120
- /** Missing Git proof is distinct from a proven non-ancestor. */
121
- function releaseAncestor(repo, before, after) {
122
- const result = mergeBaseQuery(['--is-ancestor', before, after], { cwd: repo });
123
- if (result.status === 0) return true;
124
- if (result.status === 1) return false;
125
- return null;
126
- }
127
-
128
- /** The next releases named from the released versions, for a fix text: "v<wave> or v<line>". */
129
- const nextNamed = (released, version = null) => {
130
- const next = nextReleases(released, version);
131
- return next ? `v${next.wave} or v${next.line}` : 'v<new version>';
132
- };
133
-
134
- function versionFindings({ cwd, remoteCommit, version, released }) {
135
- if (!remoteCommit) return [];
136
- const remoteVersion = versionAt(cwd, remoteCommit);
137
- if (remoteVersion === null) return [finding('RELEASE_REMOTE_HEAD_UNKNOWN', `the remote main head ${remoteCommit.slice(0, 9)} is not in this repository, so its version cannot be compared`, 'git fetch origin main')];
138
- if (remoteVersion === version) return [finding('RELEASE_VERSION_UNCHANGED', `package.json version ${version} is the version the remote main already carries`, `set package.json version to the next release, move the CHANGELOG section to it, commit, then ${CUT_COMMAND(nextNamed(released, version))}`)];
139
- return [];
140
- }
141
-
142
- /** The version must be the next release after the highest v* tag other than its own: a wave alpha.N.M is counted, never guessed. */
143
- function nextReleaseFindings({ version, released }) {
144
- if (released === null) return [finding('RELEASE_VERSION_NOT_NEXT', 'the v* tags are unreadable, so the next release cannot be derived', 'git fetch --tags')];
145
- const refusal = releaseVersionRefusal(version, released);
146
- return refusal ? [finding('RELEASE_VERSION_NOT_NEXT', refusal, `set package.json version to the next release, move the CHANGELOG section to it, commit, then ${CUT_COMMAND(nextNamed(released.filter((entry) => entry !== version), version))}`)] : [];
147
- }
148
-
149
- function changelogFindings({ cwd, commit, version, tag }) {
150
- const changelog = gitText(show, [`${commit}:CHANGELOG.md`], cwd);
151
- if (changelog === null) return [finding('RELEASE_CHANGELOG_SECTION', 'CHANGELOG.md is not readable at the pushed commit', 'restore CHANGELOG.md')];
152
- const notes = releaseNotesFindings({ tags: [tag], changelog });
153
- if (notes.length) return notes.map((n) => finding('RELEASE_CHANGELOG_SECTION', n.message, 'finish the CHANGELOG section of the release (no in-preparation mark, no open marker), commit'));
154
- if (!changelogSection(changelog, version).found || !DATED_HEADING(version).test(changelog)) return [finding('RELEASE_CHANGELOG_SECTION', `CHANGELOG.md has no dated heading "## [${version}] — YYYY-MM-DD"`, `date the heading of the ${version} section, commit`)];
155
- return [];
156
- }
157
-
158
- function tagFindings({ cwd, commit, tag }) {
159
- const kind = gitText(catFile, ['-t', `refs/tags/${tag}`], cwd)?.trim();
160
- const target = gitText(revParseQuery, [`refs/tags/${tag}^{commit}`], cwd)?.trim();
161
- if (kind === 'tag' && target === commit) return [];
162
- return [finding('RELEASE_TAG_MISSING', `no annotated tag ${tag} points at ${commit.slice(0, 9)}`, CUT_COMMAND(tag))];
163
- }
164
-
165
- /** The finding of a record that claims the delegated mode without the checked-out config saying so (or without naming the delegated linux-parity row), else null. */
166
- function suiteModeFinding({ record, cwd, commit, tag }) {
167
- if (record.suite !== 'ci') return null;
168
- if (suiteModeOf(cwd) !== 'ci') return finding('RELEASE_SUITE_MODE', `the release record of ${commit.slice(0, 9)} says suite: ci, but this checkout's config.yaml release.suite is not ci: a record does not choose the mode`, `set release.suite: ci in config.yaml (the owner's choice), or ${CUT_COMMAND(tag)} under suite: local`);
169
- if (!(record.delegated ?? []).some((row) => row?.name === 'linux-parity')) return finding('RELEASE_SUITE_MODE', `the suite: ci record of ${commit.slice(0, 9)} does not list linux-parity as delegated`, CUT_COMMAND(tag));
170
- return null;
171
- }
172
-
173
- function receiptFindings({ cwd, commit, tag }) {
174
- const record = readL4Record({ repo: cwd, head: commit, tag });
175
- if (!record) return [finding('RELEASE_RECEIPT_MISSING', `no release record for exactly ${commit.slice(0, 9)} and ${tag}: the package clean-test, the scaffold e2e and the check did not run green on this commit`, CUT_COMMAND(tag))];
176
- const mode = suiteModeFinding({ record, cwd, commit, tag });
177
- if (mode) return [mode];
178
- // A row reused from another commit (reusedFrom) counts: the cut reuses a row only when its input digest is identical (owner ruling speed-flow, 2026-10-25), and the record names the commit it came from.
179
- const green = new Set((record.logs ?? []).filter((row) => row?.ok === true).map((row) => row.name));
180
- const absent = RECEIPT_STEPS.filter((name) => !green.has(name));
181
- return absent.length ? [finding('RELEASE_RECEIPT_INCOMPLETE', `the release record of ${commit.slice(0, 9)} has no green row for: ${absent.join(', ')}`, CUT_COMMAND(tag))] : [];
182
- }
183
-
184
- /**
185
- * The findings that keep `commit` from being a release commit of the repository at `cwd`: [{code, missing, fix}], [] when it is one.
186
- * `remoteCommit` is the remote main's head being replaced (null for a tag push or a first push); `tag` the release tag (default v<version>);
187
- * `needTag` / `needReceipt` are false only for the cut's early look, before it has created the tag and run the suite.
188
- */
189
- export function releaseFindings({ cwd, commit, remoteCommit = null, tag = null, needTag = true, needReceipt = true }) {
190
- const version = versionAt(cwd, commit);
191
- if (!version) return [finding('RELEASE_VERSION_UNREADABLE', `package.json at ${commit.slice(0, 9)} has no readable version`, 'restore package.json')];
192
- const expected = `v${version}`;
193
- const named = tag ?? expected;
194
- if (named !== expected) return [finding('RELEASE_TAG_VERSION', `the tag ${named} is not v${version}, the version of ${commit.slice(0, 9)}`, `tag v${version}, or set package.json version to ${named.replace(/^v/, '')}`)];
195
- const listed = gitText(gitTag, ['--list', 'v*'], cwd);
196
- const released = listed === null ? null : taggedVersions(listed);
197
- return [
198
- ...versionFindings({ cwd, remoteCommit, version, released: released ?? [] }),
199
- ...nextReleaseFindings({ version, released }),
200
- ...changelogFindings({ cwd, commit, version, tag: named }),
201
- ...(needTag ? tagFindings({ cwd, commit, tag: named }) : []),
202
- ...(needReceipt ? receiptFindings({ cwd, commit, tag: named }) : []),
203
- ];
204
- }
@@ -1,58 +0,0 @@
1
- // release-record.mjs - the L4 record of a release: proof that the release cut ran the full suite for ONE commit. The git hooks
2
- // (the app and runtime pre-push, scripts/guards/git-hooks.mjs) let a push of main or a v* tag through only when HEAD carries
3
- // an annotated release tag AND this record exists for HEAD and names that tag, so a push that did not come out of the
4
- // release cut is refused without running a single test.
5
- //
6
- // The record lives in the git common dir (never in the tree: it is not tracked, never dirties a checkout and is shared by
7
- // every worktree of the repository): <git common dir>/starci-release/<head sha>.l4.json
8
- // {schema: 'starci/l4-record@1', head, tag, suite: 'local'|'ci', delegated: [{name, why}], logs: [{name, ok, log, ms}], at}
9
- // `suite: ci` (the owner's choice, config.yaml release.suite) lists the rows CI judges as `delegated`, never as logs: a delegated row is not green, it did not run here.
10
- // The release cut (scripts/supervisor/release-cut.mjs, GOVERNANCE lane) calls writeL4Record after its full suite and check
11
- // passed and before the atomic push. Pure fs over the common dir path; the git call is injected (seam `commonDir`).
12
- import path from 'node:path';
13
- import { revParseQuery } from '../api/git/rev-parse-query.mjs';
14
- import { RELEASE_DEFAULTS } from '../../engine/release-config.mjs';
15
- import { proofFileOf, readProofFile, writeProofFile } from '../gates/commit-proof.mjs';
16
-
17
- const L4_SCHEMA = 'starci/l4-record@1';
18
- const RELEASE_TAG = /^v\d[\w.+-]*$/;
19
- const SHA = /^[0-9a-f]{40,64}$/;
20
-
21
- /** The absolute git common dir of `repo`, or null. */
22
- export function gitCommonDir(repo, { run = revParseQuery } = {}) {
23
- const r = run(['--path-format=absolute', '--git-common-dir'], { cwd: repo });
24
- const out = String(r.stdout ?? '').trim();
25
- return r.status === 0 && out ? path.resolve(out) : null;
26
- }
27
-
28
- /** The record file of `head` under `commonDir`. */
29
- const l4RecordPath = ({ commonDir, head }) => proofFileOf({ commonDir, sha: head, kind: 'l4' });
30
-
31
- /**
32
- * Write the L4 record of `head` for release tag `tag`. {ok, file} or {ok:false, reason}: a head that is not a full sha, a tag
33
- * that is not v<version>, a failed suite step or an unreadable common dir writes nothing.
34
- */
35
- export function writeL4Record({ repo, head, tag, logs = [], suite = RELEASE_DEFAULTS.suite, delegated = [], candidate = null, commonDir = null, now = () => new Date(), run = revParseQuery } = {}) {
36
- if (!SHA.test(String(head))) return { ok: false, reason: 'the head is not a full sha' };
37
- if (!RELEASE_TAG.test(String(tag))) return { ok: false, reason: 'the tag is not a release tag v<version>' };
38
- if (logs.some((step) => step?.ok !== true)) return { ok: false, reason: 'a suite step is not green' };
39
- if (!['local', 'ci'].includes(suite)) return { ok: false, reason: 'the suite mode is local or ci' };
40
- if (suite === 'ci' && !delegated.length) return { ok: false, reason: 'a suite: ci record lists the delegated rows' };
41
- if (suite === 'local' && delegated.length) return { ok: false, reason: 'a suite: local record delegates nothing' };
42
- const dir = commonDir ?? gitCommonDir(repo, { run });
43
- if (!dir) return { ok: false, reason: 'the repository has no git common dir' };
44
- const file = l4RecordPath({ commonDir: dir, head });
45
- // candidate: the candidate CI verdict of this sha (scripts/supervisor/release-candidate.mjs), with the owner's override when the emergency path was used.
46
- writeProofFile(file, { schema: L4_SCHEMA, head, tag, suite, delegated, logs, ...(candidate ? { candidate } : {}), at: now().toISOString() });
47
- return { ok: true, file };
48
- }
49
-
50
- /** The L4 record of `head`, or null when there is none or it names another head, tag or schema. */
51
- export function readL4Record({ repo, head, tag = null, commonDir = null, run = revParseQuery } = {}) {
52
- const dir = commonDir ?? gitCommonDir(repo, { run });
53
- if (!dir) return null;
54
- try {
55
- const record = readProofFile(l4RecordPath({ commonDir: dir, head }));
56
- return record?.schema === L4_SCHEMA && record.head === head && (tag === null || record.tag === tag) ? record : null;
57
- } catch { return null; }
58
- }
@@ -1,15 +0,0 @@
1
- // release-suite-mode.mjs - where the full suite of a release is judged, read from the checked-out owner config (config.yaml `release.suite`, engine/release-config.mjs):
2
- // local the release cut runs the root rows and the Linux parity container (the shipped default)
3
- // ci the owner's recorded choice (modules/kernel/owner-rulings.yaml release-suite-ci): the GitHub workflow runs the Linux parity after the push; the root rows still run locally
4
- // The mode is read from the checkout the cut or the hook runs in. A release record names the mode it was cut under, but a record never decides it: the gate accepts a record that says `ci` only
5
- // while this checkout's config says `ci` too. An owner file that is absent, unreadable or invalid reads as `local` (fail closed: the stricter mode).
6
- import { inspectOwnerConfig } from '../../engine/config.mjs';
7
- import { releaseSuiteMode } from '../../engine/release-config.mjs';
8
-
9
- export { SUITE_MODES } from '../../engine/release-config.mjs';
10
-
11
- /** The suite mode of the owner config under `root`: 'local' or 'ci'. */
12
- export function suiteModeOf(root) {
13
- const { config, invalid, error } = inspectOwnerConfig(root);
14
- return invalid || error ? releaseSuiteMode(null) : releaseSuiteMode(config);
15
- }
@@ -1,44 +0,0 @@
1
- // release-notes.mjs - RELEASE_NOTES (knowledge/hfs/rules.yaml, gate runtime): a release tag `v<version>` on HEAD has its CHANGELOG.md section
2
- // `## [<version>]` with no unfinished markers left in it and no `in preparation` mark. The tag message and the GitHub Release are that section
3
- // (docs/git-governance.md), so a tag cut over an unfinished section would publish a half-written release. Only the tags that point at HEAD are
4
- // judged: the release being cut. Pure apart from ctx.read and the git read (ctx.tagsAtHead overrides it).
5
- import { tag } from '../../api/git/tag.mjs';
6
-
7
- export const CODE = 'RELEASE_NOTES';
8
- const RELEASE_TAG = /^v\d[\w.+-]*$/;
9
- const UNFINISHED = /\b(?:TODO|PENDING|TBD)\b|in preparation/i;
10
-
11
- /** The CHANGELOG section of `version`: {found, body}. Pure. */
12
- export function changelogSection(changelog, version) {
13
- const lines = String(changelog ?? '').split(/\r?\n/);
14
- const start = lines.findIndex((line) => { const m = /^## \[([^\]]+)\]/.exec(line); return m && m[1] === version; });
15
- if (start < 0) return { found: false, body: '' };
16
- const end = lines.findIndex((line, i) => i > start && line.startsWith('## ['));
17
- return { found: true, body: lines.slice(start, end < 0 ? undefined : end).join('\n') };
18
- }
19
-
20
- /** The RELEASE_NOTES findings of the release tags `tags` against `changelog` text. Pure. */
21
- export function releaseNotesFindings({ tags, changelog, file = 'CHANGELOG.md' }) {
22
- const found = [];
23
- for (const name of tags.filter((t) => RELEASE_TAG.test(t))) {
24
- const section = changelogSection(changelog, name.slice(1));
25
- if (!section.found) { found.push({ code: CODE, level: 'error', path: file, message: `${CODE} ${file}: the release tag ${name} has no \`## [${name.slice(1)}]\` section: write the release notes before the tag` }); continue; }
26
- const unfinished = new RegExp(UNFINISHED.source, 'gi');
27
- const marks = [...new Set([...section.body.matchAll(unfinished)].map(([mark]) => mark))];
28
- if (marks.length) {
29
- const listedMarks = marks.map((m) => `\`${m}\``).join(', ');
30
- found.push({ code: CODE, level: 'error', path: file, message: `${CODE} ${file}: the section of ${name} still holds ${listedMarks}: finish it before the tag` });
31
- }
32
- }
33
- return found;
34
- }
35
-
36
- /** RELEASE_NOTES over the checkout (ctx of scripts/hfs/runtime-check.mjs): the release tags at HEAD against the CHANGELOG at HEAD. */
37
- export function releaseNotesRepoFindings(ctx) {
38
- let tags = ctx.tagsAtHead;
39
- if (!tags) {
40
- try { const r = tag(['--points-at', 'HEAD'], { dir: ctx.repoRoot }); tags = r.status === 0 ? String(r.stdout).split(/\r?\n/).map((t) => t.trim()).filter(Boolean) : []; } catch { tags = []; }
41
- }
42
- const changelog = ctx.read('CHANGELOG.md');
43
- return tags.some((t) => RELEASE_TAG.test(t)) ? releaseNotesFindings({ tags, changelog }) : [];
44
- }