@starci/hfs 4.12.0 → 4.13.0
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/CHANGELOG.md +43 -0
- package/README.md +8 -9
- package/cache/app-families.d.mts +30 -0
- package/cache/app-families.mjs +94 -0
- package/cache/content-index.d.mts +13 -0
- package/cache/content-index.mjs +37 -0
- package/cache/db-family.d.mts +13 -0
- package/cache/db-family.mjs +37 -0
- package/cache/fake-session.d.mts +42 -0
- package/cache/fake-session.mjs +57 -0
- package/cache/fingerprint.d.mts +28 -0
- package/cache/fingerprint.mjs +120 -0
- package/cache/format-family.d.mts +13 -0
- package/cache/format-family.mjs +49 -0
- package/cache/git.d.mts +11 -0
- package/cache/git.mjs +13 -0
- package/cache/hash.d.mts +15 -0
- package/cache/hash.mjs +58 -0
- package/cache/hfs-family.d.mts +35 -0
- package/cache/hfs-family.mjs +164 -0
- package/cache/import-graph.d.mts +75 -0
- package/cache/import-graph.mjs +204 -0
- package/cache/jsonl.d.mts +31 -0
- package/cache/jsonl.mjs +115 -0
- package/cache/lint-family.d.mts +162 -0
- package/cache/lint-family.mjs +433 -0
- package/cache/order.d.mts +1 -0
- package/cache/order.mjs +2 -0
- package/cache/progress.d.mts +19 -0
- package/cache/progress.mjs +68 -0
- package/cache/quality-baselines.d.mts +109 -0
- package/cache/quality-baselines.mjs +235 -0
- package/cache/records.d.mts +33 -0
- package/cache/records.mjs +54 -0
- package/cache/report.d.mts +19 -0
- package/cache/report.mjs +32 -0
- package/cache/run-log.d.mts +17 -0
- package/cache/run-log.mjs +37 -0
- package/cache/run.d.mts +33 -0
- package/cache/run.mjs +65 -0
- package/cache/session.d.mts +29 -0
- package/cache/session.mjs +197 -0
- package/cache/side-key.d.mts +10 -0
- package/cache/side-key.mjs +47 -0
- package/cache/test-family.d.mts +130 -0
- package/cache/test-family.mjs +258 -0
- package/cache/trust.d.mts +53 -0
- package/cache/trust.mjs +157 -0
- package/cache/types-family.d.mts +74 -0
- package/cache/types-family.mjs +154 -0
- package/cache/types.d.mts +99 -0
- package/cache/types.mjs +8 -0
- package/cache/wiring-family.d.mts +14 -0
- package/cache/wiring-family.mjs +43 -0
- package/coverage/cli.d.mts +4 -2
- package/coverage/cli.mjs +44 -5
- package/db/migrate.d.mts +16 -0
- package/db/migrate.mjs +21 -4
- package/db/postgres.d.mts +23 -0
- package/db/postgres.mjs +32 -1
- package/lint/run.d.mts +47 -1
- package/lint/run.mjs +157 -5
- package/package.json +2 -1
- package/release/gate.d.mts +2 -0
- package/release/gate.mjs +4 -2
- package/release/records.d.mts +15 -0
- package/release/records.mjs +25 -0
- package/release/urgent.mjs +23 -4
- package/runtime/knowledge/grammars/core/playbook.yaml +12 -18
- package/runtime/knowledge/hfs/canon-pins.yaml +8 -8
- package/runtime/knowledge/hfs/ci-standard.yaml +29 -6
- package/runtime/knowledge/hfs/rules.yaml +97 -7
- package/runtime/knowledge/hfs/slots.yaml +19 -7
- package/runtime/modules/kernel/failure-codes.yaml +46 -4
- package/runtime/modules/schemas/application-stacks.schema.yaml +26 -0
- package/runtime/scripts/hfs/architecture/hfs.d.mts +6 -6
- package/runtime/scripts/hfs/check.mjs +1 -1
- package/runtime/scripts/hfs/gate-threshold.mjs +2 -1
- package/runtime/scripts/hfs/rules/boot-proof.d.mts +5 -0
- package/runtime/scripts/hfs/rules/boot-proof.mjs +15 -1
- package/runtime/scripts/hfs/rules/ci-custody.mjs +3 -1
- package/runtime/scripts/hfs/rules/ci-standard.mjs +32 -3
- package/runtime/scripts/hfs/rules/database-migrations.d.mts +14 -0
- package/runtime/scripts/hfs/rules/database-migrations.mjs +55 -0
- package/runtime/scripts/hfs/rules/database.mjs +2 -1
- package/runtime/scripts/hfs/rules/deploy-workflow.mjs +53 -6
- package/runtime/scripts/hfs/rules/release-provenance.mjs +32 -4
- package/runtime/scripts/hfs/rules/stacks.mjs +5 -1
- package/runtime/scripts/hfs/rules/supply-chain.mjs +4 -1
- package/runtime/scripts/hfs/rules/unused-symbol-translators.mjs +98 -7
- package/runtime/scripts/hfs/rules/unused-symbol.d.mts +9 -2
- package/runtime/scripts/hfs/rules/unused-symbol.mjs +15 -2
- package/runtime/scripts/hfs/runtime-rules/git-triggers.mjs +21 -5
- package/runtime/scripts/hfs/stack-deploy-config.d.mts +6 -0
- package/runtime/scripts/hfs/stack-deploy-config.mjs +32 -1
- package/runtime/scripts/hfs/stacks-gate.mjs +2 -0
- package/runtime/scripts/hfs/tree-findings.mjs +3 -1
- package/scaffold/add-inbox-lite.mjs +2 -4
- package/scaffold/add-queue-lite.mjs +2 -3
- package/scaffold/add-table.mjs +2 -4
- package/scaffold/migration-stamp.d.mts +11 -0
- package/scaffold/migration-stamp.mjs +32 -0
- package/src/helpers/hfs-arch-fixture.d.mts +1 -1
- package/src/main.d.mts +4 -1
- package/src/main.mjs +189 -74
- package/src/stack-records-double.d.mts +31 -0
- package/stack/cli.d.mts +3 -1
- package/stack/cli.mjs +17 -11
- package/stack/config.d.mts +6 -0
- package/stack/converge.d.mts +2 -0
- package/stack/converge.mjs +1 -1
- package/stack/db-phases.d.mts +185 -0
- package/stack/db-phases.mjs +346 -0
- package/stack/deploy.d.mts +18 -4
- package/stack/deploy.mjs +235 -70
- package/stack/journal.d.mts +127 -0
- package/stack/journal.mjs +182 -0
- package/stack/manager-backup.d.mts +54 -0
- package/stack/manager-backup.mjs +136 -0
- package/stack/release-store.d.mts +44 -0
- package/stack/release-store.mjs +89 -6
- package/stack/remote.d.mts +2 -0
- package/stack/remote.mjs +31 -11
- package/stack/server-key.d.mts +2 -0
- package/stack/server-key.mjs +1 -1
- package/stack/status.d.mts +49 -0
- package/stack/status.mjs +114 -0
- package/stack/urgent-floor.d.mts +10 -61
- package/stack/urgent-floor.mjs +28 -123
- package/stack/verify.mjs +26 -0
- package/sync/ci-commands.mjs +10 -5
- package/sync/format-worker.d.mts +1 -0
- package/sync/format-worker.mjs +17 -0
- package/sync/format.d.mts +21 -3
- package/sync/format.mjs +99 -7
- package/sync/index.d.mts +3 -6
- package/sync/index.mjs +12 -12
- package/sync/stack-images.d.mts +25 -10
- package/sync/stack-images.mjs +215 -54
- package/templates/app/ci-workflows/github/workflows/ci.yml +114 -7
- package/templates/app/ci-workflows/github/workflows/images.yml +21 -65
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +130 -37
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +114 -7
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +21 -65
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +130 -37
- package/templates/app/gitignore +1 -0
- package/templates/app/gitignore-lite +1 -0
- package/templates/app/package-scripts/db.json +1 -1
- package/templates/app/skeleton/.gitattributes +2 -0
- package/templates/app/skeleton-lite/.gitattributes +2 -0
- package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +2 -1
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +0 -63
|
@@ -126,21 +126,110 @@ function bindTranslator(state, name, initializer, factories) {
|
|
|
126
126
|
bindTranslator(state, element.name, elements[index], factories); });
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* The key a translator helper's member is known by in its declared type: the app's own type alias the member is typed with
|
|
131
|
+
* (`readonly t: OfficeText`), else the member itself. An alias with type arguments or one declared outside the app's sources is no key.
|
|
132
|
+
*/
|
|
133
|
+
function declaredTranslatorKey(state, type, member) {
|
|
134
|
+
const alias = type?.aliasSymbol;
|
|
135
|
+
if (alias) {
|
|
136
|
+
const declarations = alias.declarations ?? [];
|
|
137
|
+
const own = declarations.length > 0 && declarations.every((declaration) => state.sourceNames?.has(declaration.getSourceFile().fileName));
|
|
138
|
+
return own && !type.aliasTypeArguments?.length ? alias : null;
|
|
139
|
+
}
|
|
140
|
+
return member ?? null;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The declared object type an object literal is written for: its contextual type, or, when the literal is what `useMemo(() => literal)` makes
|
|
144
|
+
* and React's own types are not part of the program, the type argument of that useMemo or the type the call itself is written for.
|
|
145
|
+
*/
|
|
146
|
+
function literalType(state, literal) {
|
|
147
|
+
const { ts, checker } = state;
|
|
148
|
+
const known = (type) => type && !(type.flags & ts.TypeFlags.Any) && type.getProperties().length > 0 ? type : null;
|
|
149
|
+
const contextual = known(checker.getContextualType(literal));
|
|
150
|
+
if (contextual)
|
|
151
|
+
return contextual;
|
|
152
|
+
let body = literal;
|
|
153
|
+
while (ts.isParenthesizedExpression(body.parent))
|
|
154
|
+
body = body.parent;
|
|
155
|
+
let fn = body.parent;
|
|
156
|
+
if (fn && ts.isReturnStatement(fn)) {
|
|
157
|
+
while (fn && !ts.isFunctionLike(fn))
|
|
158
|
+
fn = fn.parent;
|
|
159
|
+
}
|
|
160
|
+
else if (!fn || !ts.isArrowFunction(fn) || fn.body !== body)
|
|
161
|
+
return null;
|
|
162
|
+
if (!fn || !ts.isArrowFunction(fn) && !ts.isFunctionExpression(fn))
|
|
163
|
+
return null;
|
|
164
|
+
const call = fn.parent;
|
|
165
|
+
if (!call || !ts.isCallExpression(call) || call.arguments[0] !== fn || leafName(ts, call.expression) !== 'useMemo')
|
|
166
|
+
return null;
|
|
167
|
+
const [argument] = call.typeArguments ?? [];
|
|
168
|
+
return known(argument ? checker.getTypeFromTypeNode(argument) : checker.getContextualType(call));
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* A helper that exposes a bound translator as a member of a declared object type (`useMemo<OfficeWords>(() => ({ t: (key, values) => t(key, values) }))`)
|
|
172
|
+
* binds that member's declared type: every value of that type, wherever the helper is handed on, reads in the namespaces bound to it.
|
|
173
|
+
*/
|
|
174
|
+
function bindTypedMember(state, property, factories) {
|
|
175
|
+
const { ts, checker } = state;
|
|
176
|
+
const literal = property.parent;
|
|
177
|
+
if (!ts.isObjectLiteralExpression(literal))
|
|
178
|
+
return;
|
|
179
|
+
const name = nameText(ts, property.name, { numbers: true });
|
|
180
|
+
if (name === null)
|
|
181
|
+
return;
|
|
182
|
+
const initializer = ts.isShorthandPropertyAssignment(property) ? property.name : property.initializer;
|
|
183
|
+
const value = unwrap(ts, initializer);
|
|
184
|
+
const namespaces = value && ts.isIdentifier(value)
|
|
185
|
+
? state.translators.get(symbolAt(state, value)) ?? wrapperNamespaces(state, value)
|
|
186
|
+
: translatorNamespace(state, initializer, factories) ?? wrapperNamespaces(state, initializer);
|
|
187
|
+
if (!namespaces?.length)
|
|
188
|
+
return;
|
|
189
|
+
const member = literalType(state, literal)?.getProperty(name);
|
|
190
|
+
if (!member)
|
|
191
|
+
return;
|
|
192
|
+
const key = declaredTranslatorKey(state, checker.getTypeOfSymbolAtLocation(member, literal), member);
|
|
193
|
+
if (!key)
|
|
194
|
+
return;
|
|
195
|
+
const bound = state.typedTranslators.get(key) ?? new Set();
|
|
196
|
+
for (const namespace of namespaces)
|
|
197
|
+
bound.add(namespace);
|
|
198
|
+
state.typedTranslators.set(key, bound);
|
|
199
|
+
}
|
|
129
200
|
/** Wrappers depend on translators bound in other modules, so bind until nothing new appears. */
|
|
130
201
|
export function translationBindings(state, sources) {
|
|
202
|
+
const { ts } = state;
|
|
131
203
|
const factories = new Map([...sources.values()].map(source => [source, translationFactories(state, source)]));
|
|
132
|
-
|
|
133
|
-
|
|
204
|
+
state.typedTranslators ??= new Map();
|
|
205
|
+
state.sourceNames ??= new Set([...sources.values()].map((source) => source.fileName));
|
|
206
|
+
const typedSize = () => [...state.typedTranslators.values()].reduce((sum, bound) => sum + bound.size, 0);
|
|
207
|
+
for (let pass = 0, size = -1; pass < 4 && size !== state.translators.size + typedSize(); pass++) {
|
|
208
|
+
size = state.translators.size + typedSize();
|
|
134
209
|
for (const source of sources.values()) {
|
|
135
210
|
const visit = (node) => {
|
|
136
|
-
if (
|
|
211
|
+
if (ts.isVariableDeclaration(node) && node.initializer)
|
|
137
212
|
bindTranslator(state, node.name, node.initializer, factories.get(source));
|
|
138
|
-
|
|
213
|
+
if (ts.isPropertyAssignment(node) || ts.isShorthandPropertyAssignment(node))
|
|
214
|
+
bindTypedMember(state, node, factories.get(source));
|
|
215
|
+
ts.forEachChild(node, visit);
|
|
139
216
|
};
|
|
140
217
|
visit(source);
|
|
141
218
|
}
|
|
142
219
|
}
|
|
143
220
|
}
|
|
221
|
+
/** The namespaces bound to the declared type of a receiver (its own type alias, or the member it reads), if a translator helper bound it. */
|
|
222
|
+
function typedNamespaces(state, receiver) {
|
|
223
|
+
const { ts, checker } = state;
|
|
224
|
+
if (!state.typedTranslators?.size)
|
|
225
|
+
return [];
|
|
226
|
+
const node = unwrap(ts, receiver);
|
|
227
|
+
if (!node)
|
|
228
|
+
return [];
|
|
229
|
+
const member = ts.isPropertyAccessExpression(node) ? symbolAt(state, node.name) : null;
|
|
230
|
+
const keys = [declaredTranslatorKey(state, checker.getTypeAtLocation(node), null), member];
|
|
231
|
+
return [...new Set(keys.flatMap(key => key ? [...state.typedTranslators.get(key) ?? []] : []))];
|
|
232
|
+
}
|
|
144
233
|
/** Namespaces of the translators a receiver can be: declared here, or handed in through arguments and props. */
|
|
145
234
|
function receiverNamespaces(state, receiver) {
|
|
146
235
|
const { ts, checker } = state;
|
|
@@ -201,11 +290,13 @@ export function catalogNamespace(state, callee) {
|
|
|
201
290
|
const resolved = receiverNamespaces(state, receiver);
|
|
202
291
|
if (resolved.length)
|
|
203
292
|
return resolved;
|
|
293
|
+
// A value of a type a translator helper bound reads in that helper's namespaces, on top of what the fallbacks below allow.
|
|
294
|
+
const typed = typedNamespaces(state, receiver);
|
|
204
295
|
const binding = receiverBinding(state, receiver);
|
|
205
296
|
// Observed unresolved producers cannot use the generic namespace fallback.
|
|
206
297
|
if (binding.handedIn && (TRANSLATOR_NAME.test(leafName(ts, receiver)) || translatorShaped(state, receiver)))
|
|
207
|
-
return binding.observed ?
|
|
298
|
+
return binding.observed ? typed : [...typed, null];
|
|
208
299
|
if (ts.isIdentifier(callee) && TRANSLATOR_NAME.test(callee.text))
|
|
209
|
-
return binding.observed ?
|
|
210
|
-
return
|
|
300
|
+
return binding.observed ? typed : [...typed, ''];
|
|
301
|
+
return typed;
|
|
211
302
|
}
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import type { Parsed, Repo } from './rule-types.mjs';
|
|
2
2
|
export declare const HFS_UNUSED_SYMBOL = "HFS_UNUSED_SYMBOL";
|
|
3
3
|
/** The same rule applies to lite and full. Analyze all tracked consumers before filtering findings to a fast-check scope. */
|
|
4
|
-
|
|
4
|
+
type UnusedSymbolInput = {
|
|
5
5
|
repoRoot: string;
|
|
6
6
|
files: string[];
|
|
7
7
|
repo?: Repo;
|
|
8
8
|
isRoot?: boolean;
|
|
9
9
|
inScope?: (file: string) => boolean;
|
|
10
10
|
ts: Parsed;
|
|
11
|
-
}
|
|
11
|
+
};
|
|
12
|
+
/** A seam for the app check cache: the whole-program analysis of a side is a scoped step keyed on the side, so the cache runs it only when the side changed. */
|
|
13
|
+
export interface UnusedSymbolMemo {
|
|
14
|
+
run(repoRoot: string, compute: () => Parsed[]): Parsed[];
|
|
15
|
+
}
|
|
16
|
+
export declare function withUnusedSymbolMemo<T>(seam: UnusedSymbolMemo, work: () => T): T;
|
|
17
|
+
export declare function unusedSymbolFindings(input: UnusedSymbolInput): any[];
|
|
18
|
+
export {};
|
|
@@ -339,8 +339,21 @@ function catalogRead(state, candidate, liveCodes) {
|
|
|
339
339
|
});
|
|
340
340
|
});
|
|
341
341
|
}
|
|
342
|
-
|
|
343
|
-
export function
|
|
342
|
+
let memo = null;
|
|
343
|
+
export function withUnusedSymbolMemo(seam, work) {
|
|
344
|
+
const previous = memo;
|
|
345
|
+
memo = seam;
|
|
346
|
+
try {
|
|
347
|
+
return work();
|
|
348
|
+
}
|
|
349
|
+
finally {
|
|
350
|
+
memo = previous;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
export function unusedSymbolFindings(input) {
|
|
354
|
+
return memo === null ? computeUnusedSymbols(input) : memo.run(input.repoRoot, () => computeUnusedSymbols(input));
|
|
355
|
+
}
|
|
356
|
+
function computeUnusedSymbols({ repoRoot, files, repo = {}, isRoot = false, inScope = () => true, ts: supplied }) {
|
|
344
357
|
const edition = repo.edition ?? 'full';
|
|
345
358
|
if (isRoot || repo.kind === 'runtime' || !['lite', 'full'].includes(edition))
|
|
346
359
|
return [];
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// CI_TRIGGERS_RELEASE_ONLY: runtime workflows and app ci.yml may judge main; release candidates, v* tags and manual dispatch retain their scope.
|
|
1
|
+
// CI_TRIGGERS_RELEASE_ONLY: runtime workflows and app ci.yml may judge main; release candidates, v* tags and manual dispatch retain their scope. ci.yml also judges pull requests
|
|
2
|
+
// to main (pull_request only: no secret reaches it; pull_request_target, workflow_run and schedules stay refused); a reusable workflow (workflow_call) has no trigger of its own.
|
|
2
3
|
import { parseWorkflow, workflowFindings } from './workflow-source.mjs';
|
|
3
4
|
import { workflowEventOptions } from '../../lib/workflow-events.mjs';
|
|
4
5
|
export const CODE = 'CI_TRIGGERS_RELEASE_ONLY';
|
|
@@ -36,11 +37,26 @@ function pushFindings({ ownBranch, config, refuse, release }) {
|
|
|
36
37
|
found.push(refuse(`\`push.${other}\` is not allowed beside the trigger filters`));
|
|
37
38
|
return found;
|
|
38
39
|
}
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
/** A pull request to main, in ci.yml only: the checks of a change, with no filter beyond the base branch. */
|
|
41
|
+
function pullRequestFindings({ file, config, refuse }) {
|
|
42
|
+
if (!RELEASE_WORKFLOW.test(file))
|
|
43
|
+
return [refuse('`pull_request` may start only ci.yml: no other workflow runs the code of a pull request')];
|
|
44
|
+
const filters = config && typeof config === 'object' && !Array.isArray(config) ? config : {};
|
|
45
|
+
const found = [];
|
|
46
|
+
const branches = filters.branches;
|
|
47
|
+
if (!Array.isArray(branches) || branches.length !== 1 || String(branches[0]) !== RUNTIME_BRANCH)
|
|
48
|
+
found.push(refuse(`\`pull_request.branches\` must be exactly ['${RUNTIME_BRANCH}']`));
|
|
49
|
+
for (const other of Object.keys(filters).filter((key) => key !== 'branches'))
|
|
50
|
+
found.push(refuse(`\`pull_request.${other}\` is not allowed beside the base branch`));
|
|
51
|
+
return found;
|
|
52
|
+
}
|
|
53
|
+
function eventFindings({ file, event, config, ownBranch, refuse, release }) {
|
|
54
|
+
if (event === 'workflow_dispatch' || event === 'workflow_call')
|
|
41
55
|
return [];
|
|
56
|
+
if (event === 'pull_request')
|
|
57
|
+
return pullRequestFindings({ file, config, refuse });
|
|
42
58
|
if (event !== 'push')
|
|
43
|
-
return [refuse(`the trigger \`${event}\` is not allowed: only a push of release tags [${RELEASE_TAGS}], of '${CANDIDATE_BRANCHES}' (and, in runtime workflows or app ci.yml, of branch ${RUNTIME_BRANCH}) and workflow_dispatch may start CI`)];
|
|
59
|
+
return [refuse(`the trigger \`${event}\` is not allowed: only a push of release tags [${RELEASE_TAGS}], of '${CANDIDATE_BRANCHES}' (and, in runtime workflows or app ci.yml, of branch ${RUNTIME_BRANCH}), a pull request to ${RUNTIME_BRANCH} in ci.yml, workflow_call and workflow_dispatch may start CI`)];
|
|
44
60
|
return pushFindings({ ownBranch, config, refuse, release });
|
|
45
61
|
}
|
|
46
62
|
/** The CI_TRIGGERS_RELEASE_ONLY findings of one workflow text; a text that is not YAML yields none (the other checks name it). Runtime workflows and app ci.yml may also push on main. */
|
|
@@ -56,7 +72,7 @@ function workflowTriggerFindings({ path: file, text }) {
|
|
|
56
72
|
const events = workflowEventOptions(on, String);
|
|
57
73
|
const release = !file.startsWith('.github/workflows/') && RELEASE_WORKFLOW.test(file);
|
|
58
74
|
const missingPush = release && !Object.hasOwn(events, 'push') ? [refuse(`the app release workflow must run on a push of '${CANDIDATE_BRANCHES}' and of tags [${RELEASE_TAGS}]`)] : [];
|
|
59
|
-
return [...missingPush, ...Object.entries(events).flatMap(([event, config]) => eventFindings({ event, config, ownBranch, refuse, release }))];
|
|
75
|
+
return [...missingPush, ...Object.entries(events).flatMap(([event, config]) => eventFindings({ file, event, config, ownBranch, refuse, release }))];
|
|
60
76
|
}
|
|
61
77
|
/** CI_TRIGGERS_RELEASE_ONLY over every tracked workflow (ctx of scripts/hfs/runtime-check.mjs). */
|
|
62
78
|
export const gitTriggerFindings = (ctx) => workflowFindings(ctx, workflowTriggerFindings);
|
|
@@ -113,6 +113,12 @@ export interface DeployConfig {
|
|
|
113
113
|
environment: Record<string, ServiceEnvironment>;
|
|
114
114
|
configs: ConfigEntry[];
|
|
115
115
|
passEnv: string[];
|
|
116
|
+
/** deploy.auto: a green release tag deploys here by itself. */
|
|
117
|
+
auto: boolean;
|
|
118
|
+
/** deploy.approval: none, or reviewers set on the GitHub Environment. */
|
|
119
|
+
approval: 'none' | 'reviewers';
|
|
120
|
+
/** deploy.platforms: the platforms the images are built for (default linux/amd64). */
|
|
121
|
+
platforms: string[];
|
|
116
122
|
verify: VerifyConfig;
|
|
117
123
|
jobs: JobsConfig | null;
|
|
118
124
|
tasks: Record<string, TaskConfig>;
|
|
@@ -17,7 +17,7 @@ const PINNED_HELPER_IMAGE = /^[a-z0-9./_-]+(?::[A-Za-z0-9._-]+)?@sha256:(?!0{64}
|
|
|
17
17
|
const ARGUMENT = /^[A-Za-z0-9_@%+=:,./-]+$/u;
|
|
18
18
|
const UPPER_ENV = KEY;
|
|
19
19
|
const FIELDS = {
|
|
20
|
-
deploy: ['stack', 'remote', 'images', 'volumes', 'secrets', 'environment', 'configs', 'passEnv', 'postDeploy', 'verify', 'jobs', 'tasks', 'backup', 'ingress', 'registry'],
|
|
20
|
+
deploy: ['stack', 'remote', 'images', 'volumes', 'secrets', 'environment', 'configs', 'passEnv', 'auto', 'approval', 'platforms', 'postDeploy', 'verify', 'jobs', 'tasks', 'backup', 'ingress', 'registry'],
|
|
21
21
|
remote: ['host', 'releasesBase', 'sealed', 'serverRecipient'],
|
|
22
22
|
image: ['app', 'buildArgs'],
|
|
23
23
|
buildArgs: ['values', 'sealed'],
|
|
@@ -38,6 +38,34 @@ const FIELDS = {
|
|
|
38
38
|
registry: ['host', 'visibility'],
|
|
39
39
|
};
|
|
40
40
|
const text = (value) => typeof value === 'string' && value.length > 0;
|
|
41
|
+
const APPROVALS = ['none', 'reviewers'];
|
|
42
|
+
const PLATFORMS = ['linux/amd64', 'linux/arm64'];
|
|
43
|
+
function flag(c, at, value, fallback) {
|
|
44
|
+
if (value === undefined)
|
|
45
|
+
return fallback;
|
|
46
|
+
if (typeof value !== 'boolean')
|
|
47
|
+
c.problem(at, 'must be true or false');
|
|
48
|
+
return value === true;
|
|
49
|
+
}
|
|
50
|
+
function choice(c, at, value, allowed, fallback) {
|
|
51
|
+
if (value === undefined)
|
|
52
|
+
return fallback;
|
|
53
|
+
if (typeof value !== 'string' || !allowed.includes(value)) {
|
|
54
|
+
c.problem(at, `must be one of ${allowed.join(', ')}`);
|
|
55
|
+
return fallback;
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
/** `deploy.platforms`: the platforms the images are built for, default linux/amd64 (the VPS's architecture). */
|
|
60
|
+
function platforms(c, value) {
|
|
61
|
+
if (value === undefined)
|
|
62
|
+
return ['linux/amd64'];
|
|
63
|
+
if (!Array.isArray(value) || value.length === 0 || new Set(value).size !== value.length || value.some((item) => typeof item !== 'string' || !PLATFORMS.includes(item))) {
|
|
64
|
+
c.problem('deploy.platforms', `must be a list of ${PLATFORMS.join(' and ')}, each once`);
|
|
65
|
+
return ['linux/amd64'];
|
|
66
|
+
}
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
41
69
|
const nameField = (c, at, value, pattern = NAME) => patternField(c, at, value, pattern);
|
|
42
70
|
function stringList(c, at, value, itemPattern, { nonEmpty = false } = {}) {
|
|
43
71
|
if (!Array.isArray(value) || (nonEmpty && value.length === 0)) {
|
|
@@ -414,6 +442,9 @@ export function normalizeDeploy(block, { project, probe }) {
|
|
|
414
442
|
environment: environmentBlock(c, block.environment, services, probe),
|
|
415
443
|
configs: configs(c, block.configs, probe),
|
|
416
444
|
passEnv: block.passEnv === undefined ? [] : stringList(c, 'deploy.passEnv', block.passEnv, KEY),
|
|
445
|
+
auto: flag(c, 'deploy.auto', block.auto, false),
|
|
446
|
+
approval: choice(c, 'deploy.approval', block.approval, APPROVALS, 'none'),
|
|
447
|
+
platforms: platforms(c, block.platforms),
|
|
417
448
|
verify: verify(c, block.verify, services),
|
|
418
449
|
jobs: jobs(c, block.jobs, services),
|
|
419
450
|
tasks,
|
|
@@ -308,6 +308,8 @@ const checkStackEntry = (entry, environmentNames, add, { stacksRoot, lite }) =>
|
|
|
308
308
|
}
|
|
309
309
|
if (entry.name === 'k8s')
|
|
310
310
|
return;
|
|
311
|
+
if (entry.name === 'cache')
|
|
312
|
+
return; // the committed check cache (slot app.starcistacks.cache): reserved, never an environment
|
|
311
313
|
if (lite && entry.name === 'images')
|
|
312
314
|
return; // the managed db image (slot app.db.image, ruling lite-db-image-in-stacks), no environment
|
|
313
315
|
if (lite && liteCustodyDirectory(path.join(stacksRoot, entry.name)))
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readTree, treeFacts, untrackedEntries } from './tree.mjs';
|
|
2
2
|
import { liteStarciworkTreeFindings } from './path-findings.mjs';
|
|
3
|
+
const CHECK_CACHE_DIR = '.starcistacks/cache';
|
|
3
4
|
/** The tree findings of R03: empty directories, ghost siblings, untracked entries outside an ignored slot. */
|
|
4
5
|
export function treeFindings({ repoRoot, resolver, tracked = [], reportedUntracked = new Set() }) {
|
|
5
6
|
const inIgnoredSlot = (rel) => resolver.classifyPath(`${rel}/.probe`).tracking === 'ignored';
|
|
@@ -19,5 +20,6 @@ export function treeFindings({ repoRoot, resolver, tracked = [], reportedUntrack
|
|
|
19
20
|
if (!ignored)
|
|
20
21
|
findings.push({ code: 'HFS_UNTRACKED_ROOT_ENTRY', level: 'error', path: bare, message: `${entry} is neither tracked nor git-ignored, and no ignored slot owns it` });
|
|
21
22
|
}
|
|
22
|
-
|
|
23
|
+
// The committed check cache is written by the CLI and committed with the change: before that commit its files are untracked, and a run's own output is no finding.
|
|
24
|
+
return findings.filter((finding) => !(finding.path === CHECK_CACHE_DIR || String(finding.path).startsWith(`${CHECK_CACHE_DIR}/`)));
|
|
23
25
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { nextMigrationStamp } from "./migration-stamp.mjs";
|
|
1
2
|
import fs from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { render, TEMPLATES_DIR } from "../sync/index.mjs";
|
|
@@ -8,7 +9,6 @@ import { byCodeUnit } from "../report/order.mjs";
|
|
|
8
9
|
import { withSqlIdentifierError } from "./add-database-lite.mjs";
|
|
9
10
|
const MIGRATION = /^(\d{14})_[a-z][a-z0-9]*(?:-[a-z0-9]+)*\.sql$/;
|
|
10
11
|
const PLACEHOLDER = /@@([A-Za-z][A-Za-z0-9]*)@@/g;
|
|
11
|
-
const timestampOf = (now) => new Date(now()).toISOString().replace(/\D/g, "").slice(0, 14);
|
|
12
12
|
const upperOf = (name) => name.replaceAll("-", "_").toUpperCase();
|
|
13
13
|
const camelOf = (name) => {
|
|
14
14
|
const pascal = pascalOf(name);
|
|
@@ -162,10 +162,8 @@ export function addLiteWebhookInbox({ root, repo, noun, provider, values, now =
|
|
|
162
162
|
const suffix = `${provider}-inbox.sql`;
|
|
163
163
|
if (migrations.some((file) => file.endsWith(`_${suffix}`)))
|
|
164
164
|
throw new ScaffoldError("HFS_ADD_EXISTS", `webhook ${provider} already has an inbox migration`);
|
|
165
|
-
const stamp = timestampOf(now);
|
|
166
165
|
const latest = migrations.at(-1)?.slice(0, 14);
|
|
167
|
-
|
|
168
|
-
throw new ScaffoldError("HFS_ADD_MIGRATION_ORDER", `the current UTC timestamp ${stamp} is not later than the latest migration ${latest}; fix the clock or wait for the next second`);
|
|
166
|
+
const stamp = nextMigrationStamp(now, latest);
|
|
169
167
|
const renderValues = {
|
|
170
168
|
...values,
|
|
171
169
|
provider,
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// its sealed secret. The verb also declares redis in hfs.json sides.be.backing with its bullmq client, and renders the one
|
|
5
5
|
// declared Redis into every docker-swarm environment of the stack: the stateful component, its backup and restore record, the
|
|
6
6
|
// sealed redis-password member and its grants, the AOF service on a named volume and an internal network, and the images lock.
|
|
7
|
+
import { nextMigrationStamp } from './migration-stamp.mjs';
|
|
7
8
|
import fs from 'node:fs';
|
|
8
9
|
import path from 'node:path';
|
|
9
10
|
import { parseYaml } from '../runtime/engine/yaml.mjs';
|
|
@@ -104,10 +105,8 @@ function migrationFiles({ repoRoot, beRoot, patterns, now }) {
|
|
|
104
105
|
return [];
|
|
105
106
|
const folder = path.join(repoRoot, 'db', 'migrations');
|
|
106
107
|
const migrations = fs.existsSync(folder) ? fs.readdirSync(folder).filter((file) => MIGRATION.test(file)).sort(byCodeUnit) : [];
|
|
107
|
-
const stamp = new Date(now()).toISOString().replace(/\D/g, '').slice(0, 14);
|
|
108
108
|
const latest = migrations.at(-1)?.slice(0, 14);
|
|
109
|
-
|
|
110
|
-
throw new ScaffoldError('HFS_ADD_MIGRATION_ORDER', `the current UTC timestamp ${stamp} is not later than the latest migration ${latest}; fix the clock or wait for the next second`);
|
|
109
|
+
const stamp = nextMigrationStamp(now, latest);
|
|
111
110
|
const text = sections.map(([, , source]) => template(`be/patterns/${source}`)).join('\n');
|
|
112
111
|
return [{ relative: `db/migrations/${stamp}_${sections.map(([name]) => name).join('-')}.sql`, text }];
|
|
113
112
|
}
|
package/scaffold/add-table.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { nextMigrationStamp } from "./migration-stamp.mjs";
|
|
1
2
|
import fs from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { loadSlotManifest, readRepoDeclaration, } from "../runtime/scripts/hfs/slots.mjs";
|
|
@@ -114,7 +115,6 @@ const POSTGRES_RESERVED = new Set([
|
|
|
114
115
|
"window",
|
|
115
116
|
"with",
|
|
116
117
|
]);
|
|
117
|
-
const timestampOf = (now) => new Date(now()).toISOString().replace(/\D/g, "").slice(0, 14);
|
|
118
118
|
const snakeOf = (name) => name.replaceAll("-", "_");
|
|
119
119
|
const featureOf = (name) => name.replaceAll("_", "-");
|
|
120
120
|
const sqlIdentifierOf = (name) => POSTGRES_RESERVED.has(name) ? `"${name}"` : name;
|
|
@@ -328,10 +328,8 @@ function nextMigration({ root, feature, requestedName, now }) {
|
|
|
328
328
|
: [];
|
|
329
329
|
if (migrations.some((file) => file.endsWith(`_${feature}.sql`)))
|
|
330
330
|
throw new ScaffoldError("HFS_ADD_EXISTS", `table ${requestedName} already has a migration`);
|
|
331
|
-
const stamp = timestampOf(now);
|
|
332
331
|
const latest = migrations.at(-1)?.slice(0, 14);
|
|
333
|
-
|
|
334
|
-
throw new ScaffoldError("HFS_ADD_MIGRATION_ORDER", `the current UTC timestamp ${stamp} is not later than the latest migration ${latest}; fix the clock or wait for the next second`);
|
|
332
|
+
const stamp = nextMigrationStamp(now, latest);
|
|
335
333
|
return stamp;
|
|
336
334
|
}
|
|
337
335
|
function tableValues({ feature, table }) {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The UTC timestamp (YYYYMMDDHHMMSS) of the next migration: the clock's second, strictly later than the latest migration.
|
|
3
|
+
* Two migrations written within one second (the scaffold of an app followed at once by `add table`) would collide, so the
|
|
4
|
+
* stamp then moves to the second after the latest; a latest more than a few seconds ahead of the clock is a clock that is
|
|
5
|
+
* really behind, and is refused (HFS_ADD_MIGRATION_ORDER).
|
|
6
|
+
*
|
|
7
|
+
* @param now - The clock, in milliseconds.
|
|
8
|
+
* @param latest - The timestamp of the latest migration of the folder, if any.
|
|
9
|
+
* @returns The 14-digit stamp for the new migration.
|
|
10
|
+
*/
|
|
11
|
+
export declare const nextMigrationStamp: (now: () => number, latest: string | undefined) => string;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { ScaffoldError } from "./service.mjs";
|
|
2
|
+
/** How far ahead of the clock the latest migration may be before the clock counts as behind (a collision, not a wrong clock). */
|
|
3
|
+
const CLOCK_SKEW_SECONDS = 5;
|
|
4
|
+
const STAMP = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/u;
|
|
5
|
+
const secondsOf = (stamp) => {
|
|
6
|
+
const parts = STAMP.exec(stamp);
|
|
7
|
+
if (!parts)
|
|
8
|
+
return null;
|
|
9
|
+
const [, year, month, day, hour, minute, second] = parts.map(Number);
|
|
10
|
+
return Date.UTC(year ?? 0, (month ?? 1) - 1, day, hour, minute, second) / 1000;
|
|
11
|
+
};
|
|
12
|
+
const stampOfSeconds = (seconds) => new Date(seconds * 1000).toISOString().replace(/\D/gu, "").slice(0, 14);
|
|
13
|
+
/**
|
|
14
|
+
* The UTC timestamp (YYYYMMDDHHMMSS) of the next migration: the clock's second, strictly later than the latest migration.
|
|
15
|
+
* Two migrations written within one second (the scaffold of an app followed at once by `add table`) would collide, so the
|
|
16
|
+
* stamp then moves to the second after the latest; a latest more than a few seconds ahead of the clock is a clock that is
|
|
17
|
+
* really behind, and is refused (HFS_ADD_MIGRATION_ORDER).
|
|
18
|
+
*
|
|
19
|
+
* @param now - The clock, in milliseconds.
|
|
20
|
+
* @param latest - The timestamp of the latest migration of the folder, if any.
|
|
21
|
+
* @returns The 14-digit stamp for the new migration.
|
|
22
|
+
*/
|
|
23
|
+
export const nextMigrationStamp = (now, latest) => {
|
|
24
|
+
const stamp = stampOfSeconds(Math.floor(now() / 1000));
|
|
25
|
+
if (!latest || stamp > latest)
|
|
26
|
+
return stamp;
|
|
27
|
+
const ahead = secondsOf(latest);
|
|
28
|
+
const current = secondsOf(stamp);
|
|
29
|
+
if (ahead === null || current === null || ahead - current > CLOCK_SKEW_SECONDS)
|
|
30
|
+
throw new ScaffoldError("HFS_ADD_MIGRATION_ORDER", `the current UTC timestamp ${stamp} is not later than the latest migration ${latest}; fix the clock or wait for the next second`);
|
|
31
|
+
return stampOfSeconds(ahead + 1);
|
|
32
|
+
};
|
package/src/main.d.mts
CHANGED
|
@@ -39,6 +39,9 @@ interface AppIo {
|
|
|
39
39
|
stackIo?: Record<string, unknown> | undefined;
|
|
40
40
|
runLinter?: LinterRunner | undefined;
|
|
41
41
|
checkApp?: ((args: Dynamic) => Dynamic) | undefined;
|
|
42
|
+
/** --quiet: no progress on stderr (the diagnosis stays). */
|
|
43
|
+
quiet?: boolean | undefined;
|
|
44
|
+
stderrIsTty?: boolean | undefined;
|
|
42
45
|
}
|
|
43
46
|
/** The options of main: every seam of the app IO, each optional. */
|
|
44
47
|
type MainOptions = {
|
|
@@ -54,5 +57,5 @@ export declare function appRootAbove(dir: string): string;
|
|
|
54
57
|
* the HFS check and `stackIo` Swarm tools; production defaults execute every check. `hostIdentity` ({ label, read }) is the
|
|
55
58
|
* harness CLI's own secret.env SOPS_AGE_KEY, which every verb that opens a sealed member (secret, stack, release, db) reads last for the age identity (decryptionIdentity); `prompt` replaces its masked prompt.
|
|
56
59
|
*/
|
|
57
|
-
export declare function main(argv: string[], { cwd: baseCwd, harness, stdout, stderr, presets, prettier, lock, run, stackMain, env, stdin, prompt, hostIdentity, sops, tools, platform, stackIo, runLinter, checkApp, }?: MainOptions): Promise<number>;
|
|
60
|
+
export declare function main(argv: string[], { cwd: baseCwd, harness, stdout, stderr, presets, prettier, lock, run, stackMain, env, stdin, prompt, hostIdentity, sops, tools, platform, stackIo, runLinter, checkApp, stderrIsTty, }?: MainOptions): Promise<number>;
|
|
58
61
|
export {};
|