@starci/hfs 4.4.1 → 4.5.1
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/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 +4 -1
- package/db/reset.mjs +125 -0
- package/emit/db-types.mjs +98 -5
- package/lint/run.mjs +28 -6
- package/package.json +2 -1
- package/release/ci-red.mjs +70 -0
- package/release/gate.mjs +98 -27
- package/release/index.mjs +6 -4
- package/release/push.mjs +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +5 -5
- package/runtime/knowledge/hfs/rules.yaml +95 -4
- package/runtime/modules/kernel/failure-codes.yaml +60 -0
- package/runtime/modules/schemas/application-stacks.schema.yaml +134 -1
- package/runtime/packages/cli/src/catalog.generated.mjs +243 -20
- package/runtime/scripts/hfs/ci-declaration.mjs +102 -0
- package/runtime/scripts/hfs/rules/a15-findings.mjs +9 -0
- package/runtime/scripts/hfs/rules/app-workflow-findings.mjs +10 -0
- package/runtime/scripts/hfs/rules/ci-custody.mjs +104 -0
- 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/supply-chain.mjs +40 -3
- package/runtime/scripts/hfs/rules/workflow-contexts.mjs +103 -0
- package/runtime/scripts/lib/secret-patterns.mjs +2 -2
- package/src/main.mjs +17 -6
- package/stack/context.mjs +13 -1
- package/stack/docker-transport.mjs +93 -15
- package/stack/release-store.mjs +12 -7
- package/stack/remote.mjs +14 -6
- package/stack/ssh-session.mjs +178 -0
- package/sync/ci-commands.mjs +205 -1
- package/sync/index.mjs +42 -16
- package/sync/quality-services.mjs +61 -0
- package/sync/readme-badges.mjs +44 -0
- package/sync/stack-images.mjs +29 -3
- package/templates/app/ci-workflows/github/workflows/ci.yml +33 -25
- package/templates/app/ci-workflows/github/workflows/e2e.yml +1 -0
- package/templates/app/ci-workflows/github/workflows/images.yml +25 -2
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +8 -2
- package/templates/app/ci-workflows/install-steps.yml +23 -0
- package/templates/app/ci-workflows/jest-cache.yml +8 -0
- package/templates/app/ci-workflows/lint-base.yml +16 -0
- package/templates/app/ci-workflows/sonar-steps.yml +3 -9
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +36 -36
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +3 -1
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +25 -2
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +8 -2
- package/templates/app/gitignore +2 -0
- package/templates/app/gitignore-lite +2 -0
- 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 +62 -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 +58 -3
- package/templates/app/skeleton-lite/README.md +6 -0
- package/templates/app/upgrade-full/.starcistacks/application-stacks.yaml +53 -1
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';
|
|
@@ -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,13 +69,15 @@ 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
|
|
|
@@ -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.1",
|
|
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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// gate.mjs - `starci app release gate`: the honest release gate. It runs the app's own gates on a clean HEAD and
|
|
1
|
+
// gate.mjs - `starci app release gate`: the honest release gate. It runs the app's own gates on a clean HEAD and when all required gates pass it
|
|
2
2
|
// writes the L4 record <git-common-dir>/starci-release/<HEAD sha>.l4.json (head, tag, per-gate log path and sha256) and creates the annotated tag.
|
|
3
3
|
// The canon pre-push hook lets main and v* tags through only for a HEAD with that tag and that record. Nothing is pushed here (see push.mjs).
|
|
4
4
|
// The gate list comes from the optional `release:` block of .starcistacks/application-stacks.yaml, else from the app's managed package scripts.
|
|
@@ -7,10 +7,13 @@ 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';
|
|
16
|
+
const SONAR_UNREACHABLE = 'SONAR_UNREACHABLE';
|
|
14
17
|
|
|
15
18
|
const FIELDS = new Set(['gates', 'tagPrefix', 'defaultBranch', 'tagMatchesVersion']);
|
|
16
19
|
const GATE_FIELDS = new Set(['name', 'command', 'summary', 'env']);
|
|
@@ -35,10 +38,41 @@ const scriptGate = (script) => ({
|
|
|
35
38
|
env: {},
|
|
36
39
|
});
|
|
37
40
|
|
|
38
|
-
/**
|
|
39
|
-
function defaultGates(scripts) {
|
|
41
|
+
/** Default gates run app-check, the enabled audit, then every managed script present in the app. */
|
|
42
|
+
function defaultGates(scripts, audit) {
|
|
40
43
|
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)];
|
|
44
|
+
return [{ name: 'app-check', command: ['npx', 'starci', 'app', 'check'], summary: null, env: {} }, ...(audit ? [audit] : []), ...present.map(scriptGate)];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Shared quality settings retain the release reader's audit validation, disabled mode and legacy string host compatibility. */
|
|
48
|
+
function releaseServices(services, options) {
|
|
49
|
+
const audit = services?.audit ?? {};
|
|
50
|
+
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');
|
|
51
|
+
const sonar = services?.sonar;
|
|
52
|
+
const compatible = typeof sonar?.host === 'string' ? { ...sonar, host: { public: sonar.host } } : sonar;
|
|
53
|
+
const quality = qualityServices({ services: { ...services, sonar: compatible } }, options);
|
|
54
|
+
if (audit.mode === 'disabled') quality.audit = null;
|
|
55
|
+
return quality;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The shared audit settings preserve release scope, level and development-dependency selection. */
|
|
59
|
+
function auditGate(audit) {
|
|
60
|
+
if (!audit || !audit.in.includes('release-gate')) return null;
|
|
61
|
+
if (!['info', 'low', 'moderate', 'high', 'critical'].includes(audit.level)) throw refusal(RELEASE_CODE, 'services.audit.level must be an npm audit level');
|
|
62
|
+
return { name: 'audit', command: ['npm', 'audit', ...(audit.omitDev ? ['--omit=dev'] : []), `--audit-level=${audit.level}`], summary: null, env: {} };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A requested local Sonar scan uses only the declared host and token variable; the token never enters argv. */
|
|
66
|
+
function sonarGate(services, env) {
|
|
67
|
+
const configured = services?.sonar;
|
|
68
|
+
const sonar = configured?.provider === 'sonarcloud' ? { ...configured, host: 'https://sonarcloud.io' } : configured;
|
|
69
|
+
const tokenName = sonar?.token;
|
|
70
|
+
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');
|
|
71
|
+
const command = ['npx', '--yes', '@sonar/scan@4.3.0', `-Dsonar.host.url=${sonar.host}`];
|
|
72
|
+
const organization = sonar.organization ?? (sonar.organizationVar ? env[sonar.organizationVar] : null);
|
|
73
|
+
if (organization) command.push(`-Dsonar.organization=${organization}`);
|
|
74
|
+
if (!command.every((word) => TOKEN.test(word))) throw refusal('RELEASE_SONAR_UNAVAILABLE', 'Sonar host and organization must be plain command arguments');
|
|
75
|
+
return { name: 'sonar', command, summary: null, env: { SONAR_TOKEN: env[tokenName] }, provider: sonar };
|
|
42
76
|
}
|
|
43
77
|
|
|
44
78
|
function gateProblems(entry, at, seen) {
|
|
@@ -74,7 +108,7 @@ function gatesOf(list, problems) {
|
|
|
74
108
|
}
|
|
75
109
|
|
|
76
110
|
/** Judges the optional `release:` block. Absence is fine (defaults). Returns { config } or { problems }. */
|
|
77
|
-
export function normalizeRelease(block, scripts = {}) {
|
|
111
|
+
export function normalizeRelease(block, scripts = {}, services = {}, options = {}) {
|
|
78
112
|
const raw = block ?? {};
|
|
79
113
|
if (!plain(raw)) return { problems: ['release: must be a mapping'] };
|
|
80
114
|
const problems = Object.keys(raw).filter((key) => !FIELDS.has(key)).map((key) => `release.${key}: is not a field of the release block`);
|
|
@@ -84,8 +118,11 @@ export function normalizeRelease(block, scripts = {}) {
|
|
|
84
118
|
if (typeof tagPrefix !== 'string' || !PREFIX.test(tagPrefix)) problems.push(`release.tagPrefix: must match ${PREFIX}`);
|
|
85
119
|
if (!refName(defaultBranch)) problems.push(`release.defaultBranch: must match ${REF_NAME}`);
|
|
86
120
|
if (typeof tagMatchesVersion !== 'boolean') problems.push('release.tagMatchesVersion: must be true or false');
|
|
87
|
-
const
|
|
88
|
-
|
|
121
|
+
const quality = releaseServices(services, options);
|
|
122
|
+
const audit = auditGate(quality.audit);
|
|
123
|
+
const gates = raw.gates === undefined ? defaultGates(scripts, audit) : gatesOf(raw.gates, problems);
|
|
124
|
+
if (raw.gates !== undefined && audit && !gates.some((gate) => gate.name === 'audit')) gates.push(audit);
|
|
125
|
+
return problems.length === 0 ? { config: { gates, tagPrefix, defaultBranch, tagMatchesVersion, services: quality } } : { problems };
|
|
89
126
|
}
|
|
90
127
|
|
|
91
128
|
/* ---- git through the seam */
|
|
@@ -174,14 +211,42 @@ export function launch(argv, platform) {
|
|
|
174
211
|
return [argv[0], argv.slice(1)];
|
|
175
212
|
}
|
|
176
213
|
|
|
214
|
+
/** Sonar availability is public and bounded; HTTP uses the existing injected fetch seam. */
|
|
215
|
+
async function sonarAvailability(ctx, gate) {
|
|
216
|
+
const sonar = gate.provider;
|
|
217
|
+
if (!sonar) return null;
|
|
218
|
+
let up = false;
|
|
219
|
+
try {
|
|
220
|
+
const response = await ctx.fetch(sonar.host.replace(/\/$/u, '') + '/api/system/status', { signal: AbortSignal.timeout(10000) });
|
|
221
|
+
up = response.ok && (await response.json()).status === 'UP';
|
|
222
|
+
} catch { up = false; }
|
|
223
|
+
if (up) return null;
|
|
224
|
+
const detail = sonar.optional
|
|
225
|
+
? `notice: sonar skipped: ${sonar.host} not reachable (services.sonar.optional)`
|
|
226
|
+
: `${SONAR_UNREACHABLE}: ${sonar.host} did not report UP within ten seconds`;
|
|
227
|
+
return { skipped: sonar.optional === true, pass: false, detail };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Unreachable optional providers remain explicit skipped rows, with a real log and no scan invocation. */
|
|
231
|
+
async function runAvailableGate(ctx, gate, options) {
|
|
232
|
+
const availability = await sonarAvailability(ctx, gate);
|
|
233
|
+
if (!availability) return runGate(ctx, gate, options);
|
|
234
|
+
const log = path.join(options.dir, `${gate.name}.log`);
|
|
235
|
+
fs.writeFileSync(log, availability.detail + '\n');
|
|
236
|
+
return { name: gate.name, command: gate.command.join(' '), ...availability, seconds: 0,
|
|
237
|
+
summary: null, log: path.resolve(log), logSha256: sha256(log), env: Object.keys(gate.env) };
|
|
238
|
+
}
|
|
239
|
+
|
|
177
240
|
function runGate(ctx, gate, { dir, platform }) {
|
|
178
241
|
const log = path.join(dir, `${gate.name}.log`);
|
|
179
242
|
const started = ctx.now();
|
|
180
243
|
const [command, args] = launch(gate.command, platform);
|
|
181
244
|
const run = tool(ctx, command, args, { cwd: ctx.cwd, env: { ...ctx.env, ...gate.env } });
|
|
182
|
-
const
|
|
245
|
+
const output = `${run.stdout ?? ''}${run.stderr ?? ''}`;
|
|
246
|
+
const text = gate.name === 'sonar' && gate.env.SONAR_TOKEN ? output.replaceAll(gate.env.SONAR_TOKEN, '[REDACTED]') : output;
|
|
183
247
|
fs.writeFileSync(log, text);
|
|
184
248
|
const verdict = judgeVerdict(run, gate, text);
|
|
249
|
+
if (gate.name === 'sonar' && gate.env.SONAR_TOKEN) verdict.detail = verdict.detail.replaceAll(gate.env.SONAR_TOKEN, '[REDACTED]');
|
|
185
250
|
const seconds = Math.round((ctx.now() - started) / 1000);
|
|
186
251
|
return {
|
|
187
252
|
name: gate.name, command: gate.command.join(' '), pass: verdict.pass, detail: verdict.detail, seconds,
|
|
@@ -233,7 +298,7 @@ function inspect(ctx, config, options) {
|
|
|
233
298
|
}
|
|
234
299
|
|
|
235
300
|
function planLines(config, facts, dir, recordPath) {
|
|
236
|
-
const out = [`L4 record ${recordPath}`, `logs ${dir}`, '', 'gates (
|
|
301
|
+
const out = [`L4 record ${recordPath}`, `logs ${dir}`, '', 'gates (required gates must pass, optional provider outages are skipped, in this order):'];
|
|
237
302
|
for (const gate of config.gates) {
|
|
238
303
|
const summary = gate.summary ? ` (summary: ${gate.summary})` : '';
|
|
239
304
|
out.push(` ${gate.name.padEnd(17)} ${gate.command.join(' ')}${summary}`);
|
|
@@ -252,47 +317,53 @@ function assertUnmoved(ctx, head) {
|
|
|
252
317
|
function writeRecord(ctx, { head, tag, results, recordPath }) {
|
|
253
318
|
const record = {
|
|
254
319
|
schema: 'starci-app-l4-record/1', head, tag, createdAt: new Date(ctx.now()).toISOString(), producedBy: 'starci app release gate',
|
|
255
|
-
note: '
|
|
320
|
+
note: 'Required gates passed on a clean HEAD; optional provider skips are explicit and every entry links its log and sha256.',
|
|
256
321
|
gates: results,
|
|
257
322
|
};
|
|
258
323
|
fs.mkdirSync(path.dirname(recordPath), { recursive: true });
|
|
259
324
|
fs.writeFileSync(recordPath, `${JSON.stringify(record, null, 2)}\n`);
|
|
260
325
|
}
|
|
261
326
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
const
|
|
267
|
-
results.
|
|
268
|
-
|
|
269
|
-
}
|
|
270
|
-
return results;
|
|
327
|
+
/** Gates run sequentially because each failure prevents subsequent commands and record creation. */
|
|
328
|
+
async function runAll(ctx, config, options) {
|
|
329
|
+
fs.mkdirSync(options.dir, { recursive: true });
|
|
330
|
+
return config.gates.reduce(async (pending, gate) => {
|
|
331
|
+
const results = await pending;
|
|
332
|
+
if (results.some((result) => !result.pass && !result.skipped)) return results;
|
|
333
|
+
return [...results, await runAvailableGate(ctx, gate, options)];
|
|
334
|
+
}, Promise.resolve([]));
|
|
271
335
|
}
|
|
272
336
|
|
|
273
|
-
|
|
337
|
+
function summaryOf(result) {
|
|
338
|
+
let status = result.pass ? 'PASS' : 'FAIL';
|
|
339
|
+
if (result.skipped) status = 'SKIPPED';
|
|
340
|
+
return `${status} ${result.name} (${result.detail}, ${result.seconds}s) log ${result.log}`;
|
|
341
|
+
}
|
|
274
342
|
|
|
275
343
|
/**
|
|
276
|
-
* `starci app release gate`: dry run prints the preconditions, the gate list, the head and the tag; --execute runs every gate and, only if
|
|
277
|
-
* writes the L4 record and creates the annotated tag. options: { execute, source, tagPrefix, platform }.
|
|
344
|
+
* `starci app release gate`: dry run prints the preconditions, the gate list, the head and the tag; --execute runs every gate and, only if required gates pass,
|
|
345
|
+
* writes the L4 record and creates the annotated tag. options: { execute, sonar, source, tagPrefix, platform }.
|
|
278
346
|
*/
|
|
279
347
|
export async function releaseGate(ctx, config, options = {}) {
|
|
348
|
+
if (options.sonar) config = { ...config, gates: [...config.gates.filter((gate) => gate.name !== 'sonar'), sonarGate(config.services, ctx.env)] };
|
|
349
|
+
const ci = latestDefaultBranchCi({ spawn: ctx.spawn, cwd: ctx.cwd, defaultBranch: config.defaultBranch });
|
|
350
|
+
const ciLines = requireGreenCi(ctx, ci);
|
|
280
351
|
const facts = inspect(ctx, config, options);
|
|
281
352
|
const common = releaseDir(ctx);
|
|
282
353
|
const recordPath = path.join(common, `${facts.head}.l4.json`);
|
|
283
354
|
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) };
|
|
355
|
+
const plan = [...ciLines, ...facts.out, ...planLines(config, facts, dir, recordPath)];
|
|
356
|
+
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
357
|
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
358
|
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
|
-
const results = runAll(ctx, config, { dir, platform: options.platform ?? process.platform });
|
|
359
|
+
const results = await runAll(ctx, config, { dir, platform: options.platform ?? process.platform });
|
|
289
360
|
const report = results.map(summaryOf);
|
|
290
|
-
const failed = results.filter((result) => !result.pass);
|
|
361
|
+
const failed = results.filter((result) => !result.pass && !result.skipped);
|
|
291
362
|
if (failed.length > 0) return { ok: false, lines: [...plan, ...report, `gate(s) red: ${failed.map((item) => item.name).join(', ')}. No L4 record, no tag.`], data: { ...base, executed: true, results } };
|
|
292
363
|
assertUnmoved(ctx, facts.head);
|
|
293
364
|
writeRecord(ctx, { head: facts.head, tag: facts.chosen.tag, results, recordPath });
|
|
294
365
|
const created = facts.chosen.state !== 'on-head';
|
|
295
|
-
if (created) git(ctx, ['tag', '-a', facts.chosen.tag, '-m', `release ${facts.chosen.tag}: gates
|
|
366
|
+
if (created) git(ctx, ['tag', '-a', facts.chosen.tag, '-m', `release ${facts.chosen.tag}: required gates passed on ${facts.head.slice(0, 12)} (L4 record ${path.basename(recordPath)})`, facts.head]);
|
|
296
367
|
const tagged = created ? `annotated tag ${facts.chosen.tag} created on ${facts.head.slice(0, 10)}` : `annotated tag ${facts.chosen.tag} already on ${facts.head.slice(0, 10)}`;
|
|
297
368
|
return { ok: true, lines: [...plan, ...report, `L4 record written: ${recordPath}`, tagged], data: { ...base, executed: true, results, tagCreated: created } };
|
|
298
369
|
}
|
package/release/index.mjs
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// index.mjs - `starci app release <gate|push>` and the plumbing the release and database verbs share: the context built from the app's IO, strict flag
|
|
2
2
|
// parsing, the optional `release:` / `database:` blocks of .starcistacks/application-stacks.yaml, text and --json output and the exit-code mapping.
|
|
3
|
-
// starci app release gate [--execute] [--source <ref>] [--tag-prefix <prefix>] [--json]
|
|
3
|
+
// starci app release gate [--execute] [--sonar] [--source <ref>] [--tag-prefix <prefix>] [--json]
|
|
4
4
|
// starci app release push [--execute] [--remote <name>] [--json]
|
|
5
5
|
// Every action is a dry run unless --execute is given. A refusal is a StackError (exit 2); a failed step returns ok false (exit 1).
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { isPlainObject as plain } from '../runtime/engine/plain-object.mjs';
|
|
9
9
|
import { parseYaml } from '../runtime/engine/yaml.mjs';
|
|
10
|
+
import { isRuntimeExampleApp } from '../runtime/scripts/lib/runtime-example.mjs';
|
|
10
11
|
import { STACKS_DIR, STACKS_FILE } from '../stack/config.mjs';
|
|
11
12
|
import { stackContext } from '../stack/context.mjs';
|
|
12
13
|
import { EXIT, StackError, refusal } from '../stack/errors.mjs';
|
|
@@ -56,7 +57,8 @@ function readPackage(root) {
|
|
|
56
57
|
function loadReleaseConfig(root) {
|
|
57
58
|
const pkg = readPackage(root);
|
|
58
59
|
if (typeof pkg.version !== 'string' || !VERSION.test(pkg.version)) throw refusal(RELEASE_CODE, 'package.json needs a version like 1.2.3 (the release tag derives from it)');
|
|
59
|
-
const
|
|
60
|
+
const declaration = readDeclaration(root);
|
|
61
|
+
const { config, problems } = normalizeRelease(declaration.release, pkg.scripts, declaration.services, { example: isRuntimeExampleApp(root) });
|
|
60
62
|
if (!config) throw refusal(RELEASE_CODE, `${problems.length} problem(s) in release: ${problems.slice(0, 6).join('; ')}`);
|
|
61
63
|
return { root, version: pkg.version, ...config };
|
|
62
64
|
}
|
|
@@ -83,8 +85,8 @@ export async function runVerb({ label, name, io, argv, spec, run, code = 'RELEAS
|
|
|
83
85
|
|
|
84
86
|
const ACTIONS = {
|
|
85
87
|
gate: {
|
|
86
|
-
spec: { boolean: ['--execute', '--json'], value: ['--source', '--tag-prefix'] },
|
|
87
|
-
run: (ctx, flags) => releaseGate(ctx, loadReleaseConfig(ctx.cwd), { execute: flags.execute === true, source: flags.source, tagPrefix: flags['tag-prefix'] }),
|
|
88
|
+
spec: { boolean: ['--execute', '--json', '--sonar'], value: ['--source', '--tag-prefix'] },
|
|
89
|
+
run: (ctx, flags) => releaseGate(ctx, loadReleaseConfig(ctx.cwd), { execute: flags.execute === true, sonar: flags.sonar === true, source: flags.source, tagPrefix: flags['tag-prefix'] }),
|
|
88
90
|
},
|
|
89
91
|
push: {
|
|
90
92
|
spec: { boolean: ['--execute', '--json'], value: ['--remote'] },
|
package/release/push.mjs
CHANGED
|
@@ -14,7 +14,7 @@ function readRecord(file) {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
const greenRecord = (record, head, tag) => record?.head === head && record?.tag === tag
|
|
17
|
-
&& Array.isArray(record.gates) && record.gates.length > 0 && record.gates.every((gate) => gate?.pass === true);
|
|
17
|
+
&& Array.isArray(record.gates) && record.gates.length > 0 && record.gates.every((gate) => gate?.pass === true || (gate?.name === 'sonar' && gate?.skipped === true));
|
|
18
18
|
|
|
19
19
|
/** The release gate as the hook reads it: the annotated tag on HEAD whose L4 record names head and tag. Returns { tag } or { reason }. */
|
|
20
20
|
function gateState(ctx, head, prefix) {
|
|
@@ -29,14 +29,14 @@ pins:
|
|
|
29
29
|
source: packages/grammar/package.json
|
|
30
30
|
why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
|
|
31
31
|
'@starci/eslint-canon-be':
|
|
32
|
-
version: 3.4.
|
|
32
|
+
version: 3.4.3
|
|
33
33
|
group: starci
|
|
34
34
|
install: registry
|
|
35
35
|
side: be
|
|
36
36
|
source: packages/eslint/be/package.json
|
|
37
37
|
why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
|
|
38
38
|
'@starci/eslint-canon-fe':
|
|
39
|
-
version: 8.4.
|
|
39
|
+
version: 8.4.3
|
|
40
40
|
group: starci
|
|
41
41
|
install: registry
|
|
42
42
|
side: fe
|
|
@@ -62,7 +62,7 @@ pins:
|
|
|
62
62
|
side: both
|
|
63
63
|
source: packages/prettier-config/package.json
|
|
64
64
|
'@starci/jest-preset':
|
|
65
|
-
version: 2.3.
|
|
65
|
+
version: 2.3.1
|
|
66
66
|
group: starci
|
|
67
67
|
install: registry
|
|
68
68
|
side: be
|
|
@@ -76,14 +76,14 @@ pins:
|
|
|
76
76
|
source: packages/test-world/package.json
|
|
77
77
|
why: 'the shared e2e library of every back end (R47, R48), a devDependency of every back end: the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs and the outage lock. Kafka is real own infra (the one digest-pinned apache/kafka KRaft image, a broker listener and proxy per slot, slot-prefixed topics, consumer groups and client ids) and Postgres is schema-per-context (a schema and a login role per context in a shared database). Each worker has its own data slot (<ns>_w<k>) with its own databases, Keycloak realm, leased Redis DB, bucket/collection/topic prefixes, fakes host, toxiproxy proxies and outage lock; a run provisions min(jest workers, workers cap, default 2) slots, and state file protocol 2 pairs exactly with @starci/jest-preset (TEST_WORLD_PAIR_MISMATCH otherwise). A modules world boots real peer apps beside its modules, world.apps.<name>.during(fn) is the outage of a peer app, w.keycloak.clientSecret(client) answers the run-generated secret of a confidential realm client, world.keycloak.events/sessions read the realm user events and live sessions, world.infra.postgresql.connection(name) takes one database connection down while the others serve, and world.resolve and scope.resolve take any Nest token. Every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json); the globalSetup registers the path aliases as TypeScript resolves them. Its CLI main is exported at ./cli for starci app stack.'
|
|
78
78
|
'@starci/cli':
|
|
79
|
-
version: 1.
|
|
79
|
+
version: 1.5.1
|
|
80
80
|
group: starci
|
|
81
81
|
install: registry
|
|
82
82
|
side: both
|
|
83
83
|
source: packages/cli/package.json
|
|
84
84
|
why: 'the only StarCi command package; product repositories install it for starci app, while @starci/hfs remains its pinned transitive implementation.'
|
|
85
85
|
'@starci/hfs':
|
|
86
|
-
version: 4.
|
|
86
|
+
version: 4.5.1
|
|
87
87
|
group: starci
|
|
88
88
|
install: registry
|
|
89
89
|
side: both
|