@starci/hfs 4.4.0 → 4.5.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 +8 -0
- package/README.md +1 -1
- package/ci/cli.mjs +64 -0
- package/ci/github.mjs +41 -0
- package/ci/secret.mjs +28 -0
- package/ci/vars.mjs +33 -0
- package/ci/verify.mjs +36 -0
- package/db/backup-receipt.mjs +129 -0
- package/db/dump.mjs +66 -0
- package/db/migrate.mjs +8 -5
- package/db/post-cutover.mjs +4 -4
- package/db/reset.mjs +125 -0
- package/emit/contracts.mjs +2 -2
- package/emit/db-types.mjs +100 -7
- package/lint/run.mjs +30 -8
- package/package.json +2 -1
- package/release/ci-red.mjs +70 -0
- package/release/gate.mjs +53 -13
- package/release/index.mjs +8 -6
- package/release/push.mjs +1 -1
- package/report/sonar.mjs +1 -1
- package/runtime/config.example.yaml +7 -6
- package/runtime/engine/config.mjs +10 -18
- package/runtime/engine/removed-vocabulary.mjs +0 -2
- package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
- package/runtime/knowledge/hfs/rules.yaml +505 -971
- package/runtime/knowledge/hfs/slots.yaml +13 -24
- package/runtime/modules/kernel/failure-codes.yaml +60 -22
- package/runtime/modules/kernel/removed-vocabulary.yaml +30 -0
- package/runtime/modules/models/runtimes.yaml +4 -6
- package/runtime/modules/schemas/application-stacks.schema.yaml +131 -1
- package/runtime/packages/cli/src/catalog.generated.mjs +286 -398
- package/runtime/scripts/api/git/lib.mjs +3 -3
- package/runtime/scripts/api/sops/lib.mjs +1 -1
- package/runtime/scripts/hfs/app-wiring.mjs +2 -2
- package/runtime/scripts/hfs/architecture/config.mjs +1 -1
- package/runtime/scripts/hfs/architecture/index.mjs +1 -1
- package/runtime/scripts/hfs/architecture/supabase.mjs +1 -1
- package/runtime/scripts/hfs/architecture/tiers.mjs +1 -1
- package/runtime/scripts/hfs/architecture.mjs +1 -1
- package/runtime/scripts/hfs/ci-declaration.mjs +102 -0
- package/runtime/scripts/hfs/coverage-from.mjs +1 -1
- package/runtime/scripts/hfs/coverage-scope.mjs +1 -1
- package/runtime/scripts/hfs/example-secrets.mjs +1 -1
- package/runtime/scripts/hfs/manifest-shape.mjs +3 -3
- package/runtime/scripts/hfs/param-names.mjs +1 -1
- package/runtime/scripts/hfs/rule-catalog.mjs +3 -1
- package/runtime/scripts/hfs/rules/a15-findings.mjs +10 -1
- package/runtime/scripts/hfs/rules/app-workflow-findings.mjs +10 -0
- package/runtime/scripts/hfs/rules/assets.mjs +1 -1
- package/runtime/scripts/hfs/rules/ci-custody.mjs +104 -0
- package/runtime/scripts/hfs/rules/cli.mjs +1 -1
- package/runtime/scripts/hfs/rules/contract-compat.mjs +1 -1
- package/runtime/scripts/hfs/rules/contract.mjs +1 -1
- package/runtime/scripts/hfs/rules/deploy-workflow.mjs +6 -19
- package/runtime/scripts/hfs/rules/fe-build-args.mjs +142 -0
- package/runtime/scripts/hfs/rules/frontend-tree.mjs +2 -2
- package/runtime/scripts/hfs/rules/supabase-secrets.mjs +1 -1
- package/runtime/scripts/hfs/rules/supply-chain.mjs +40 -3
- package/runtime/scripts/hfs/rules/workflow-contexts.mjs +103 -0
- package/runtime/scripts/hfs/runtime-rules/workflow-source.mjs +2 -2
- package/runtime/scripts/hfs/slots.mjs +2 -2
- package/runtime/scripts/hfs/typescript-programs.mjs +0 -8
- package/runtime/scripts/lib/env.mjs +2 -2
- package/runtime/scripts/lib/git.mjs +1 -1
- package/runtime/scripts/lib/i18n.mjs +0 -2
- package/runtime/scripts/lib/language.mjs +1 -1
- package/runtime/scripts/lib/roots.mjs +2 -4
- package/runtime/scripts/lib/secret-patterns.mjs +2 -2
- package/scaffold/add-fe-overlay.mjs +0 -121
- package/scaffold/add-fe-page.mjs +0 -268
- package/scaffold/supabase-ports.mjs +1 -1
- package/src/main.mjs +17 -6
- package/stack/age-install.mjs +1 -1
- package/stack/cli.mjs +2 -2
- package/stack/context.mjs +14 -2
- package/stack/digest.mjs +1 -1
- package/stack/docker-transport.mjs +93 -15
- package/stack/edge-alias.mjs +1 -1
- package/stack/image-scan.mjs +1 -1
- package/stack/ingress-create.mjs +2 -2
- package/stack/ingress.mjs +3 -3
- package/stack/release-store.mjs +12 -7
- package/stack/remote.mjs +16 -8
- package/stack/sealed.mjs +5 -5
- package/stack/ssh-session.mjs +178 -0
- package/sync/ci-commands.mjs +103 -1
- package/sync/format.mjs +1 -1
- package/sync/hygiene.mjs +1 -1
- package/sync/index.mjs +43 -18
- package/sync/quality-services.mjs +61 -0
- package/sync/readme-badges.mjs +44 -0
- package/sync/stack-images.mjs +31 -5
- package/templates/app/ci-workflows/github/workflows/ci.yml +26 -24
- package/templates/app/ci-workflows/github/workflows/images.yml +24 -2
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +8 -2
- package/templates/app/ci-workflows/install-steps.yml +21 -0
- package/templates/app/ci-workflows/sonar-steps.yml +3 -9
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +28 -35
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +2 -1
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +24 -2
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +8 -2
- package/templates/app/quality-config/codecov.yml +6 -6
- package/templates/app/quality-config/sonar-project.properties +1 -0
- package/templates/app/readme-badges/README.md +1 -0
- package/templates/app/skeleton/.github/dependabot.yml +1 -14
- package/templates/app/skeleton/.starcistacks/application-stacks.yaml +61 -3
- package/templates/app/skeleton/README.md +6 -0
- package/templates/app/skeleton-lite/.github/dependabot.yml +1 -14
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +57 -3
- package/templates/app/skeleton-lite/README.md +6 -0
- package/templates/app/upgrade-full/.starcistacks/application-stacks.yaml +52 -1
- package/upgrade/index.mjs +1 -1
package/db/reset.mjs
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// reset.mjs - linked reset requires explicit custody choices, a recent verified encrypted backup and the migration arming rule.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { runVerb } from '../release/index.mjs';
|
|
6
|
+
import { requireArmed } from './migrate.mjs';
|
|
7
|
+
import { failure, refusal } from '../stack/errors.mjs';
|
|
8
|
+
import { backupSession, backupArgs, backupRecipients, backupTool, encryptFile, queryRows, validateReceipt, runCli } from './backup-receipt.mjs';
|
|
9
|
+
|
|
10
|
+
/** Metadata fingerprints allow verification without printing or retaining rows, object keys or auth identities in the result. */
|
|
11
|
+
function inventory(session, schema) {
|
|
12
|
+
const tables = queryRows(session, `select tablename from pg_catalog.pg_tables where schemaname = '${schema}' and tablename not in ('schema_migrations', 'migrations') order by tablename;`);
|
|
13
|
+
return tables.map(({ tablename }) => {
|
|
14
|
+
if (typeof tablename !== 'string' || !/^[a-z_][a-z0-9_]*$/u.test(tablename)) throw failure('DB_RESET_VERIFY_FAILED', 'invalid preservation table inventory');
|
|
15
|
+
const rows = queryRows(session, `begin read only; select count(*)::text as count, md5(coalesce(string_agg(md5(row_to_json(t)::text), '' order by md5(row_to_json(t)::text)), '')) as hash from "${schema}"."${tablename}" t; commit;`);
|
|
16
|
+
if (rows.length !== 1 || !/^\d+$/u.test(String(rows[0].count)) || !/^[a-f0-9]{32}$/u.test(rows[0].hash ?? '')) throw failure('DB_RESET_VERIFY_FAILED', 'invalid preservation fingerprint');
|
|
17
|
+
return { table: tablename, count: String(rows[0].count), hash: rows[0].hash };
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** age reads identity bytes on stdin or the configured identity file; neither identity nor SQL is placed in argv or diagnostics. */
|
|
22
|
+
function decryptPart(ctx, encrypted, plaintext) {
|
|
23
|
+
const key = ctx.env.SOPS_AGE_KEY;
|
|
24
|
+
const identity = key ? '-' : ctx.env.SOPS_AGE_KEY_FILE;
|
|
25
|
+
if (!identity) throw refusal('DB_RESET_IDENTITY_MISSING', '--auth/--storage keep needs SOPS_AGE_KEY or SOPS_AGE_KEY_FILE to restore the temporary encrypted part');
|
|
26
|
+
backupTool(ctx, 'age', ['--decrypt', '--identity', identity, '-o', plaintext, encrypted], key ? { input: `${key}\n` } : {});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function preservePart(session, scratch, schema, recipients) {
|
|
30
|
+
const before = inventory(session, schema);
|
|
31
|
+
const plain = path.join(scratch, `${schema}.sql`);
|
|
32
|
+
const encrypted = `${plain}.age`;
|
|
33
|
+
runCli(session, ['db', 'dump', '--data-only', '--schema', schema, '--exclude', `${schema}.schema_migrations,${schema}.migrations`, '--file', plain, ...backupArgs(session)]);
|
|
34
|
+
encryptFile(session.ctx, plain, encrypted, recipients);
|
|
35
|
+
fs.rmSync(plain, { force: true });
|
|
36
|
+
// Prove the selected identity can restore this exact part before the destructive call.
|
|
37
|
+
decryptPart(session.ctx, encrypted, plain);
|
|
38
|
+
fs.rmSync(plain, { force: true });
|
|
39
|
+
return { schema, before, encrypted, plain };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function restorePart(session, part) {
|
|
43
|
+
// Supabase retains some managed tables. Replace their data to avoid duplicate users, buckets or migration-independent metadata.
|
|
44
|
+
decryptPart(session.ctx, part.encrypted, part.plain);
|
|
45
|
+
const tables = part.before.map((row) => `"${part.schema}"."${row.table}"`);
|
|
46
|
+
const source = fs.readFileSync(part.plain, 'utf8');
|
|
47
|
+
fs.writeFileSync(part.plain, `begin;\nset constraints all deferred;\n${tables.length ? `truncate ${tables.join(', ')} cascade;\n` : ''}${source}\ncommit;\n`, { mode: 0o600 });
|
|
48
|
+
try { runCli(session, ['db', 'query', '--file', part.plain, '--output', 'json', '--agent', 'no', ...backupArgs(session)]); }
|
|
49
|
+
finally { fs.rmSync(part.plain, { force: true }); }
|
|
50
|
+
if (JSON.stringify(inventory(session, part.schema)) !== JSON.stringify(part.before)) throw failure('DB_RESET_VERIFY_FAILED', `${part.schema} data differs after restore; use the full backup receipt for recovery`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function expectedHead(session) {
|
|
54
|
+
const dir = path.join(session.ctx.cwd, session.database.migrationsDir ?? 'supabase/migrations');
|
|
55
|
+
let expected;
|
|
56
|
+
try { expected = fs.readdirSync(dir).filter((name) => /^\d{14}_[A-Za-z0-9_-]+\.sql$/u.test(name)).map((name) => name.slice(0, 14)).sort(); }
|
|
57
|
+
catch { throw refusal('DB_MIGRATIONS_MISSING', 'migration directory is not readable'); }
|
|
58
|
+
if (!expected.length || new Set(expected).size !== expected.length) throw refusal('DB_MIGRATIONS_MISSING', 'migration versions must be present and unique');
|
|
59
|
+
return expected;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function verifyHead(session) {
|
|
63
|
+
const expected = expectedHead(session);
|
|
64
|
+
const actual = queryRows(session, 'select version from supabase_migrations.schema_migrations order by version;').map((row) => row.version);
|
|
65
|
+
if (JSON.stringify(expected) !== JSON.stringify(actual)) throw failure('DB_RESET_HEAD_MISMATCH', 'reset migration history differs from repository migration versions');
|
|
66
|
+
return expected;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Dry run validates custody and prints a plan; execute preserves chosen managed data, resets, restores and verifies the exact migration head. */
|
|
70
|
+
function resetDatabase(ctx, options = {}) {
|
|
71
|
+
if (options.linked !== true || options.local || options.urlEnv !== undefined) throw refusal('DB_RESET_LINKED_REQUIRED', 'reset requires --linked; use npm run db:reset for a local database');
|
|
72
|
+
if (!['keep', 'drop'].includes(options.auth) || !['keep', 'drop'].includes(options.storage)) throw refusal('DB_RESET_CHOICE_MISSING', 'explicit --auth keep|drop and --storage keep|drop are required');
|
|
73
|
+
const session = backupSession(ctx, options);
|
|
74
|
+
const receipt = validateReceipt(session, options.backup ? path.resolve(ctx.cwd, options.backup) : undefined);
|
|
75
|
+
const lines = [`target: linked project ${session.target.ref}`, `backup: verified encrypted receipt from ${receipt.at}`,
|
|
76
|
+
options.auth === 'keep' ? 'auth: encrypt all auth data tables before reset, restore in a transaction and verify' : 'auth: users and identities are deleted',
|
|
77
|
+
options.storage === 'keep' ? 'storage: preserve and verify buckets and objects metadata' : 'storage: empty storage.objects metadata; files in the storage backend are not deleted by SQL',
|
|
78
|
+
'reset: supabase db reset --linked; verify migration versions against repository'];
|
|
79
|
+
if (!options.execute) return { ok: true, lines: [...lines, 'dry run: nothing changed'], data: { executed: false, auth: options.auth, storage: options.storage, ref: session.target.ref } };
|
|
80
|
+
requireArmed(ctx);
|
|
81
|
+
// Refuse unsupported CLI file execution before resetting: SQL containing identity data must never travel in argv.
|
|
82
|
+
if (options.auth === 'keep' || options.storage === 'keep') {
|
|
83
|
+
const help = runCli(session, ['db', 'query', '--help']).text;
|
|
84
|
+
if (!help.includes('--file')) throw refusal('DB_RESET_QUERY_FILE_UNSUPPORTED', 'the Supabase CLI must support db query --file for transactional restore');
|
|
85
|
+
}
|
|
86
|
+
expectedHead(session);
|
|
87
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'starci-db-reset-'));
|
|
88
|
+
const parts = [];
|
|
89
|
+
let resetStarted = false;
|
|
90
|
+
let complete = false;
|
|
91
|
+
try {
|
|
92
|
+
const keep = ['auth', 'storage'].filter((schema) => options[schema] === 'keep');
|
|
93
|
+
const recipients = keep.length ? backupRecipients(ctx.cwd) : [];
|
|
94
|
+
for (const schema of keep) parts.push(preservePart(session, scratch, schema, recipients));
|
|
95
|
+
resetStarted = true;
|
|
96
|
+
runCli(session, ['db', 'reset', '--yes', ...backupArgs(session)]);
|
|
97
|
+
for (const part of parts) restorePart(session, part);
|
|
98
|
+
if (options.auth === 'drop') {
|
|
99
|
+
const rows = queryRows(session, 'select (select count(*) from auth.users)::text as users, (select count(*) from auth.identities)::text as identities;');
|
|
100
|
+
if (rows.length !== 1 || String(rows[0].users) !== '0' || String(rows[0].identities) !== '0') throw failure('DB_RESET_VERIFY_FAILED', 'auth users or identities remain after reset');
|
|
101
|
+
}
|
|
102
|
+
if (options.storage === 'drop') {
|
|
103
|
+
runCli(session, ['db', 'query', 'begin; delete from storage.objects; commit;', '--output', 'json', '--agent', 'no', ...backupArgs(session)]);
|
|
104
|
+
const rows = queryRows(session, 'select count(*)::text as count from storage.objects;');
|
|
105
|
+
if (rows.length !== 1 || String(rows[0].count) !== '0') throw failure('DB_RESET_VERIFY_FAILED', 'storage.objects metadata was not emptied');
|
|
106
|
+
}
|
|
107
|
+
const versions = verifyHead(session);
|
|
108
|
+
complete = true;
|
|
109
|
+
return { ok: true, lines: [...lines, 'reset completed; managed data choices and migration head verified'], data: { executed: true, auth: options.auth, storage: options.storage, ref: session.target.ref, versions } };
|
|
110
|
+
} finally {
|
|
111
|
+
// Preserve encrypted recovery parts after a reset/restore failure; remove every plaintext file on every exit.
|
|
112
|
+
for (const name of fs.readdirSync(scratch)) if (!name.endsWith('.age')) fs.rmSync(path.join(scratch, name), { force: true });
|
|
113
|
+
if (complete || !resetStarted) fs.rmSync(scratch, { recursive: true, force: true });
|
|
114
|
+
else ctx.stderr(`DB_RESET_RECOVERY_PARTS: encrypted recovery parts retained at ${scratch}\n`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function dbResetMain(argv, io = {}) {
|
|
119
|
+
return runVerb({ label: 'starci app db reset', name: 'db reset', code: 'DB_USAGE', io, argv,
|
|
120
|
+
spec: { boolean: ['--linked', '--execute', '--json', '--local'], value: ['--auth', '--storage', '--backup', '--url-env'] },
|
|
121
|
+
run: (ctx, flags, positional) => {
|
|
122
|
+
if (positional.length) throw refusal('DB_USAGE', 'reset takes flags only');
|
|
123
|
+
return resetDatabase(ctx, { linked: flags.linked, local: flags.local, urlEnv: flags['url-env'], execute: flags.execute, auth: flags.auth, storage: flags.storage, backup: flags.backup });
|
|
124
|
+
} });
|
|
125
|
+
}
|
package/emit/contracts.mjs
CHANGED
|
@@ -41,7 +41,7 @@ const OPERATIONS_WORKER = path.join(path.dirname(fileURLToPath(import.meta.url))
|
|
|
41
41
|
export const appModulePath = (app) => `apps/${app}/src/app.module.ts`;
|
|
42
42
|
|
|
43
43
|
/** The repository-relative path of an app's snapshot. */
|
|
44
|
-
|
|
44
|
+
const snapshotPath = (app) => `contracts/${app}/schema.graphql`;
|
|
45
45
|
|
|
46
46
|
/** The `paths` aliases of a tsconfig as [{ prefix, targets }], `@modules/*` giving prefix `@modules/`. Pure. */
|
|
47
47
|
export function aliasesOf(paths, baseDir) {
|
|
@@ -57,7 +57,7 @@ export function aliasTarget(aliases, specifier) {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
/** The api apps of a declaration that can serve GraphQL: kind `api`. Pure. */
|
|
60
|
-
|
|
60
|
+
const apiApps = (declaration) => (declaration.apps ?? []).filter((app) => app.kind === 'api').map((app) => app.name);
|
|
61
61
|
|
|
62
62
|
/** The text written to a snapshot file: the printed schema and one final newline. Pure. */
|
|
63
63
|
export const snapshotText = (printed) => `${printed.replace(/\n+$/, '')}\n`;
|
package/emit/db-types.mjs
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* schemas `supabase/config.toml` declares under [api]; the CLI reaches Docker, so nothing here runs inside a check that has
|
|
9
9
|
* no stack. `run` is the command runner - `(file, args, {cwd})` answering the spawnSync result `{status, stdout, stderr}` -
|
|
10
10
|
* the default spawns the `supabase` binary with an argument array, never a shell string and never printing env; a spec's fake
|
|
11
|
-
* keeps Docker out of the suite.
|
|
11
|
+
* keeps Docker out of the suite. Before generation, `migration list --local` must prove that the database's applied versions
|
|
12
|
+
* equal the migration filename stamps (HFS_EMIT_DB_TYPES_STALE otherwise). Unreadable history or failed generation throws HFS_EMIT_DB_TYPES_FAILED.
|
|
12
13
|
*/
|
|
13
14
|
import { createHash } from 'node:crypto';
|
|
14
15
|
import { spawnSync } from 'node:child_process';
|
|
@@ -22,13 +23,13 @@ export const dbTypesPath = 'supabase/types/database.types.ts';
|
|
|
22
23
|
|
|
23
24
|
/** The folder of the app's migrations, and the first line of the committed types: the digest of the migrations they were generated from. */
|
|
24
25
|
const MIGRATIONS_DIR = 'supabase/migrations';
|
|
25
|
-
|
|
26
|
+
const TYPES_STAMP = '// migrations-sha256: ';
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* The sha256 of the app's migrations, hex: every `supabase/migrations/*.sql` by name, each as name, NUL, its text with LF line ends,
|
|
29
30
|
* NUL. The offline codegen hook of a lite app (templates/app/codegen-lite) computes the same digest and refuses types stamped with another.
|
|
30
31
|
*/
|
|
31
|
-
|
|
32
|
+
function migrationsDigest(root) {
|
|
32
33
|
const dir = path.join(root, MIGRATIONS_DIR);
|
|
33
34
|
const hash = createHash('sha256');
|
|
34
35
|
for (const name of (fs.existsSync(dir) ? fs.readdirSync(dir) : []).filter((file) => file.endsWith('.sql')).sort()) {
|
|
@@ -44,12 +45,12 @@ const DB_TYPES_SLOT = 'app.supabase.types';
|
|
|
44
45
|
const CONFIG_FILE = 'supabase/config.toml';
|
|
45
46
|
const SUPABASE_CLI = 'supabase';
|
|
46
47
|
|
|
47
|
-
/** A types emit
|
|
48
|
+
/** A types emit refuses unreadable history or failed generation with HFS_EMIT_DB_TYPES_FAILED, and unequal applied versions with HFS_EMIT_DB_TYPES_STALE. */
|
|
48
49
|
class DbTypesError extends Error {
|
|
49
|
-
constructor(message, details = {}) {
|
|
50
|
-
super(
|
|
50
|
+
constructor(message, details = {}, code = 'HFS_EMIT_DB_TYPES_FAILED') {
|
|
51
|
+
super(`${code}: ${message}`);
|
|
51
52
|
this.name = 'DbTypesError';
|
|
52
|
-
this.code =
|
|
53
|
+
this.code = code;
|
|
53
54
|
this.details = details;
|
|
54
55
|
}
|
|
55
56
|
}
|
|
@@ -97,6 +98,97 @@ function apiSchemas(root) {
|
|
|
97
98
|
return (toml?.api?.schemas ?? []).filter((schema) => typeof schema === 'string');
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
/** CLI JSON and table rows use the remote field for the database history even with --local; local only describes files. */
|
|
102
|
+
function historyVersion(value) {
|
|
103
|
+
if (value == null || value === '') return null;
|
|
104
|
+
if (typeof value !== 'string' || !/^\d+$/.test(value)) {
|
|
105
|
+
throw new DbTypesError('the local migration history contains an unreadable row', { file: dbTypesPath });
|
|
106
|
+
}
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** JSON must explicitly carry a migrations array; an empty array proves an empty database history. */
|
|
111
|
+
function jsonAppliedVersions(text) {
|
|
112
|
+
let history;
|
|
113
|
+
try { history = JSON.parse(text); } catch {
|
|
114
|
+
throw new DbTypesError('the local migration history contains unreadable JSON', { file: dbTypesPath });
|
|
115
|
+
}
|
|
116
|
+
if (!Array.isArray(history?.migrations)) {
|
|
117
|
+
throw new DbTypesError('the local migration history has no migrations array', { file: dbTypesPath });
|
|
118
|
+
}
|
|
119
|
+
const versions = new Set();
|
|
120
|
+
for (const row of history.migrations) {
|
|
121
|
+
if (!row || !Object.hasOwn(row, 'local') || !Object.hasOwn(row, 'remote')) {
|
|
122
|
+
throw new DbTypesError('the local migration history contains an unreadable row', { file: dbTypesPath });
|
|
123
|
+
}
|
|
124
|
+
historyVersion(row.local);
|
|
125
|
+
const applied = historyVersion(row.remote);
|
|
126
|
+
if (applied) versions.add(applied);
|
|
127
|
+
}
|
|
128
|
+
return versions;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Optional outer borders do not add columns; pretty output may wrap each table cell in Markdown backticks. */
|
|
132
|
+
const historyCells = (line, bordered) => {
|
|
133
|
+
const row = bordered ? line.trim().replace(/^\|/, '').replace(/\|$/, '') : line;
|
|
134
|
+
return row.split('|').map((cell) => cell.trim().replace(/^`(.*)`$/, '$1'));
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/** A recognized header, including a header without rows, proves a table history; malformed rows still refuse. */
|
|
138
|
+
function tableAppliedVersions(lines, header) {
|
|
139
|
+
const versions = new Set();
|
|
140
|
+
const bordered = /^\s*\|/.test(lines[header]);
|
|
141
|
+
const width = historyCells(lines[header], bordered).length;
|
|
142
|
+
for (const line of lines.slice(header + 1)) {
|
|
143
|
+
if (/^[\s|+-]+$/.test(line)) continue;
|
|
144
|
+
if (!line.includes('|')) continue;
|
|
145
|
+
const cells = historyCells(line, bordered);
|
|
146
|
+
if (cells.length !== width) {
|
|
147
|
+
throw new DbTypesError('the local migration history contains an unreadable row', { file: dbTypesPath });
|
|
148
|
+
}
|
|
149
|
+
historyVersion(cells[0]);
|
|
150
|
+
const applied = historyVersion(cells[1]);
|
|
151
|
+
if (applied) versions.add(applied);
|
|
152
|
+
}
|
|
153
|
+
return versions;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Read either CLI stream, stripping terminal colors, and require an explicit JSON history or Local | Remote [| Time] header. */
|
|
157
|
+
function appliedVersions(result) {
|
|
158
|
+
for (const output of [result.stdout, result.stderr]) {
|
|
159
|
+
const text = String(output ?? '').replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replaceAll('\u2502', '|').trim();
|
|
160
|
+
const lines = text.split(/\r?\n/);
|
|
161
|
+
const json = lines.findIndex((line) => line.trimStart().startsWith('{'));
|
|
162
|
+
if (json >= 0) return jsonAppliedVersions(lines.slice(json).join('\n'));
|
|
163
|
+
const header = lines.findIndex((line) => /^\s*\|?\s*Local\s*\|\s*Remote\s*(?:\|\s*Time(?:\s*\(UTC\))?\s*)?\|?\s*$/i.test(line));
|
|
164
|
+
if (header >= 0) return tableAppliedVersions(lines, header);
|
|
165
|
+
}
|
|
166
|
+
throw new DbTypesError('the local migration history is unreadable; expected migrations JSON or the Local | Remote migration list', { file: dbTypesPath });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Every applied local version must equal a migration filename stamp, including gaps and extra versions even when the heads match. */
|
|
170
|
+
function requireCurrentDatabase(root, run) {
|
|
171
|
+
const dir = path.join(root, MIGRATIONS_DIR);
|
|
172
|
+
const expected = new Set();
|
|
173
|
+
for (const name of (fs.existsSync(dir) ? fs.readdirSync(dir) : []).filter((file) => file.endsWith('.sql'))) {
|
|
174
|
+
const stamp = /^(\d+)(?:_|\.sql$)/.exec(name)?.[1];
|
|
175
|
+
if (!stamp) throw new DbTypesError(`${MIGRATIONS_DIR} contains a SQL file without a migration version`, { file: dbTypesPath });
|
|
176
|
+
expected.add(stamp);
|
|
177
|
+
}
|
|
178
|
+
let result;
|
|
179
|
+
try { result = run(SUPABASE_CLI, ['migration', 'list', '--local'], { cwd: root }); } catch {
|
|
180
|
+
throw new DbTypesError('supabase migration list --local could not run; the local migration history must be readable before emitting types', { file: dbTypesPath });
|
|
181
|
+
}
|
|
182
|
+
if (result?.error || result?.signal || result?.status !== 0) {
|
|
183
|
+
throw new DbTypesError('supabase migration list --local failed; the local migration history must be readable before emitting types', { file: dbTypesPath });
|
|
184
|
+
}
|
|
185
|
+
const applied = appliedVersions(result);
|
|
186
|
+
if (applied.size !== expected.size || [...expected].some((version) => !applied.has(version))) {
|
|
187
|
+
const head = (versions) => [...versions].sort().at(-1) ?? 'none';
|
|
188
|
+
throw new DbTypesError(`the local database is at ${head(applied)}, the migrations folder at ${head(expected)}; run "npm run db:reset" then emit again`, { file: dbTypesPath }, 'HFS_EMIT_DB_TYPES_STALE');
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
100
192
|
/**
|
|
101
193
|
* The freshly generated text of `dbTypesPath` for the app at `root`: `supabase gen types typescript --local` (the running
|
|
102
194
|
* local stack), narrowed to the [api] schemas of supabase/config.toml. A failing CLI, a spawn error and an empty answer all
|
|
@@ -104,6 +196,7 @@ function apiSchemas(root) {
|
|
|
104
196
|
*/
|
|
105
197
|
function emitDbTypesWithRunner({ root, run }) {
|
|
106
198
|
const schemas = apiSchemas(root);
|
|
199
|
+
requireCurrentDatabase(root, run);
|
|
107
200
|
const args = ['gen', 'types', 'typescript', '--local', ...(schemas.length ? ['--schema', schemas.join(',')] : [])];
|
|
108
201
|
const command = `${SUPABASE_CLI} ${args.join(' ')}`;
|
|
109
202
|
let result;
|
package/lint/run.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// starci app lint - the ONE lint entry and the ONE report of a StarCi app.
|
|
2
2
|
//
|
|
3
|
-
// starci app lint [--cwd <dir>] [--changed <file>...] [--workspace <dir>] [--fix] [--format text|json] [--sonar <file>]
|
|
3
|
+
// starci app lint [--cwd <dir>] [--changed <file>...] [--workspace <dir>] [--side be|fe] [--fix] [--format text|json] [--sonar <file>]
|
|
4
4
|
//
|
|
5
5
|
// It runs at the app root (the folder of hfs.json), over the app (or over the app-relative files named by --changed):
|
|
6
6
|
// 1. ESLint once per side, from the side folder, through the app's own install and that side's eslint.config.mjs (be/ the be canon,
|
|
@@ -26,6 +26,7 @@ import { STYLE_GLOB } from '../sync/index.mjs';
|
|
|
26
26
|
import { byCodeUnit } from '../report/order.mjs';
|
|
27
27
|
import { braceVariants, globExpression } from '../runtime/scripts/lib/glob.mjs';
|
|
28
28
|
|
|
29
|
+
// --side judges only that side; root HFS findings belong to be, and Sonar always requires the full lint.
|
|
29
30
|
export const LINT_SCHEMA = 'starci/lint@1';
|
|
30
31
|
/** The side whose stylesheets stylelint judges. */
|
|
31
32
|
const STYLE_SIDE = 'fe';
|
|
@@ -41,6 +42,22 @@ export function workspaceOf(workspace) {
|
|
|
41
42
|
return rel;
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
/** Side scopes partition the full lint; Sonar requires the complete report and a frontend workspace cannot belong to be. */
|
|
46
|
+
function validateSide(opts) {
|
|
47
|
+
if (opts.side === undefined) return;
|
|
48
|
+
if (!SIDES.includes(opts.side)) throw new Error('LINT_SIDE_INVALID: --side is be or fe');
|
|
49
|
+
if (opts.sonar !== undefined) throw new Error('LINT_SIDE_SONAR_CONFLICT: --side and --sonar do not combine');
|
|
50
|
+
if (opts.side === 'be' && opts.workspace !== undefined) throw new Error('LINT_SIDE_WORKSPACE_CONFLICT: --side be cannot judge a frontend workspace');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Side findings stay with their side; findings outside both sides belong to the backend lint. */
|
|
54
|
+
function findingOnSide(finding, side) {
|
|
55
|
+
if (side === undefined) return true;
|
|
56
|
+
const file = posix(finding.path ?? '');
|
|
57
|
+
const owner = SIDES.find((candidate) => file === candidate || file.startsWith(`${candidate}/`));
|
|
58
|
+
return (owner ?? 'be') === side;
|
|
59
|
+
}
|
|
60
|
+
|
|
44
61
|
/** The flags of `starci app lint`; `--changed` takes every argument up to the next flag, and a repeated `--changed` adds to the list (the
|
|
45
62
|
* catalog's list flag, `--changed a --changed b`, as the CLI dispatcher passes it through). */
|
|
46
63
|
export function parseLintArgs(argv) {
|
|
@@ -52,19 +69,21 @@ export function parseLintArgs(argv) {
|
|
|
52
69
|
while (i + 1 < argv.length && !argv[i + 1].startsWith('--')) opts.changed.push(posix(argv[++i]));
|
|
53
70
|
} else if (arg === '--fix') opts.fix = true;
|
|
54
71
|
else if (arg === '--json') opts.format = 'json';
|
|
55
|
-
else if (['--format', '--sonar', '--workspace'].includes(arg)) {
|
|
72
|
+
else if (['--format', '--sonar', '--workspace', '--side'].includes(arg)) {
|
|
73
|
+
if (arg === '--side' && (argv[i + 1] === undefined || argv[i + 1].startsWith('--'))) throw new Error('LINT_SIDE_INVALID: --side needs be or fe');
|
|
56
74
|
if (argv[i + 1] === undefined) throw new Error(`${arg} needs a value`);
|
|
57
75
|
opts[arg.slice(2)] = argv[++i];
|
|
58
76
|
} else throw new Error(`unknown argument ${arg}`);
|
|
59
77
|
}
|
|
60
78
|
if (!['text', 'json'].includes(opts.format)) throw new Error('--format is text or json');
|
|
61
79
|
if (opts.workspace !== undefined && opts.changed !== null) throw new Error('--workspace and --changed do not combine: a workspace lint judges the whole workspace');
|
|
80
|
+
validateSide(opts);
|
|
62
81
|
return opts;
|
|
63
82
|
}
|
|
64
83
|
|
|
65
84
|
/** The typed ESLint run reads nothing above the app root: its TypeScript `sys` is bound by this preload (3.3, bound-sys.cjs). */
|
|
66
|
-
|
|
67
|
-
|
|
85
|
+
const BOUND_ENV = 'STARCI_LINT_BOUND';
|
|
86
|
+
const linterBoundArgs = () => ['--require', fileURLToPath(new URL('./bound-sys.cjs', import.meta.url))];
|
|
68
87
|
|
|
69
88
|
/** The child `node` ran with `args` from `cwd`: its exit status, signal, spawn error and the text of both streams, once it closes. */
|
|
70
89
|
function runNode(args, options) {
|
|
@@ -182,7 +201,7 @@ function checkedEslintResults({ results, expected, cwd, scope, changed }) {
|
|
|
182
201
|
/** Starts the ESLint child of every side that has work and returns one record per side: skipped, a scope error, or the running child. */
|
|
183
202
|
async function launchEslint({ app, repoRoot, opts, changed, existing, workspace, onFe }) {
|
|
184
203
|
const launches = [];
|
|
185
|
-
for (const side of app ? SIDES : []) {
|
|
204
|
+
for (const side of app ? SIDES.filter((candidate) => opts.side === undefined || opts.side === candidate) : []) {
|
|
186
205
|
if (workspace !== null && side !== STYLE_SIDE) { launches.push({ side, skipped: `outside --workspace ${workspace}` }); continue; }
|
|
187
206
|
const sources = onSide(existing, side).filter((file) => /\.(?:[cm]?[jt]sx?)$/.test(file));
|
|
188
207
|
if (changed !== null && !sources.length) { launches.push({ side, skipped: 'no changed source file' }); continue; }
|
|
@@ -197,6 +216,7 @@ async function launchEslint({ app, repoRoot, opts, changed, existing, workspace,
|
|
|
197
216
|
|
|
198
217
|
/** Starts stylelint over the fe stylesheets (the running child), or returns null when no stylesheet is in scope. */
|
|
199
218
|
function launchStylelint({ repoRoot, opts, changed, existing, onFe }) {
|
|
219
|
+
if (opts.side === 'be') return null;
|
|
200
220
|
const styles = onSide(existing, STYLE_SIDE).filter((file) => file.endsWith('.css'));
|
|
201
221
|
if (changed !== null && !styles.length) return null;
|
|
202
222
|
return runLinter({ cwd: path.join(repoRoot, STYLE_SIDE), pkg: 'stylelint', bin: 'stylelint', args: [...(changed ? styles : [onFe ? `${onFe}/src/**/*.css` : STYLE_GLOB]), '--formatter', 'json', ...(opts.fix ? ['--fix'] : [])] });
|
|
@@ -210,6 +230,8 @@ export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles =
|
|
|
210
230
|
const changed = opts.changed === null ? null : new Set(opts.changed);
|
|
211
231
|
const errors = [];
|
|
212
232
|
let workspace = null;
|
|
233
|
+
let validSide = true;
|
|
234
|
+
try { validateSide(opts); } catch (error) { errors.push(String(error?.message ?? error)); validSide = false; }
|
|
213
235
|
if (opts.workspace !== undefined) {
|
|
214
236
|
try { workspace = workspaceOf(opts.workspace); } catch (error) { errors.push(String(error?.message ?? error)); }
|
|
215
237
|
}
|
|
@@ -223,7 +245,7 @@ export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles =
|
|
|
223
245
|
let app = null;
|
|
224
246
|
try { app = readRepoDeclaration(loadSlotManifest(), repoRoot); } catch (error) { errors.push(`starci app lint runs at the app root: ${String(error?.message ?? error)}`); }
|
|
225
247
|
if (app && !app.sides) { errors.push(`${repoRoot} is the ${app.side} side of an app; starci app lint runs at the app root, the folder of hfs.json`); app = null; }
|
|
226
|
-
if (opts.workspace !== undefined && workspace === null) app = null;
|
|
248
|
+
if (!validSide || (opts.workspace !== undefined && workspace === null)) app = null;
|
|
227
249
|
if (workspace !== null && !fs.existsSync(path.join(repoRoot, workspace, 'package.json'))) { errors.push(`--workspace ${workspace} holds no package.json; it is not a workspace of this app`); app = null; }
|
|
228
250
|
/** The workspace folder relative to the fe side folder (apps/<app> or packages/<pkg>). */
|
|
229
251
|
const onFe = workspace === null ? null : workspace.slice(STYLE_SIDE.length + 1);
|
|
@@ -277,7 +299,7 @@ export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles =
|
|
|
277
299
|
if (settled.error) errors.push(`starci app check could not run: ${String(settled.error?.message ?? settled.error)}`);
|
|
278
300
|
else checked = settled.value;
|
|
279
301
|
}
|
|
280
|
-
const repoErrors = checked.findings.filter((finding) => finding.level === 'error');
|
|
302
|
+
const repoErrors = checked.findings.filter((finding) => finding.level === 'error' && findingOnSide(finding, opts.side));
|
|
281
303
|
// A workspace lint keeps only the app findings inside the workspace; the rest belong to the root lint of the whole app.
|
|
282
304
|
const kept = workspace !== null
|
|
283
305
|
? repoErrors.filter((finding) => finding.path && inWorkspace(finding.path))
|
|
@@ -297,7 +319,7 @@ export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles =
|
|
|
297
319
|
linterReport('eslint', raw.eslint, { root: repoRoot, sourceRoots, tracked }),
|
|
298
320
|
linterReport('stylelint', raw.stylelint, { root: repoRoot, sourceRoots, tracked }),
|
|
299
321
|
]);
|
|
300
|
-
const report = { schema: LINT_SCHEMA, ok: findings.length === 0 && errors.length === 0, repoRoot, changed: changed ? [...changed].sort(byCodeUnit) : null, workspace, counts: { error: findings.length }, engines, errors, findings };
|
|
322
|
+
const report = { schema: LINT_SCHEMA, ok: findings.length === 0 && errors.length === 0, repoRoot, changed: changed ? [...changed].sort(byCodeUnit) : null, workspace, ...(opts.side === undefined ? {} : { side: opts.side }), counts: { error: findings.length }, engines, errors, findings };
|
|
301
323
|
return { report, sonar, exit: errors.length ? 2 : findings.length ? 1 : 0 };
|
|
302
324
|
}
|
|
303
325
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@starci/hfs",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.5.0",
|
|
4
4
|
"description": "The StarCi app command implementation for scaffolding, linting, checking and synchronizing one product repository.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"stack/**",
|
|
22
22
|
"release/**",
|
|
23
23
|
"db/**",
|
|
24
|
+
"ci/**",
|
|
24
25
|
"lint/**",
|
|
25
26
|
"scaffold/**",
|
|
26
27
|
"report/**",
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// ci-red.mjs - the latest completed default-branch CI is a release precondition; unreadable GitHub warns, a red run needs the owner's SHA-bound override.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { refusal } from '../stack/errors.mjs';
|
|
5
|
+
|
|
6
|
+
const FIELDS = 'databaseId,headSha,status,conclusion,url,event,createdAt';
|
|
7
|
+
const SHA = /^[a-f0-9]{40}$/u;
|
|
8
|
+
const RED = new Set(['failure', 'cancelled', 'timed_out', 'startup_failure', 'action_required']);
|
|
9
|
+
|
|
10
|
+
/** Every process uses the supplied seam; an unavailable executable is an unreadable result. */
|
|
11
|
+
function run(spawn, cwd, command, args) {
|
|
12
|
+
try { return spawn(command, args, { cwd }); } catch { return { status: null }; }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function runsOf(result) {
|
|
16
|
+
if (result.error || result.status !== 0) return null;
|
|
17
|
+
try {
|
|
18
|
+
const rows = JSON.parse(result.stdout);
|
|
19
|
+
return Array.isArray(rows) ? rows : null;
|
|
20
|
+
} catch { return null; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Reads branch runs plus push/tag runs whose commits are reachable from the default branch; the newest completed run decides, even if a newer run is pending. */
|
|
24
|
+
export function latestDefaultBranchCi({ spawn, cwd, defaultBranch = 'main' }) {
|
|
25
|
+
const args = ['run', 'list', '--workflow', 'ci.yml'];
|
|
26
|
+
const tail = ['--limit', '20', '--json', FIELDS];
|
|
27
|
+
const branch = runsOf(run(spawn, cwd, 'gh', [...args, '--branch', defaultBranch, ...tail]));
|
|
28
|
+
if (!branch) return { state: 'unknown', why: 'gh branch runs unavailable (missing gh, authentication, or unreadable response)' };
|
|
29
|
+
const pushes = runsOf(run(spawn, cwd, 'gh', [...args, '--event', 'push', ...tail]));
|
|
30
|
+
if (!pushes) return { state: 'unknown', why: 'gh push runs unavailable (missing gh, authentication, or unreadable response)' };
|
|
31
|
+
const candidates = branch.filter((row) => row && SHA.test(row.headSha));
|
|
32
|
+
let ancestryUnknown = false;
|
|
33
|
+
for (const row of pushes) {
|
|
34
|
+
if (!row || !SHA.test(row.headSha) || row.event !== 'push') continue;
|
|
35
|
+
const ancestor = run(spawn, cwd, 'git', ['merge-base', '--is-ancestor', row.headSha, defaultBranch]);
|
|
36
|
+
if (!ancestor.error && ancestor.status === 0) candidates.push(row);
|
|
37
|
+
else if (ancestor.error || ancestor.status !== 1) ancestryUnknown = true;
|
|
38
|
+
}
|
|
39
|
+
const completed = candidates.filter((row) => row.status === 'completed' && Number.isFinite(Date.parse(row.createdAt)));
|
|
40
|
+
completed.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt) || Number(b.databaseId) - Number(a.databaseId));
|
|
41
|
+
const latest = completed[0];
|
|
42
|
+
if (!latest) return { state: 'unknown', why: ancestryUnknown ? 'cannot establish push run ancestry on the default branch' : 'no completed CI run on the default branch' };
|
|
43
|
+
if (latest.conclusion === 'success') return { state: 'green', run: latest };
|
|
44
|
+
if (RED.has(latest.conclusion)) return { state: 'red', run: latest };
|
|
45
|
+
return { state: 'unknown', why: 'latest completed CI run has no success or red conclusion', run: latest };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Only a complete starci/ci-override@1 record for this exact commit can excuse a red run. */
|
|
49
|
+
function overrideOf(ctx, sha) {
|
|
50
|
+
const common = run(ctx.spawn, ctx.cwd, 'git', ['rev-parse', '--git-common-dir']);
|
|
51
|
+
if (common.error || common.status !== 0 || !String(common.stdout ?? '').trim()) return null;
|
|
52
|
+
const file = path.join(path.resolve(ctx.cwd, String(common.stdout).trim()), 'starci-release', `${sha}.ci-override.json`);
|
|
53
|
+
try {
|
|
54
|
+
const record = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
55
|
+
if (record.schema !== 'starci/ci-override@1' || record.sha !== sha || typeof record.reason !== 'string' || !record.reason.trim() || typeof record.by !== 'string' || !record.by.trim() || typeof record.at !== 'string' || !Number.isFinite(Date.parse(record.at))) return null;
|
|
56
|
+
return record;
|
|
57
|
+
} catch { return null; }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Warns on unknown CI and refuses red CI unless the owner left a valid override beside the release records. */
|
|
61
|
+
export function requireGreenCi(ctx, ci) {
|
|
62
|
+
if (ci.state === 'unknown') {
|
|
63
|
+
ctx.stderr(`RELEASE_CI_UNKNOWN: ${ci.why}\n`);
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
if (ci.state !== 'red') return [];
|
|
67
|
+
const record = overrideOf(ctx, ci.run.headSha);
|
|
68
|
+
if (record) return [`RELEASE_CI_RED overridden by the owner: ${record.reason}`];
|
|
69
|
+
throw refusal('RELEASE_CI_RED', `CI run ${ci.run.url} for ${ci.run.headSha} concluded ${ci.run.conclusion}; the owner must use starci release ci-override --sha ${ci.run.headSha} --reason <text>`);
|
|
70
|
+
}
|
package/release/gate.mjs
CHANGED
|
@@ -7,8 +7,10 @@ import { createHash } from 'node:crypto';
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { isPlainObject as plain } from '../runtime/engine/plain-object.mjs';
|
|
10
|
+
import { qualityServices } from '../runtime/scripts/hfs/ci-declaration.mjs';
|
|
10
11
|
import { firstLine, lines, tool } from '../stack/context.mjs';
|
|
11
12
|
import { failure, refusal } from '../stack/errors.mjs';
|
|
13
|
+
import { latestDefaultBranchCi, requireGreenCi } from './ci-red.mjs';
|
|
12
14
|
|
|
13
15
|
export const RELEASE_CODE = 'RELEASE_CONFIG_INVALID';
|
|
14
16
|
|
|
@@ -35,10 +37,40 @@ const scriptGate = (script) => ({
|
|
|
35
37
|
env: {},
|
|
36
38
|
});
|
|
37
39
|
|
|
38
|
-
/**
|
|
39
|
-
|
|
40
|
+
/** Default gates run app-check, the enabled audit, then every managed script present in the app. */
|
|
41
|
+
function defaultGates(scripts, audit) {
|
|
40
42
|
const present = SCRIPT_GATES.filter((script) => typeof scripts?.[script] === 'string');
|
|
41
|
-
return [{ name: 'app-check', command: ['npx', 'starci', 'app', 'check'], summary: null, env: {} }, ...present.map(scriptGate)];
|
|
43
|
+
return [{ name: 'app-check', command: ['npx', 'starci', 'app', 'check'], summary: null, env: {} }, ...(audit ? [audit] : []), ...present.map(scriptGate)];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Shared quality settings retain the release reader's audit validation, disabled mode and legacy string host compatibility. */
|
|
47
|
+
function releaseServices(services, options) {
|
|
48
|
+
const audit = services?.audit ?? {};
|
|
49
|
+
if (!plain(audit) || (audit.in !== undefined && (!Array.isArray(audit.in) || !audit.in.every((scope) => typeof scope === 'string'))) || (audit.omitDev !== undefined && typeof audit.omitDev !== 'boolean')) throw refusal(RELEASE_CODE, 'services.audit must be a mapping with a string-list in and boolean omitDev');
|
|
50
|
+
const sonar = services?.sonar;
|
|
51
|
+
const compatible = typeof sonar?.host === 'string' ? { ...sonar, host: { public: sonar.host } } : sonar;
|
|
52
|
+
const quality = qualityServices({ services: { ...services, sonar: compatible } }, options);
|
|
53
|
+
if (audit.mode === 'disabled') quality.audit = null;
|
|
54
|
+
return quality;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The shared audit settings preserve release scope, level and development-dependency selection. */
|
|
58
|
+
function auditGate(audit) {
|
|
59
|
+
if (!audit || !audit.in.includes('release-gate')) return null;
|
|
60
|
+
if (!['info', 'low', 'moderate', 'high', 'critical'].includes(audit.level)) throw refusal(RELEASE_CODE, 'services.audit.level must be an npm audit level');
|
|
61
|
+
return { name: 'audit', command: ['npm', 'audit', ...(audit.omitDev ? ['--omit=dev'] : []), `--audit-level=${audit.level}`], summary: null, env: {} };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A requested local Sonar scan uses only the declared host and token variable; the token never enters argv. */
|
|
65
|
+
function sonarGate(services, env) {
|
|
66
|
+
const sonar = services?.sonar;
|
|
67
|
+
const tokenName = sonar?.token;
|
|
68
|
+
if (!sonar?.host || !ENV_KEY.test(tokenName) || !env[tokenName]) throw refusal('RELEASE_SONAR_UNAVAILABLE', 'declare an enabled services.sonar host and set its ci.token environment variable');
|
|
69
|
+
const command = ['npx', '--yes', '@sonar/scan@4.3.0', `-Dsonar.host.url=${sonar.host}`];
|
|
70
|
+
const organization = sonar.organization ?? (sonar.organizationVar ? env[sonar.organizationVar] : null);
|
|
71
|
+
if (organization) command.push(`-Dsonar.organization=${organization}`);
|
|
72
|
+
if (!command.every((word) => TOKEN.test(word))) throw refusal('RELEASE_SONAR_UNAVAILABLE', 'Sonar host and organization must be plain command arguments');
|
|
73
|
+
return { name: 'sonar', command, summary: null, env: { SONAR_TOKEN: env[tokenName] } };
|
|
42
74
|
}
|
|
43
75
|
|
|
44
76
|
function gateProblems(entry, at, seen) {
|
|
@@ -74,7 +106,7 @@ function gatesOf(list, problems) {
|
|
|
74
106
|
}
|
|
75
107
|
|
|
76
108
|
/** Judges the optional `release:` block. Absence is fine (defaults). Returns { config } or { problems }. */
|
|
77
|
-
export function normalizeRelease(block, scripts = {}) {
|
|
109
|
+
export function normalizeRelease(block, scripts = {}, services = {}, options = {}) {
|
|
78
110
|
const raw = block ?? {};
|
|
79
111
|
if (!plain(raw)) return { problems: ['release: must be a mapping'] };
|
|
80
112
|
const problems = Object.keys(raw).filter((key) => !FIELDS.has(key)).map((key) => `release.${key}: is not a field of the release block`);
|
|
@@ -84,8 +116,11 @@ export function normalizeRelease(block, scripts = {}) {
|
|
|
84
116
|
if (typeof tagPrefix !== 'string' || !PREFIX.test(tagPrefix)) problems.push(`release.tagPrefix: must match ${PREFIX}`);
|
|
85
117
|
if (!refName(defaultBranch)) problems.push(`release.defaultBranch: must match ${REF_NAME}`);
|
|
86
118
|
if (typeof tagMatchesVersion !== 'boolean') problems.push('release.tagMatchesVersion: must be true or false');
|
|
87
|
-
const
|
|
88
|
-
|
|
119
|
+
const quality = releaseServices(services, options);
|
|
120
|
+
const audit = auditGate(quality.audit);
|
|
121
|
+
const gates = raw.gates === undefined ? defaultGates(scripts, audit) : gatesOf(raw.gates, problems);
|
|
122
|
+
if (raw.gates !== undefined && audit && !gates.some((gate) => gate.name === 'audit')) gates.push(audit);
|
|
123
|
+
return problems.length === 0 ? { config: { gates, tagPrefix, defaultBranch, tagMatchesVersion, services: quality } } : { problems };
|
|
89
124
|
}
|
|
90
125
|
|
|
91
126
|
/* ---- git through the seam */
|
|
@@ -128,7 +163,7 @@ function nextFree(prefix, version, taken) {
|
|
|
128
163
|
}
|
|
129
164
|
|
|
130
165
|
/** The tag this release uses and its state: on-head (reused), free (will be created) or taken (exists on another commit). */
|
|
131
|
-
|
|
166
|
+
function chooseTag(ctx, config, { prefix, remote }) {
|
|
132
167
|
const onHead = annotatedOnHead(ctx, prefix);
|
|
133
168
|
const taken = takenTags(ctx, prefix, remote);
|
|
134
169
|
const exact = `${prefix}${config.version}`;
|
|
@@ -147,7 +182,7 @@ const lineAfter = (text, label) => {
|
|
|
147
182
|
};
|
|
148
183
|
|
|
149
184
|
/** The jest summary lines of a log: { suites, failedSuites, tests, failed, passed } or null when jest printed none. */
|
|
150
|
-
|
|
185
|
+
function jestSummary(text) {
|
|
151
186
|
const suites = lineAfter(text, 'Test Suites:');
|
|
152
187
|
const tests = lineAfter(text, 'Tests:');
|
|
153
188
|
if (suites === null || tests === null) return null;
|
|
@@ -159,7 +194,7 @@ export function jestSummary(text) {
|
|
|
159
194
|
}
|
|
160
195
|
|
|
161
196
|
/** Judges a jest-style gate from its log: green needs suites that all passed, every test passed and no coverage threshold miss. */
|
|
162
|
-
|
|
197
|
+
function judgeJest(text) {
|
|
163
198
|
const summary = jestSummary(text);
|
|
164
199
|
const misses = (text.match(THRESHOLD_MISS) ?? []).length;
|
|
165
200
|
if (!summary) return { pass: false, detail: 'no jest summary in the log', summary: null, misses };
|
|
@@ -179,9 +214,11 @@ function runGate(ctx, gate, { dir, platform }) {
|
|
|
179
214
|
const started = ctx.now();
|
|
180
215
|
const [command, args] = launch(gate.command, platform);
|
|
181
216
|
const run = tool(ctx, command, args, { cwd: ctx.cwd, env: { ...ctx.env, ...gate.env } });
|
|
182
|
-
const
|
|
217
|
+
const output = `${run.stdout ?? ''}${run.stderr ?? ''}`;
|
|
218
|
+
const text = gate.name === 'sonar' && gate.env.SONAR_TOKEN ? output.replaceAll(gate.env.SONAR_TOKEN, '[REDACTED]') : output;
|
|
183
219
|
fs.writeFileSync(log, text);
|
|
184
220
|
const verdict = judgeVerdict(run, gate, text);
|
|
221
|
+
if (gate.name === 'sonar' && gate.env.SONAR_TOKEN) verdict.detail = verdict.detail.replaceAll(gate.env.SONAR_TOKEN, '[REDACTED]');
|
|
185
222
|
const seconds = Math.round((ctx.now() - started) / 1000);
|
|
186
223
|
return {
|
|
187
224
|
name: gate.name, command: gate.command.join(' '), pass: verdict.pass, detail: verdict.detail, seconds,
|
|
@@ -274,15 +311,18 @@ const summaryOf = (result) => `${result.pass ? 'PASS' : 'FAIL'} ${result.name} (
|
|
|
274
311
|
|
|
275
312
|
/**
|
|
276
313
|
* `starci app release gate`: dry run prints the preconditions, the gate list, the head and the tag; --execute runs every gate and, only if all are green,
|
|
277
|
-
* writes the L4 record and creates the annotated tag. options: { execute, source, tagPrefix, platform }.
|
|
314
|
+
* writes the L4 record and creates the annotated tag. options: { execute, sonar, source, tagPrefix, platform }.
|
|
278
315
|
*/
|
|
279
316
|
export async function releaseGate(ctx, config, options = {}) {
|
|
317
|
+
if (options.sonar) config = { ...config, gates: [...config.gates.filter((gate) => gate.name !== 'sonar'), sonarGate(config.services, ctx.env)] };
|
|
318
|
+
const ci = latestDefaultBranchCi({ spawn: ctx.spawn, cwd: ctx.cwd, defaultBranch: config.defaultBranch });
|
|
319
|
+
const ciLines = requireGreenCi(ctx, ci);
|
|
280
320
|
const facts = inspect(ctx, config, options);
|
|
281
321
|
const common = releaseDir(ctx);
|
|
282
322
|
const recordPath = path.join(common, `${facts.head}.l4.json`);
|
|
283
323
|
const dir = path.join(common, facts.head.slice(0, 12));
|
|
284
|
-
const plan = [...facts.out, ...planLines(config, facts, dir, recordPath)];
|
|
285
|
-
const base = { head: facts.head, tag: facts.chosen.tag, record: recordPath, gates: config.gates.map((gate) => gate.name) };
|
|
324
|
+
const plan = [...ciLines, ...facts.out, ...planLines(config, facts, dir, recordPath)];
|
|
325
|
+
const base = { head: facts.head, tag: facts.chosen.tag, record: recordPath, gates: config.gates.map((gate) => gate.name), ci: { state: ci.state, notices: ciLines } };
|
|
286
326
|
if (facts.red.length > 0) return { ok: false, lines: [...plan, ...facts.red.map((item) => `RED: ${item}`), 'nothing was run.'], data: { ...base, red: facts.red, executed: false } };
|
|
287
327
|
if (options.execute !== true) return { ok: true, lines: [...plan, '', 'dry run: no gate was run, nothing written. preconditions OK.'], data: { ...base, red: [], executed: false } };
|
|
288
328
|
const results = runAll(ctx, config, { dir, platform: options.platform ?? process.platform });
|