@starci/hfs 4.12.2 → 4.13.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/CHANGELOG.md +50 -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 +20 -8
- package/runtime/modules/kernel/failure-codes.yaml +46 -4
- package/runtime/modules/schemas/application-stacks.schema.yaml +48 -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 +18 -0
- package/runtime/scripts/hfs/stack-deploy-config.mjs +53 -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 +33 -0
- package/stack/ci-key.d.mts +11 -0
- package/stack/ci-key.mjs +44 -0
- package/stack/cli.d.mts +7 -1
- package/stack/cli.mjs +22 -13
- package/stack/config.d.mts +15 -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 +22 -5
- package/stack/deploy.mjs +242 -72
- package/stack/journal.d.mts +146 -0
- package/stack/journal.mjs +191 -0
- package/stack/manager-backup.d.mts +54 -0
- package/stack/manager-backup.mjs +136 -0
- package/stack/notify.d.mts +9 -0
- package/stack/notify.mjs +32 -0
- package/stack/release-store.d.mts +49 -1
- package/stack/release-store.mjs +102 -16
- package/stack/remote.d.mts +2 -0
- package/stack/remote.mjs +32 -11
- package/stack/rollback-guard.d.mts +10 -0
- package/stack/rollback-guard.mjs +41 -0
- package/stack/server-key.d.mts +2 -0
- package/stack/server-key.mjs +1 -1
- package/stack/ssh-gate.d.mts +35 -0
- package/stack/ssh-gate.mjs +145 -0
- package/stack/status.d.mts +51 -0
- package/stack/status.mjs +115 -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
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// R271: a product release keeps its v<semver> tag, root package version, image tags and database deployment ref together.
|
|
1
|
+
// R271: a product release keeps its v<semver> tag, root package version, image tags and database deployment ref together. The pushed images are named by the
|
|
2
|
+
// commit (sha-<sha>); the release name v<version> is a retag of that digest under a release guard. A migration runs in the stack deploy after the release tag.
|
|
2
3
|
// Ordinary untagged development trees have no release version to compare. Workflow checks still run there in both editions.
|
|
3
4
|
// YAML is parsed, so comments and unrelated jobs cannot supply a guard. Shell guards are deliberately a small, explicit form;
|
|
4
5
|
// opaque helper scripts and computed image metadata cannot prove provenance in this static check.
|
|
@@ -171,10 +172,12 @@ function dbJobConstrained(job, guarded) {
|
|
|
171
172
|
constrained = false;
|
|
172
173
|
return constrained;
|
|
173
174
|
}
|
|
175
|
+
// The database deploy workflow is retired in a lite app: its migrations are the migrate phase of the stack deploy (starci app stack deploy, tag-gated by
|
|
176
|
+
// stack-deploy.yml). A full app that keeps its own db-deploy.yml, and a lite app that has not synced yet, are still judged here.
|
|
174
177
|
function dbFindings(file, workflow) {
|
|
175
178
|
const jobs = Object.entries(workflow.jobs ?? {});
|
|
176
179
|
if (!jobs.length)
|
|
177
|
-
return [found(HFS_DB_DEPLOY_UNGUARDED, file, `${file} has no verifiable database deploy job; require a release tag before migration`)];
|
|
180
|
+
return [found(HFS_DB_DEPLOY_UNGUARDED, file, `${file} has no verifiable database deploy job; require a release tag before migration (a lite app's migrations are the migrate phase of the stack deploy: run starci app sync --execute to retire this file)`)];
|
|
178
181
|
const findings = [];
|
|
179
182
|
for (const [name, job] of jobs) {
|
|
180
183
|
if (!job || typeof job !== 'object')
|
|
@@ -203,17 +206,42 @@ function imageTagIsRelease(value, version) {
|
|
|
203
206
|
return SEMVER.test(String(version)) && ref.endsWith(`:v${version}`);
|
|
204
207
|
});
|
|
205
208
|
}
|
|
209
|
+
/** A commit tag: `<repo>:sha-${{ github.sha }}`, immutable and per commit, so any ref may push it. */
|
|
210
|
+
const COMMIT_TAG = /^.+:sha-\$\{\{\s*github\.sha\s*\}\}$/;
|
|
211
|
+
function imageTagsAreCommit(value) {
|
|
212
|
+
if (typeof value !== 'string')
|
|
213
|
+
return false;
|
|
214
|
+
const tags = value.split(/[\n,]+/).map((tag) => tag.trim()).filter(Boolean);
|
|
215
|
+
return tags.length > 0 && tags.every((tag) => COMMIT_TAG.test(tag));
|
|
216
|
+
}
|
|
217
|
+
/** The release guard of a reusable images workflow: a step under `if: ${{ inputs.release }}` whose script rejects a ref that is not refs/tags/v*. */
|
|
218
|
+
function inputReleaseGuard(step) {
|
|
219
|
+
if (typeof step.run !== 'string' || step['continue-on-error'] || !/^\$\{\{\s*inputs\.release\s*\}\}$/.test(String(step.if ?? '').trim()))
|
|
220
|
+
return false;
|
|
221
|
+
const first = step.run.trim().split(/\r?\n/)[0].trim();
|
|
222
|
+
return /^\[\[\s*["']?\$(?:GITHUB_REF|\{GITHUB_REF\})["']?\s*==\s*refs\/tags\/v\*\s*\]\]\s*\|\|\s*exit\s+[1-9]\d*\s*;?$/.test(first);
|
|
223
|
+
}
|
|
224
|
+
/** The release name v<version> is a retag of the commit digest (`imagetools create --tag <repo>:$GITHUB_REF_NAME <repo>:sha-...`): only after the guard, only on a release run. */
|
|
225
|
+
function retagIsGuarded(job, step) {
|
|
226
|
+
const steps = job.steps ?? [];
|
|
227
|
+
const guard = steps.findIndex(inputReleaseGuard);
|
|
228
|
+
return guard >= 0 && guard < steps.indexOf(step) && /^\$\{\{\s*inputs\.release\s*&&/.test(String(step.if ?? '').trim());
|
|
229
|
+
}
|
|
206
230
|
function imageFindings(file, workflow, version) {
|
|
207
231
|
const findings = [];
|
|
208
232
|
let builds = 0;
|
|
209
233
|
for (const [name, job] of Object.entries(workflow.jobs ?? {})) {
|
|
210
234
|
for (const step of job?.steps ?? []) {
|
|
235
|
+
if (typeof step.run === 'string' && /\bimagetools\s+create\b/.test(step.run) && !retagIsGuarded(job, step)) {
|
|
236
|
+
findings.push(found(HFS_RELEASE_TAG_MISMATCH, file, `${file} job ${name} retags an image outside a release guard; put it after a step \`if: \${{ inputs.release }}\` that rejects a ref other than refs/tags/v*, and under \`if: \${{ inputs.release && ... }}\``, { job: name }));
|
|
237
|
+
}
|
|
211
238
|
if (!(step.uses ?? '').startsWith('docker/build-push-action@'))
|
|
212
239
|
continue;
|
|
213
240
|
builds += 1;
|
|
214
241
|
const guarded = tagOnlyTrigger(workflow) || refProof(job.if) === RELEASE_REF || refProof(step.if) === RELEASE_REF;
|
|
215
|
-
|
|
216
|
-
|
|
242
|
+
// An immutable commit tag (sha-<sha>) is pushed by any ref; a release name must come from the checked-out release ref under a guard.
|
|
243
|
+
if (!checkoutPreservesRef(job) || !(imageTagsAreCommit(step.with?.tags) || (guarded && imageTagIsRelease(step.with?.tags, version))))
|
|
244
|
+
findings.push(found(HFS_RELEASE_TAG_MISMATCH, file, `${file} job ${name} must build the checked-out ref and tag every image with sha-\${{ github.sha }} (the release name v<package.json version> is a guarded retag of that digest), or tag with v<version> or github.ref_name under a release-tag guard`, { job: name }));
|
|
217
245
|
}
|
|
218
246
|
}
|
|
219
247
|
if (!builds)
|
|
@@ -20,7 +20,8 @@ const allowedExpressions = (allows) => allows.flatMap((entry) => braceVariants(e
|
|
|
20
20
|
const shapeFinding = (file, message, extra) => found(STACKS_SHAPE, file, message, extra);
|
|
21
21
|
function treeFindings(files, allowed, prefix) {
|
|
22
22
|
const findings = [];
|
|
23
|
-
|
|
23
|
+
// `cache` is reserved: .starcistacks/cache/ is the committed check cache (slot app.starcistacks.cache), written by the CLI, not an environment.
|
|
24
|
+
for (const file of files.filter((f) => f.startsWith(prefix) && !f.startsWith(`${prefix}cache/`))) {
|
|
24
25
|
const rel = file.slice(prefix.length);
|
|
25
26
|
if (/\.enc(?:\.yaml)?$/.test(rel) && !INSIDE_SECRETS.test(rel))
|
|
26
27
|
findings.push(shapeFinding(file, `${file} is a sealed secret outside <env>/secrets/<slug>.enc.yaml; move it to .starcistacks/<env>/secrets/`));
|
|
@@ -34,6 +35,9 @@ function declarationFindings(files, prefix, declaration) {
|
|
|
34
35
|
if (!files.includes(declared))
|
|
35
36
|
return [];
|
|
36
37
|
const findings = [];
|
|
38
|
+
const environments = declaration.doc?.environments;
|
|
39
|
+
if (environments !== null && typeof environments === 'object' && Object.hasOwn(environments, 'cache'))
|
|
40
|
+
findings.push(shapeFinding(declared, `${declared} declares an environment named cache; the name is reserved for the committed check cache (.starcistacks/cache/)`));
|
|
37
41
|
if (declaration.error)
|
|
38
42
|
return [shapeFinding(declared, `${declared} cannot be read: ${declaration.error}`)];
|
|
39
43
|
const services = declaration.doc?.services;
|
|
@@ -5,7 +5,10 @@ const DEPENDABOT = '.github/dependabot.yml';
|
|
|
5
5
|
const CI = '.github/workflows/ci.yml';
|
|
6
6
|
const IMAGES = '.github/workflows/images.yml';
|
|
7
7
|
const ECOSYSTEMS = ['npm', 'github-actions', 'docker'];
|
|
8
|
-
|
|
8
|
+
// A job the plan or the caller decides is no disabled job: a reusable workflow runs a job on `if: ${{ inputs.release }}` whenever its caller asks, and the CI jobs
|
|
9
|
+
// run on `if: ${{ needs.plan.outputs.checks == 'true' }}` on every run that checks (the plan leaves them out only where a green run of the same commit stands in).
|
|
10
|
+
const DECIDED_CONDITION = /^\$\{\{\s*(?:inputs\.[A-Za-z_][\w-]*|needs\.plan\.outputs\.[A-Za-z_]\w*\s*==\s*'true')\s*\}\}$/;
|
|
11
|
+
const enabled = (entry) => entry && (entry.if === undefined || entry.if === true || entry.if === 'true' || (typeof entry.if === 'string' && DECIDED_CONDITION.test(entry.if.trim()))) && !entry['continue-on-error'];
|
|
9
12
|
function shellSeparator(line, index, quote) {
|
|
10
13
|
if (quote)
|
|
11
14
|
return null;
|
|
@@ -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);
|
|
@@ -99,6 +99,16 @@ export interface BackupConfig {
|
|
|
99
99
|
volume: string;
|
|
100
100
|
}>;
|
|
101
101
|
}
|
|
102
|
+
/** The outcomes of a deploy attempt a notification can be sent for. */
|
|
103
|
+
export declare const NOTIFY_OUTCOMES: readonly ["accepted", "rolled-back", "rollback-failed", "failed", "refused"];
|
|
104
|
+
/** `deploy.notify`: the sealed member and key that hold the webhook URL (https), and the outcomes that are announced. */
|
|
105
|
+
export interface NotifyConfig {
|
|
106
|
+
webhook: {
|
|
107
|
+
member: string;
|
|
108
|
+
key: string;
|
|
109
|
+
};
|
|
110
|
+
on: string[];
|
|
111
|
+
}
|
|
102
112
|
export interface RegistryConfig {
|
|
103
113
|
host: string;
|
|
104
114
|
visibility: string;
|
|
@@ -113,11 +123,19 @@ export interface DeployConfig {
|
|
|
113
123
|
environment: Record<string, ServiceEnvironment>;
|
|
114
124
|
configs: ConfigEntry[];
|
|
115
125
|
passEnv: string[];
|
|
126
|
+
/** deploy.auto: a green release tag deploys here by itself. */
|
|
127
|
+
auto: boolean;
|
|
128
|
+
/** deploy.approval: none, or reviewers set on the GitHub Environment. */
|
|
129
|
+
approval: 'none' | 'reviewers';
|
|
130
|
+
/** deploy.platforms: the platforms the images are built for (default linux/amd64). */
|
|
131
|
+
platforms: string[];
|
|
116
132
|
verify: VerifyConfig;
|
|
117
133
|
jobs: JobsConfig | null;
|
|
118
134
|
tasks: Record<string, TaskConfig>;
|
|
119
135
|
postDeploy: string[];
|
|
120
136
|
backup: BackupConfig | null;
|
|
137
|
+
/** deploy.notify: one webhook message per deploy outcome; null (off) unless declared. */
|
|
138
|
+
notify: NotifyConfig | null;
|
|
121
139
|
ingress: IngressConfig | null;
|
|
122
140
|
registry: RegistryConfig;
|
|
123
141
|
}
|
|
@@ -5,6 +5,8 @@ import { isPlainObject as plain } from '../../engine/plain-object.mjs';
|
|
|
5
5
|
import { closedFields, collector, patternField, treeFile } from './stack-config-kit.mjs';
|
|
6
6
|
import { list } from '../lib/list.mjs';
|
|
7
7
|
import { normalizeIngress } from './stack-ingress-config.mjs';
|
|
8
|
+
/** The outcomes of a deploy attempt a notification can be sent for. */
|
|
9
|
+
export const NOTIFY_OUTCOMES = ['accepted', 'rolled-back', 'rollback-failed', 'failed', 'refused'];
|
|
8
10
|
export const DEPLOY_CONFIG_CODE = 'STACKS_DEPLOY_CONFIG_INVALID';
|
|
9
11
|
export const NAME = /^[a-z][a-z0-9-]*$/u;
|
|
10
12
|
export const KEY = /^[A-Z][A-Z0-9_]*$/u;
|
|
@@ -17,7 +19,7 @@ const PINNED_HELPER_IMAGE = /^[a-z0-9./_-]+(?::[A-Za-z0-9._-]+)?@sha256:(?!0{64}
|
|
|
17
19
|
const ARGUMENT = /^[A-Za-z0-9_@%+=:,./-]+$/u;
|
|
18
20
|
const UPPER_ENV = KEY;
|
|
19
21
|
const FIELDS = {
|
|
20
|
-
deploy: ['stack', 'remote', 'images', 'volumes', 'secrets', 'environment', 'configs', 'passEnv', 'postDeploy', 'verify', 'jobs', 'tasks', 'backup', 'ingress', 'registry'],
|
|
22
|
+
deploy: ['stack', 'remote', 'images', 'volumes', 'secrets', 'environment', 'configs', 'passEnv', 'auto', 'approval', 'platforms', 'postDeploy', 'verify', 'jobs', 'tasks', 'backup', 'notify', 'ingress', 'registry'],
|
|
21
23
|
remote: ['host', 'releasesBase', 'sealed', 'serverRecipient'],
|
|
22
24
|
image: ['app', 'buildArgs'],
|
|
23
25
|
buildArgs: ['values', 'sealed'],
|
|
@@ -36,8 +38,38 @@ const FIELDS = {
|
|
|
36
38
|
backup: ['image', 'keepDays', 'dir', 'volumes'],
|
|
37
39
|
backupVolume: ['name', 'volume'],
|
|
38
40
|
registry: ['host', 'visibility'],
|
|
41
|
+
notify: ['webhook', 'on'],
|
|
42
|
+
webhook: ['member', 'key'],
|
|
39
43
|
};
|
|
40
44
|
const text = (value) => typeof value === 'string' && value.length > 0;
|
|
45
|
+
const APPROVALS = ['none', 'reviewers'];
|
|
46
|
+
const PLATFORMS = ['linux/amd64', 'linux/arm64'];
|
|
47
|
+
function flag(c, at, value, fallback) {
|
|
48
|
+
if (value === undefined)
|
|
49
|
+
return fallback;
|
|
50
|
+
if (typeof value !== 'boolean')
|
|
51
|
+
c.problem(at, 'must be true or false');
|
|
52
|
+
return value === true;
|
|
53
|
+
}
|
|
54
|
+
function choice(c, at, value, allowed, fallback) {
|
|
55
|
+
if (value === undefined)
|
|
56
|
+
return fallback;
|
|
57
|
+
if (typeof value !== 'string' || !allowed.includes(value)) {
|
|
58
|
+
c.problem(at, `must be one of ${allowed.join(', ')}`);
|
|
59
|
+
return fallback;
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
/** `deploy.platforms`: the platforms the images are built for, default linux/amd64 (the VPS's architecture). */
|
|
64
|
+
function platforms(c, value) {
|
|
65
|
+
if (value === undefined)
|
|
66
|
+
return ['linux/amd64'];
|
|
67
|
+
if (!Array.isArray(value) || value.length === 0 || new Set(value).size !== value.length || value.some((item) => typeof item !== 'string' || !PLATFORMS.includes(item))) {
|
|
68
|
+
c.problem('deploy.platforms', `must be a list of ${PLATFORMS.join(' and ')}, each once`);
|
|
69
|
+
return ['linux/amd64'];
|
|
70
|
+
}
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
41
73
|
const nameField = (c, at, value, pattern = NAME) => patternField(c, at, value, pattern);
|
|
42
74
|
function stringList(c, at, value, itemPattern, { nonEmpty = false } = {}) {
|
|
43
75
|
if (!Array.isArray(value) || (nonEmpty && value.length === 0)) {
|
|
@@ -381,6 +413,22 @@ function backup(c, block) {
|
|
|
381
413
|
c.problem('deploy.backup.volumes', 'must list at least one volume');
|
|
382
414
|
return { image: block.image, keepDays: block.keepDays ?? 14, dir: block.dir ?? null, volumes };
|
|
383
415
|
}
|
|
416
|
+
/**
|
|
417
|
+
* `deploy.notify`: off unless declared. The webhook URL is a sealed value (never in the declaration, the log or the journal); the default announces every
|
|
418
|
+
* outcome that changed or tried to change the services (accepted, rolled-back, rollback-failed, failed), and `on` narrows or adds `refused`.
|
|
419
|
+
*/
|
|
420
|
+
function notify(c, block, members) {
|
|
421
|
+
if (block === undefined)
|
|
422
|
+
return null;
|
|
423
|
+
if (!closedFields(c, 'deploy.notify', block, FIELDS.notify))
|
|
424
|
+
return null;
|
|
425
|
+
if (!closedFields(c, 'deploy.notify.webhook', block.webhook, FIELDS.webhook))
|
|
426
|
+
return null;
|
|
427
|
+
memberRef(c, 'deploy.notify.webhook.member', block.webhook.member, members);
|
|
428
|
+
patternField(c, 'deploy.notify.webhook.key', block.webhook.key, KEY);
|
|
429
|
+
const on = block.on === undefined ? ['accepted', 'rolled-back', 'rollback-failed', 'failed'] : stringList(c, 'deploy.notify.on', block.on, new RegExp(`^(?:${NOTIFY_OUTCOMES.join('|')})$`, 'u'), { nonEmpty: true });
|
|
430
|
+
return { webhook: { member: block.webhook.member, key: block.webhook.key }, on };
|
|
431
|
+
}
|
|
384
432
|
function ingress(c, block, probe) {
|
|
385
433
|
if (block === undefined)
|
|
386
434
|
return null;
|
|
@@ -414,11 +462,15 @@ export function normalizeDeploy(block, { project, probe }) {
|
|
|
414
462
|
environment: environmentBlock(c, block.environment, services, probe),
|
|
415
463
|
configs: configs(c, block.configs, probe),
|
|
416
464
|
passEnv: block.passEnv === undefined ? [] : stringList(c, 'deploy.passEnv', block.passEnv, KEY),
|
|
465
|
+
auto: flag(c, 'deploy.auto', block.auto, false),
|
|
466
|
+
approval: choice(c, 'deploy.approval', block.approval, APPROVALS, 'none'),
|
|
467
|
+
platforms: platforms(c, block.platforms),
|
|
417
468
|
verify: verify(c, block.verify, services),
|
|
418
469
|
jobs: jobs(c, block.jobs, services),
|
|
419
470
|
tasks,
|
|
420
471
|
postDeploy: postDeploy(c, block.postDeploy, tasks),
|
|
421
472
|
backup: backup(c, block.backup),
|
|
473
|
+
notify: notify(c, block.notify, members),
|
|
422
474
|
ingress: ingress(c, block.ingress, probe),
|
|
423
475
|
registry: registry(c, block.registry),
|
|
424
476
|
};
|
|
@@ -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 {};
|