@elia97/officina 0.13.0 → 0.15.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/README.md +12 -6
- package/dist/bin.js +2 -0
- package/dist/checks/scope.d.ts +2 -0
- package/dist/checks/scope.js +13 -0
- package/dist/checks/smoke.js +6 -7
- package/dist/lib/alignment.js +2 -5
- package/dist/lib/ci-scope.d.ts +6 -0
- package/dist/lib/ci-scope.js +20 -0
- package/dist/lib/config.d.ts +3 -1
- package/dist/lib/config.js +2 -1
- package/dist/lib/database-auth.js +1 -3
- package/dist/lib/git.d.ts +2 -0
- package/dist/lib/git.js +21 -6
- package/dist/lib/smoke-on-demand.js +7 -8
- package/dist/lib/smoke-production.d.ts +1 -1
- package/dist/lib/smoke-production.js +4 -5
- package/dist/lib/smoke-robots.d.ts +4 -0
- package/dist/lib/smoke-robots.js +22 -0
- package/dist/lib/validate.d.ts +1 -0
- package/dist/lib/validate.js +9 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -76,7 +76,9 @@ Per Lighthouse un sito SSR si misura con `check lighthouse <url>`, sulla produzi
|
|
|
76
76
|
|
|
77
77
|
Lo smoke legge gli header di sicurezza sulla pagina a cui arriva la radice. Se `/` è un redirect sulla stessa origine — il 302 di lingua che Astro emette con `i18n.routing.redirectToDefaultLocale`, per esempio — li cerca sulla destinazione, perché il redirect non porta la CSP delle pagine renderizzate, e il nome del controllo dice dove li ha letti: `header content-security-policy su /it`. Un redirect verso un'altra origine, o senza `location`, è un fallimento. Anche l'attesa che l'alias di produzione punti al deployment nuovo conta un 3xx come una risposta.
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
**Prima del dominio.** Un sito può andare in produzione sull'indirizzo di Vercel prima che il DNS punti a Vercel, per esempio per formare sul sito vero chi lo gestirà. Finché il dominio di `siteUrl` non serve la produzione, `smoke.url` in `officina.config.ts` dice dove la serve: l'origine di produzione che Vercel dà al progetto, come `https://<progetto>.vercel.app`, senza percorso né query. Lo smoke senza argomenti prova quella al posto di `siteUrl`, che resta obbligatorio come host canonico, e l'intestazione dell'uscita lo ricorda; così fa anche il deploy, che lancia lo smoke senza indirizzo. Un indirizzo passato a mano vince su `smoke.url`. Su un indirizzo `*.vercel.app` che non è `siteUrl` il controllo del noindex si rovescia: la regola `has: host` di `vercel.json` mette `X-Robots-Tag: noindex` su ogni host di Vercel, quindi lì il noindex deve esserci, per tutti i motori, e un indirizzo di Vercel indicizzabile è il guasto. Vale anche per un indirizzo passato a mano, e un progetto il cui `vercel.json` non ha quella regola lì fallisce. Il controllo dell'host canonico si salta, come per ogni URL di base che non è `siteUrl`. L'URL che stampa `vercel deploy` non si usa: è quello del singolo deployment, che la Standard Protection di Vercel protegge, e lo smoke riceverebbe 401 su ogni pagina, mentre il dominio di produzione resta pubblico. Con il dominio la voce esce, e lo smoke torna sull'apice.
|
|
80
|
+
|
|
81
|
+
Lo smoke visita anche le pagine rese a richiesta, quelle di `src/pages` con `export const prerender = false`. Ognuna risponde 200, oppure con un redirect sulla stessa origine, come una pagina protetta che rimanda all'accesso; un 200 è HTML e porta il meta `Content-Security-Policy` con la direttiva `script-src`, quello che il middleware dei template scrive nelle pagine rese a richiesta. Una pagina dinamica si visita col suo rappresentante di `routes.representatives`, altrimenti si salta dicendolo. Queste pagine non entrano nel controllo della barra finale né in Lighthouse. Un'anomalia fa fallire lo smoke. officina riconosce una pagina resa a richiesta dalla dichiarazione: con `output: 'server'`, una pagina senza `prerender = false` resta per tutti i gate una pagina prerenderizzata, e lo smoke la visita come le altre, senza cercarne la CSP.
|
|
80
82
|
|
|
81
83
|
Quando fallisce, lo smoke dice che la produzione è online e rotta, e consiglia di tornare indietro dalla dashboard di Vercel promuovendo l'ultimo deployment di produzione sano. Con `features.database` accesa aggiunge che lo schema non torna indietro: le migrazioni già applicate restano, e il deployment promosso regge solo se sono compatibili con il suo codice, la regola di «Migrazioni compatibili con la produzione» in § Action per i workflow.
|
|
82
84
|
|
|
@@ -109,7 +111,7 @@ La radice del pacchetto esporta `defineConfig` e i tipi; i pezzi del motore stan
|
|
|
109
111
|
| `@elia97/officina/bundle` | `CSS_BUDGET_GZIP` |
|
|
110
112
|
| `@elia97/officina/icons` | `ICON_SPECS` |
|
|
111
113
|
|
|
112
|
-
`siteUrl` serve a `check smoke` e `icons.background` a `gen icons`. I budget del progetto vengono prima del default di 20 KB, nell'ordine in cui sono scritti; `smoke.checks` sostituisce la lista del pacchetto, quindi chi ne toglie un controllo la ricompone da `DEFAULT_CHECKS`; `smoke.securityHeaders` e `smoke.nonHtmlRoutes` sostituiscono `SECURITY_HEADERS` e `NON_HTML_ROUTES
|
|
114
|
+
`siteUrl` serve a `check smoke` e `icons.background` a `gen icons`. I budget del progetto vengono prima del default di 20 KB, nell'ordine in cui sono scritti; `smoke.checks` sostituisce la lista del pacchetto, quindi chi ne toglie un controllo la ricompone da `DEFAULT_CHECKS`; `smoke.securityHeaders` e `smoke.nonHtmlRoutes` sostituiscono `SECURITY_HEADERS` e `NON_HTML_ROUTES`; `smoke.url` prende il posto di `siteUrl` come indirizzo da provare, finché il dominio non serve la produzione, come in «Prima del dominio».
|
|
113
115
|
|
|
114
116
|
`features` dice quali controlli il progetto pretende: `'required'` fa fallire il gate quando ciò che deve guardare non c'è, `false` lo spegne dicendolo. Per BotID è `features.botId`: con `false` lo smoke salta la challenge e lo scrive nell'uscita, anche dentro una lista di `smoke.checks` che la contiene. Una voce non dichiarata vale `'required'`, e `doctor` la segnala: un gate che esce 0 su un container GTM che non esiste non afferma niente. `routes.representatives` dà a ogni pattern dinamico di `src/pages` un percorso vero, e da lì smoke e Lighthouse guardano anche quelle pagine.
|
|
115
117
|
|
|
@@ -152,7 +154,7 @@ I passi dei workflow stanno in `actions/` di questo repository, come composite a
|
|
|
152
154
|
|
|
153
155
|
| Action | Passi | Cosa resta nel workflow del progetto |
|
|
154
156
|
|---|---|---|
|
|
155
|
-
| `actions/ci` | checkout, node, install, `astro sync`, con `migrate` le migrazioni del database della corsa, `pnpm run ci`, i canary di `check secrets`, build, `perf:bundle`, `check:links`, `check:secrets`; con `e2e: 'true'` anche Playwright | trigger, permessi, il job `ci`; con un database, il branch Neon della corsa, creato e cancellato intorno all'action, e lo script delle migrazioni e il suo indirizzo passati in `with:` |
|
|
157
|
+
| `actions/ci` | checkout, node, install, `astro sync`, con `migrate` le migrazioni del database della corsa, `pnpm run ci`, i canary di `check secrets`, build, `perf:bundle`, `check:links`, `check:secrets`; con `e2e: 'true'` anche Playwright; se la PR o il push toccano solo documenti, dopo l'install soltanto `check:language`, `check:routes`, `check:roadmap` e `doctor` | trigger, permessi, il job `ci`; con un database, il branch Neon della corsa, creato e cancellato intorno all'action, e lo script delle migrazioni e il suo indirizzo passati in `with:` |
|
|
156
158
|
| `actions/review` | `astro sync`, poi `fallow review` sul diff contro il merge-base | il job informativo |
|
|
157
159
|
| `actions/deploy` | risoluzione del tag, stessa versione di officina, gate, `vercel pull`, con `migrate` le migrazioni, `build`, controllo della build, `deploy`, smoke; espone `url` | trigger, `environment`, i tre segreti Vercel passati in `with:`, il job che controlla se i segreti ci sono; con un database, lo script delle migrazioni e il segreto `PRODUCTION_DATABASE_URL` passati in `with:` |
|
|
158
160
|
| `actions/lighthouse` | build equivalente alla produzione e `pnpm run lhci` | trigger, etichetta, `continue-on-error` |
|
|
@@ -183,6 +185,8 @@ jobs:
|
|
|
183
185
|
|
|
184
186
|
I tre segreti sono input obbligatori e l'action li mette nell'`env` dei soli tre passi che chiamano `vercel`, come `database-url` in quello delle migrazioni. Nell'`env` del job li vedrebbero anche `pnpm install` e gli script di installazione di ogni dipendenza, che girano codice di terzi con in mano un token di deploy o il database di produzione.
|
|
185
187
|
|
|
188
|
+
**Il percorso veloce dei documenti.** Dopo l'install `actions/ci` lancia `officina scope`, che confronta l'`HEAD` del checkout con la base dell'evento: per una PR la sua base, mentre l'`HEAD` è il merge commit di GitHub; per un push il commit `before`. Se ogni file cambiato è un documento, cioè un `.md` alla radice, sotto `docs/`, sotto `.github/` o sotto `.claude/`, l'action lancia soltanto `check:language`, `check:routes`, `check:roadmap` e `doctor`, e salta il resto: `astro sync`, le migrazioni, `pnpm run ci`, la build, `perf:bundle`, `check:links`, `check:secrets` e gli e2e. `doctor` resta perché alcuni Markdown, come gli agenti in `.claude/agents/` o `docs/DECISIONS.md`, sono residui che vieta. Un'immagine o uno snippet in `docs/`, e `src/content/` anche quando è Markdown, fanno il percorso completo. Il job parte comunque, perché `ci` è un check obbligatorio e un `paths-ignore` sulla PR lo lascerebbe appeso. Nel dubbio il percorso è completo: un evento senza base, come il primo push di un branch, un diff che non riesce, nessun file cambiato, un file che non è un documento, che il log del passo nomina, o `officina scope` che fallisce, e allora il passo lo dice con un avviso. Il confronto è fra due alberi e scarica la sola base, senza la storia: il percorso veloce vuol dire che, fuori dai documenti, l'albero da provare è identico a quello della base, e una base andata avanti nel frattempo aggiunge file al diff e riporta il percorso completo. Uno spostamento conta come cancellazione più aggiunta, quindi un file di codice spostato in `docs/` non passa per documento. Su un push il percorso veloce non riprova il codice di `before`, come un `paths-ignore`.
|
|
189
|
+
|
|
186
190
|
**Il deploy lanciato a mano.** Con `workflow_dispatch` il workflow viene dal ramo scelto in «Use workflow from», e il pacchetto dal lockfile del tag che si deploya: le due metà possono avere due versioni di officina diverse, e un passo dell'action più nuova può chiamare un'opzione che gli script del tag non conoscono. `actions/deploy` confronta le due versioni subito dopo l'install, prima di ogni script del progetto e di `vercel pull`, e si ferma dicendole se non coincidono o se il tag non installa officina. Un tag più vecchio si deploya lanciando Deploy da quel tag: il suo `deploy.yml` dichiara l'action della sua versione, con i gate di allora. Perché si possa, l'environment `production` del progetto deve ammettere i tag fra i ref da cui si deploya.
|
|
187
191
|
|
|
188
192
|
**Il database di test.** Ogni corsa della CI migra un database suo: un branch Neon che il `ci.yml` del progetto crea prima di `actions/ci`, figlio di un branch vuoto, e cancella alla fine. Così due corse in parallelo, come la CI di una PR e quella di `main`, non vedono l'una le migrazioni dell'altra. Il genitore resta vuoto perché il migratore di drizzle-orm 0.45 applica una migrazione solo se è più recente dell'ultima applicata: ogni corsa le applica tutte, da zero, e nessuna si salta.
|
|
@@ -217,7 +221,7 @@ I tre segreti sono input obbligatori e l'action li mette nell'`env` dei soli tre
|
|
|
217
221
|
api_key: ${{ secrets.NEON_API_KEY }}
|
|
218
222
|
```
|
|
219
223
|
|
|
220
|
-
Il genitore, `test` nell'esempio, è il branch di test di prima. Si svuota una volta con `DROP SCHEMA public CASCADE; CREATE SCHEMA public; DROP SCHEMA IF EXISTS drizzle CASCADE;`, e da lì nessuno lo migra. Il primo passo legge la chiave dal proprio `env`, non da quello del job, dove la vedrebbero anche le dipendenze. `expires_at` cancella il branch anche quando il passo finale non arriva. La chiave dell'API di Neon crea e cancella i branch del progetto, quindi va limitata al progetto dove Neon lo permette, e non va data a Dependabot, che la passerebbe al codice di ogni aggiornamento. `doctor`
|
|
224
|
+
Il genitore, `test` nell'esempio, è il branch di test di prima. Si svuota una volta con `DROP SCHEMA public CASCADE; CREATE SCHEMA public; DROP SCHEMA IF EXISTS drizzle CASCADE;`, e da lì nessuno lo migra. Il primo passo legge la chiave dal proprio `env`, non da quello del job, dove la vedrebbero anche le dipendenze. `expires_at` cancella il branch anche quando il passo finale non arriva. La chiave dell'API di Neon crea e cancella i branch del progetto, quindi va limitata al progetto dove Neon lo permette, e non va data a Dependabot, che la passerebbe al codice di ogni aggiornamento. `doctor` fallisce quando `ci.yml` passa ancora un branch condiviso, `TEST_DATABASE_URL`.
|
|
221
225
|
|
|
222
226
|
Prima di `pnpm run ci` l'action lancia `officina migrate --script <migrate>`, che legge `database.migrationUrlKey` da `officina.config.ts` e dà l'indirizzo allo script sotto quella chiave, e solo sotto quella. `pnpm run ci` lo riceve come `TEST_DATABASE_URL`, per i test di integrazione. La build e `check:secrets` lo ricevono come `DATABASE_URL`, al posto del canary: una pagina prerenderizzata dal database lo legge davvero, e `check secrets` cerca il valore che la build ha ricevuto. Gli altri passi non lo vedono, e senza `database-url` i comandi ricevono l'ambiente del job com'è. Le PR di Dependabot e quelle da un fork non ricevono `NEON_API_KEY`: il branch non nasce, `database-url` arriva vuoto, e con `migrate` l'action non migra, lo dice con un `::notice::` e prosegue. I test di integrazione si saltano da soli, e la build riceve il canary, quindi una pagina prerenderizzata dal database non ha un database da leggere, e la build si ferma.
|
|
223
227
|
|
|
@@ -230,7 +234,7 @@ Prima di `pnpm run ci` l'action lancia `officina migrate --script <migrate>`, ch
|
|
|
230
234
|
database-url: ${{ secrets.PRODUCTION_DATABASE_URL }}
|
|
231
235
|
```
|
|
232
236
|
|
|
233
|
-
Dopo `vercel pull` e `check:placeholders --env`, subito prima di `vercel build`, l'action lancia `officina migrate --production --script <migrate>`, che dà l'indirizzo allo script sotto `database.migrationUrlKey`, e solo sotto quella. `PRODUCTION_DATABASE_URL` è un segreto dell'environment `production` del repository, che il job di deploy dichiara: lo leggono solo i job di quell'environment, e solo dai ref che le sue regole ammettono, da restringere a `main` e ai tag di release, perché senza regole lo legge un job su qualunque branch. È l'indirizzo diretto al database, senza pooler, come `DATABASE_URL_UNPOOLED` su Neon. Senza `database-url` il passo delle migrazioni si ferma, e il deploy con lui. `doctor`
|
|
237
|
+
Dopo `vercel pull` e `check:placeholders --env`, subito prima di `vercel build`, l'action lancia `officina migrate --production --script <migrate>`, che dà l'indirizzo allo script sotto `database.migrationUrlKey`, e solo sotto quella. `PRODUCTION_DATABASE_URL` è un segreto dell'environment `production` del repository, che il job di deploy dichiara: lo leggono solo i job di quell'environment, e solo dai ref che le sue regole ammettono, da restringere a `main` e ai tag di release, perché senza regole lo legge un job su qualunque branch. È l'indirizzo diretto al database, senza pooler, come `DATABASE_URL_UNPOOLED` su Neon. Senza `database-url` il passo delle migrazioni si ferma, e il deploy con lui. `doctor` fallisce anche quando il deploy riceve `TEST_DATABASE_URL`, o la CI `PRODUCTION_DATABASE_URL`: le migrazioni andrebbero sul database sbagliato, uscendo 0. Il runner maschera il segreto intero, ma non la password da sola: il comando registra con `::add-mask::` l'indirizzo e la sua password, com'è e decodificata, su stdout e su stderr, e lancia lo script quando le maschere sono scritte.
|
|
234
238
|
|
|
235
239
|
**Il segreto delle migrazioni.** Su Vercel la chiave delle migrazioni non serve, e se l'applicazione la usa a runtime può restare Secret. Una risorsa del Marketplace impostata su «Production only», che protegge le sue credenziali come Secret, non ferma più le migrazioni. Rende però Secret anche `DATABASE_URL`, e una build che legge il database la riceve come `[SENSITIVE]` e si ferma, a migrazioni già applicate. Il prezzo è un secondo posto da tenere allineato: quando cambia l'indirizzo, che sia la password, l'host o il database, il segreto va aggiornato anche su GitHub. Con una password vecchia il deploy si ferma al passo delle migrazioni. Se invece il database vecchio risponde ancora, le migrazioni vanno lì, e il deploy non se ne accorge.
|
|
236
240
|
|
|
@@ -283,7 +287,9 @@ I sorgenti sono TypeScript, ma il pacchetto pubblica JavaScript: Node toglie i t
|
|
|
283
287
|
|
|
284
288
|
## Pubblicare
|
|
285
289
|
|
|
286
|
-
**
|
|
290
|
+
**Finché officina cambia spesso, una release quando una modifica è pronta.** I progetti la ricevono a mano, perché la PR del gruppo `officina` di Dependabot non passa `doctor`: aspettare la settimana rimanderebbe soltanto la correzione che un progetto aspetta.
|
|
291
|
+
|
|
292
|
+
**Poi una release a settimana**, salvo una rottura che ferma un progetto. Il passaggio si decide, non scatta da solo. Le correzioni si accumuleranno su `main` per uscire insieme: ogni release è un aggiornamento da portare in ogni progetto e nel template, e il gruppo `officina` di Dependabot lo propone una volta a settimana. Rilasciare più spesso non lo fa arrivare prima, moltiplica soltanto le PR da seguire.
|
|
287
293
|
|
|
288
294
|
```sh
|
|
289
295
|
npm pkg set version=X.Y.Z
|
package/dist/bin.js
CHANGED
|
@@ -22,6 +22,7 @@ const USAGE = `Uso:
|
|
|
22
22
|
officina gen icons le icone del manifest da public/favicon.svg
|
|
23
23
|
officina doctor cosa manca al progetto perché i generatori funzionino
|
|
24
24
|
officina migrate --script <nome> le migrazioni sotto database.migrationUrlKey: da TEST_DATABASE_URL, o con --production da PRODUCTION_DATABASE_URL
|
|
25
|
+
officina scope in actions/ci, docs-only=true se la PR o il push toccano solo documenti
|
|
25
26
|
|
|
26
27
|
Opzioni dei gate sui sorgenti: --diff, --base <ref>, --head <ref>, --strict, --format text|github
|
|
27
28
|
bundle, links e secrets leggono dist/client; smoke [url] e analytics [GTM-…] vanno in rete; lighthouse [--local]
|
|
@@ -35,6 +36,7 @@ const GROUPS = {
|
|
|
35
36
|
: { load: () => import('./gen/run.js'), args },
|
|
36
37
|
doctor: (args) => ({ load: () => import('./checks/doctor.js'), args }),
|
|
37
38
|
migrate: (args) => ({ load: () => import('./checks/migrate.js'), args }),
|
|
39
|
+
scope: (args) => ({ load: () => import('./checks/scope.js'), args }),
|
|
38
40
|
};
|
|
39
41
|
export async function run(argv) {
|
|
40
42
|
const [group = '', ...rest] = argv;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// actions/ci/action.yml appende lo stdout a $GITHUB_OUTPUT: lì va solo `docs-only=…`, la ragione va su stderr.
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { ciScope } from '../lib/ci-scope.js';
|
|
5
|
+
import { changedSince, eventBase } from '../lib/git.js';
|
|
6
|
+
export function main() {
|
|
7
|
+
const { docsOnly, reason } = ciScope(eventBase(), changedSince);
|
|
8
|
+
console.error(reason);
|
|
9
|
+
console.log(`docs-only=${docsOnly}`);
|
|
10
|
+
return 0;
|
|
11
|
+
}
|
|
12
|
+
if (import.meta.main)
|
|
13
|
+
process.exit(main());
|
package/dist/checks/smoke.js
CHANGED
|
@@ -22,6 +22,9 @@ function printResults(results) {
|
|
|
22
22
|
console.error(`✗ ${check} — ${detail}`);
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
|
+
const declaredNote = (argument, { siteUrl, smoke }) => argument === undefined && smoke?.url !== undefined
|
|
26
|
+
? `\nda smoke.url, al posto di ${siteUrl}: con il dominio la voce esce`
|
|
27
|
+
: '';
|
|
25
28
|
const disabledResults = ({ disabled }) => disabled.map(({ label }) => ({ check: `pagina ${label}`, status: 'skip', detail: 'spenta da `routes.disabled`' }));
|
|
26
29
|
function printFailures(failures, database) {
|
|
27
30
|
console.error(`\n✗ ${failures.length} controllo/i falliti:`);
|
|
@@ -37,7 +40,8 @@ function printFailures(failures, database) {
|
|
|
37
40
|
export async function main(args = []) {
|
|
38
41
|
const config = await loadConfig(process.cwd());
|
|
39
42
|
const { siteUrl, smoke = {}, routes = {} } = config;
|
|
40
|
-
const [
|
|
43
|
+
const [argument] = args;
|
|
44
|
+
const url = argument ?? smoke.url;
|
|
41
45
|
if (siteUrl === undefined) {
|
|
42
46
|
console.error('\n✗ officina.config.ts non dichiara siteUrl: il controllo dell’host canonico non ha con cosa confrontarsi.\n');
|
|
43
47
|
return 1;
|
|
@@ -46,7 +50,6 @@ export async function main(args = []) {
|
|
|
46
50
|
console.error('\n✗ siteUrl è ancora il segnaposto del template: passa un URL, pnpm smoke:prod https://…\n');
|
|
47
51
|
return 1;
|
|
48
52
|
}
|
|
49
|
-
// L'apice, non l'URL *.vercel.app che stampa `vercel deploy`: lì la regola `has: host` di vercel.json mette noindex.
|
|
50
53
|
const baseUrl = (url ?? siteUrl).replace(/\/+$/, '');
|
|
51
54
|
const context = {
|
|
52
55
|
get: (target) => fetch(target, { redirect: 'manual' }),
|
|
@@ -59,7 +62,7 @@ export async function main(args = []) {
|
|
|
59
62
|
return 1;
|
|
60
63
|
const expected = expectedRoutes(readPageFiles(PAGES_DIR), PAGES_DIR, routes.disabled);
|
|
61
64
|
const pages = smokeRoutes(expected, smoke.nonHtmlRoutes, routes.representatives);
|
|
62
|
-
console.log(`\nSmoke di produzione — ${baseUrl}\n`);
|
|
65
|
+
console.log(`\nSmoke di produzione — ${baseUrl}${declaredNote(argument, config)}\n`);
|
|
63
66
|
await waitForAlias(context, (ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
64
67
|
const onDemand = onDemandPages(expected, PAGES_DIR, routes.representatives);
|
|
65
68
|
const results = await runChecks({ ...context, onDemand }, pages, smoke.checks);
|
|
@@ -69,10 +72,6 @@ export async function main(args = []) {
|
|
|
69
72
|
printFailures(failures, isOn(config, readManifest({ read: projectFile }), 'database'));
|
|
70
73
|
return 1;
|
|
71
74
|
}
|
|
72
|
-
const warnings = results.filter(({ status }) => status === 'warn').length;
|
|
73
|
-
// TODO: dalla 0.14.0 le anomalie a richiesta sono `fail` (smoke-on-demand.ts): togli il log degli avvisi qui sotto.
|
|
74
|
-
if (warnings > 0)
|
|
75
|
-
console.log(`\n· ${warnings} avviso/i: dalla 0.14.0 fanno fallire lo smoke.`);
|
|
76
75
|
console.log(`\n✓ Tutti i controlli passati su ${baseUrl}.\n`);
|
|
77
76
|
return 0;
|
|
78
77
|
}
|
package/dist/lib/alignment.js
CHANGED
|
@@ -28,17 +28,14 @@ export const CI_STEPS = [
|
|
|
28
28
|
'doctor',
|
|
29
29
|
];
|
|
30
30
|
const MANIFEST = 'package.json';
|
|
31
|
-
// TODO: dalla 0.14.0 `check:secrets` è un errore di doctor: toglilo da NEW_SCRIPTS.
|
|
32
|
-
const NEW_SCRIPTS = new Set(['check:secrets']);
|
|
33
31
|
export function scriptGaps({ scripts = {} }) {
|
|
34
32
|
const gaps = [];
|
|
35
33
|
for (const [name, expected] of Object.entries(EXPECTED_SCRIPTS)) {
|
|
36
34
|
const actual = scripts[name];
|
|
37
|
-
const severity = NEW_SCRIPTS.has(name) ? { severity: 'warning' } : {};
|
|
38
35
|
if (actual === undefined)
|
|
39
|
-
gaps.push({ path: MANIFEST, message: `script \`${name}\` assente: atteso \`${expected}
|
|
36
|
+
gaps.push({ path: MANIFEST, message: `script \`${name}\` assente: atteso \`${expected}\`` });
|
|
40
37
|
else if (!actual.startsWith(expected))
|
|
41
|
-
gaps.push({ path: MANIFEST, message: `script \`${name}\` è \`${actual}\`: atteso \`${expected}
|
|
38
|
+
gaps.push({ path: MANIFEST, message: `script \`${name}\` è \`${actual}\`: atteso \`${expected}\`` });
|
|
42
39
|
}
|
|
43
40
|
const ci = scripts.ci ?? '';
|
|
44
41
|
for (const step of CI_STEPS) {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const DOCUMENTS = [/^[^/]+\.md$/, /^(?:docs|\.github|\.claude)\/.+\.md$/];
|
|
2
|
+
export const isDocument = (path) => DOCUMENTS.some((pattern) => pattern.test(path));
|
|
3
|
+
const fullRun = (reason) => ({ docsOnly: false, reason: `percorso completo: ${reason}` });
|
|
4
|
+
export function ciScope(base, changed) {
|
|
5
|
+
if (base === undefined)
|
|
6
|
+
return fullRun("l'evento non ha una base con cui confrontarsi");
|
|
7
|
+
let paths;
|
|
8
|
+
try {
|
|
9
|
+
paths = changed(base);
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
return fullRun(`il diff da ${base} non è riuscito: ${error.message}`);
|
|
13
|
+
}
|
|
14
|
+
if (paths.length === 0)
|
|
15
|
+
return fullRun(`nessun file cambiato da ${base}`);
|
|
16
|
+
const code = paths.find((path) => !isDocument(path));
|
|
17
|
+
if (code !== undefined)
|
|
18
|
+
return fullRun(`${code} non è un documento`);
|
|
19
|
+
return { docsOnly: true, reason: `percorso veloce: solo documenti (${paths.length} file)` };
|
|
20
|
+
}
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { SmokeCheck } from './smoke-production.ts';
|
|
|
6
6
|
export type FeatureSetting = 'required' | false;
|
|
7
7
|
export type GeneratorsSetting = FeatureSetting | 'project';
|
|
8
8
|
export interface OfficinaConfig {
|
|
9
|
-
/** L'apice di produzione, senza barra finale: lo usa `check smoke` quando non riceve un URL. */
|
|
9
|
+
/** L'apice di produzione, senza barra finale: lo usa `check smoke` quando non riceve un URL e `smoke.url` manca. */
|
|
10
10
|
siteUrl?: string;
|
|
11
11
|
icons?: {
|
|
12
12
|
background: string;
|
|
@@ -16,6 +16,8 @@ export interface OfficinaConfig {
|
|
|
16
16
|
cssMaxGzip?: number;
|
|
17
17
|
};
|
|
18
18
|
smoke?: {
|
|
19
|
+
/** Origine Vercel della produzione, come `https://<progetto>.vercel.app`: lo smoke senza URL prova questa. */
|
|
20
|
+
url?: string;
|
|
19
21
|
securityHeaders?: Record<string, string | null>;
|
|
20
22
|
nonHtmlRoutes?: readonly VerifiedRoute[];
|
|
21
23
|
checks?: readonly SmokeCheck[];
|
package/dist/lib/config.js
CHANGED
|
@@ -9,7 +9,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
9
9
|
import { existsSync } from 'node:fs';
|
|
10
10
|
import { basename, join } from 'node:path';
|
|
11
11
|
import { pathToFileURL } from 'node:url';
|
|
12
|
-
import { aFunction, aNumber, anArrayOf, anEnvName, aRecordOf, aShape, aString, aStringOrNull, isRecord, oneOf, } from './validate.js';
|
|
12
|
+
import { aFunction, aNumber, anArrayOf, anEnvName, anHttpOrigin, aRecordOf, aShape, aString, aStringOrNull, isRecord, oneOf, } from './validate.js';
|
|
13
13
|
export const FEATURES = ['analytics', 'roadmap', 'generators', 'botId', 'links'];
|
|
14
14
|
/** Un controllo si spegne solo scrivendolo: il silenzio vale `'required'`. */
|
|
15
15
|
export const isRequired = (config, name) => (config.features?.[name] ?? 'required') === 'required';
|
|
@@ -40,6 +40,7 @@ const SHAPE = aShape({
|
|
|
40
40
|
icons: aShape({ background: aString }),
|
|
41
41
|
bundle: aShape({ budgets: anArrayOf(budget), cssMaxGzip: aNumber }),
|
|
42
42
|
smoke: aShape({
|
|
43
|
+
url: anHttpOrigin,
|
|
43
44
|
securityHeaders: aRecordOf(aStringOrNull),
|
|
44
45
|
nonHtmlRoutes: anArrayOf(verifiedRoute),
|
|
45
46
|
checks: anArrayOf(aFunction),
|
|
@@ -72,10 +72,8 @@ function secretGaps(source, keys) {
|
|
|
72
72
|
return message === undefined ? [] : [{ path: ASTRO_CONFIG, message }];
|
|
73
73
|
});
|
|
74
74
|
}
|
|
75
|
-
// TODO: dalla 0.14.0 i requisiti del database e dell'autenticazione sono errori di doctor: togli `severity`.
|
|
76
|
-
const asWarning = (gap) => ({ ...gap, severity: 'warning' });
|
|
77
75
|
export function databaseAuthGaps(files, manifest, on) {
|
|
78
76
|
const database = on.includes('database') ? databaseGaps(files, manifest) : [];
|
|
79
77
|
const secrets = secretGaps(files.read(ASTRO_CONFIG), on.map((name) => DEPENDENCY_FEATURES[name].secret));
|
|
80
|
-
return [...database, ...secrets]
|
|
78
|
+
return [...database, ...secrets];
|
|
81
79
|
}
|
package/dist/lib/git.d.ts
CHANGED
package/dist/lib/git.js
CHANGED
|
@@ -19,14 +19,14 @@ export const trackedAndUntracked = () => [
|
|
|
19
19
|
...gitPaths('ls-files', []),
|
|
20
20
|
...gitPaths('ls-files', ['--others', '--exclude-standard']),
|
|
21
21
|
];
|
|
22
|
-
function ensureCommit(ref) {
|
|
22
|
+
function ensureCommit(ref, { shallow = false } = {}) {
|
|
23
23
|
const exists = spawnSync('git', ['cat-file', '-e', `${ref}^{commit}`], {
|
|
24
24
|
stdio: 'ignore',
|
|
25
25
|
});
|
|
26
26
|
if (exists.status === 0)
|
|
27
27
|
return;
|
|
28
28
|
// Checkout shallow in CI: la base della PR spesso non c'è in locale.
|
|
29
|
-
git(['fetch', '--no-tags', 'origin', ref]);
|
|
29
|
+
git(['fetch', '--no-tags', ...(shallow ? ['--depth=1'] : []), 'origin', ref]);
|
|
30
30
|
}
|
|
31
31
|
function defaultBase() {
|
|
32
32
|
const originHead = git(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], {
|
|
@@ -42,14 +42,29 @@ function defaultBase() {
|
|
|
42
42
|
}
|
|
43
43
|
throw new Error('nessuna base trovata: passa --base <ref>');
|
|
44
44
|
}
|
|
45
|
-
|
|
46
|
-
function pullRequestShas() {
|
|
45
|
+
const eventPayload = (name) => {
|
|
47
46
|
const { GITHUB_EVENT_NAME, GITHUB_EVENT_PATH } = process.env;
|
|
48
|
-
if (GITHUB_EVENT_NAME !==
|
|
47
|
+
if (GITHUB_EVENT_NAME !== name || !GITHUB_EVENT_PATH || !existsSync(GITHUB_EVENT_PATH))
|
|
49
48
|
return undefined;
|
|
50
|
-
|
|
49
|
+
return JSON.parse(readFileSync(GITHUB_EVENT_PATH, 'utf8'));
|
|
50
|
+
};
|
|
51
|
+
// Sulle PR GitHub il diff va fatto tra gli SHA dell'evento, non tra branch locali.
|
|
52
|
+
function pullRequestShas() {
|
|
53
|
+
const pr = eventPayload('pull_request')?.pull_request;
|
|
51
54
|
return pr?.base?.sha && pr?.head?.sha ? { base: pr.base.sha, head: pr.head.sha } : undefined;
|
|
52
55
|
}
|
|
56
|
+
// Il primo push di un branch porta come `before` uno SHA tutto zeri, che non è un commit.
|
|
57
|
+
function pushBefore() {
|
|
58
|
+
const before = eventPayload('push')?.before;
|
|
59
|
+
return typeof before === 'string' && !/^0+$/.test(before) ? before : undefined;
|
|
60
|
+
}
|
|
61
|
+
export const eventBase = () => pullRequestShas()?.base ?? pushBefore();
|
|
62
|
+
export function changedSince(base) {
|
|
63
|
+
ensureCommit(base, { shallow: true });
|
|
64
|
+
// Con il rilevamento delle rinomine, attivo di default, `--name-only` elenca solo il percorso nuovo;
|
|
65
|
+
// `submodule.<nome>.ignore = all` in .gitmodules toglie dal diff anche un sottomodulo aggiornato.
|
|
66
|
+
return gitPaths('diff', ['--name-only', '--no-renames', '--ignore-submodules=none', base, 'HEAD']);
|
|
67
|
+
}
|
|
53
68
|
export function changedFiles({ base, head }) {
|
|
54
69
|
const pr = base ? undefined : pullRequestShas();
|
|
55
70
|
const from = base ?? pr?.base ?? defaultBase();
|
|
@@ -18,16 +18,15 @@ const attributeValue = (tag, attribute) => {
|
|
|
18
18
|
};
|
|
19
19
|
const hasCspMeta = (html) => [...html.matchAll(META)].some(([tag]) => attributeValue(tag, HTTP_EQUIV).toLowerCase() === 'content-security-policy' &&
|
|
20
20
|
SCRIPT_SRC.test(attributeValue(tag, CONTENT)));
|
|
21
|
-
|
|
22
|
-
const warn = (check, detail) => ({ check, status: 'warn', detail });
|
|
21
|
+
const fail = (check, detail) => ({ check, status: 'fail', detail });
|
|
23
22
|
function redirect(check, response, url) {
|
|
24
23
|
const location = response.headers.get('location');
|
|
25
24
|
if (location === null)
|
|
26
|
-
return
|
|
25
|
+
return fail(check, `risponde ${response.status} senza location`);
|
|
27
26
|
const target = new URL(location, url);
|
|
28
27
|
const { origin } = new URL(url);
|
|
29
28
|
if (target.origin !== origin)
|
|
30
|
-
return
|
|
29
|
+
return fail(check, `rimanda a ${target.href}, fuori da ${origin}`);
|
|
31
30
|
return { check, status: 'pass' };
|
|
32
31
|
}
|
|
33
32
|
async function visit({ get, baseUrl }, path) {
|
|
@@ -38,17 +37,17 @@ async function visit({ get, baseUrl }, path) {
|
|
|
38
37
|
if (response.status >= 300 && response.status < 400)
|
|
39
38
|
return redirect(check, response, url);
|
|
40
39
|
if (response.status !== 200) {
|
|
41
|
-
return
|
|
40
|
+
return fail(check, `atteso 200 o un redirect sulla stessa origine, ricevuto ${response.status}`);
|
|
42
41
|
}
|
|
43
42
|
const contentType = response.headers.get('content-type') ?? '';
|
|
44
43
|
if (!contentType.includes('text/html'))
|
|
45
|
-
return
|
|
44
|
+
return fail(check, `atteso un content-type text/html, ricevuto "${contentType}"`);
|
|
46
45
|
if (!hasCspMeta((await response.text?.()) ?? ''))
|
|
47
|
-
return
|
|
46
|
+
return fail(check, 'manca il meta Content-Security-Policy con script-src');
|
|
48
47
|
return { check, status: 'pass' };
|
|
49
48
|
}
|
|
50
49
|
catch (error) {
|
|
51
|
-
return
|
|
50
|
+
return fail(check, error instanceof Error ? error.message : String(error));
|
|
52
51
|
}
|
|
53
52
|
}
|
|
54
53
|
export async function checkOnDemandPages(context) {
|
|
@@ -29,7 +29,7 @@ export declare const BOTID_CHALLENGE = "/149e9513-01fa-4fb0-aad4-566afd725d1b/2d
|
|
|
29
29
|
/** L'alias di produzione di Vercel impiega un attimo a puntare al deployment appena caricato. */
|
|
30
30
|
export declare function waitForAlias({ get, baseUrl }: SmokeContext, sleep: (ms: number) => Promise<void>, attempts?: number): Promise<void>;
|
|
31
31
|
export declare function checkPages({ get, baseUrl }: SmokeContext, pages: readonly VerifiedRoute[]): Promise<CheckResult[]>;
|
|
32
|
-
export declare function checkSecurityHeaders(
|
|
32
|
+
export declare function checkSecurityHeaders(context: SmokeContext): Promise<CheckResult[]>;
|
|
33
33
|
export declare function checkBotIdChallenge({ get, baseUrl, botIdRequired }: SmokeContext): Promise<CheckResult[]>;
|
|
34
34
|
/** Il 308 da www all'apice, da vercel.json: dipende dal DNS e dal dominio del progetto Vercel, non dal deployment. */
|
|
35
35
|
export declare function checkCanonicalHost({ get, baseUrl, siteUrl }: SmokeContext): Promise<CheckResult[]>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { checkOnDemandPages } from './smoke-on-demand.js';
|
|
2
|
+
import { checkRobotsTag } from './smoke-robots.js';
|
|
2
3
|
const pass = (check) => ({ check, status: 'pass' });
|
|
3
4
|
const fail = (check, detail) => ({ check, status: 'fail', detail });
|
|
4
5
|
const skip = (check, detail) => ({ check, status: 'skip', detail });
|
|
@@ -68,7 +69,8 @@ export async function checkPages({ get, baseUrl }, pages) {
|
|
|
68
69
|
}
|
|
69
70
|
return results;
|
|
70
71
|
}
|
|
71
|
-
export async function checkSecurityHeaders(
|
|
72
|
+
export async function checkSecurityHeaders(context) {
|
|
73
|
+
const { get, baseUrl, securityHeaders } = context;
|
|
72
74
|
let landing;
|
|
73
75
|
try {
|
|
74
76
|
landing = await landingPage(get, baseUrl);
|
|
@@ -87,10 +89,7 @@ export async function checkSecurityHeaders({ get, baseUrl, securityHeaders }) {
|
|
|
87
89
|
return fail(check, `atteso "${expected}", ricevuto "${value}"`);
|
|
88
90
|
return pass(check);
|
|
89
91
|
});
|
|
90
|
-
|
|
91
|
-
const check = 'nessun x-robots-tag sull’host di produzione';
|
|
92
|
-
const robots = response.headers.get('x-robots-tag');
|
|
93
|
-
results.push(robots === null ? pass(check) : fail(check, `presente su ${url}: "${robots}"`));
|
|
92
|
+
results.push(checkRobotsTag(context, url, response.headers.get('x-robots-tag')));
|
|
94
93
|
return results;
|
|
95
94
|
}
|
|
96
95
|
export async function checkBotIdChallenge({ get, baseUrl, botIdRequired }) {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// La regola `has: host` del vercel.json dei progetti mette noindex su ogni `*.vercel.app`:
|
|
2
|
+
// lì deve esserci, e sul dominio vero toglierebbe il sito dagli indici senza nessun errore.
|
|
3
|
+
const onVercel = ({ baseUrl, siteUrl }) => {
|
|
4
|
+
const { hostname } = new URL(baseUrl);
|
|
5
|
+
return hostname !== URL.parse(siteUrl)?.hostname && hostname.endsWith('.vercel.app');
|
|
6
|
+
};
|
|
7
|
+
const OUT_OF_INDEX = new Set(['noindex', 'none']);
|
|
8
|
+
const keepsOutOfIndex = (robots) => robots.split(',').some((directive) => OUT_OF_INDEX.has(directive.trim().toLowerCase()));
|
|
9
|
+
export function checkRobotsTag(addresses, url, robots) {
|
|
10
|
+
if (!onVercel(addresses)) {
|
|
11
|
+
const check = 'nessun x-robots-tag sull’host di produzione';
|
|
12
|
+
if (robots === null)
|
|
13
|
+
return { check, status: 'pass' };
|
|
14
|
+
return { check, status: 'fail', detail: `presente su ${url}: "${robots}"` };
|
|
15
|
+
}
|
|
16
|
+
const check = 'x-robots-tag noindex sull’indirizzo di Vercel';
|
|
17
|
+
if (robots === null)
|
|
18
|
+
return { check, status: 'fail', detail: `assente su ${url}` };
|
|
19
|
+
if (keepsOutOfIndex(robots))
|
|
20
|
+
return { check, status: 'pass' };
|
|
21
|
+
return { check, status: 'fail', detail: `senza noindex su ${url}: "${robots}"` };
|
|
22
|
+
}
|
package/dist/lib/validate.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export declare const aNumber: Check;
|
|
|
7
7
|
export declare const aFunction: Check;
|
|
8
8
|
export declare const aStringOrNull: Check;
|
|
9
9
|
export declare const anEnvName: Check;
|
|
10
|
+
export declare const anHttpOrigin: Check;
|
|
10
11
|
export declare const anArrayOf: (item: Check) => Check;
|
|
11
12
|
export declare const aRecordOf: (item: Check) => Check;
|
|
12
13
|
export declare const oneOf: (expected: string, allowed: readonly unknown[]) => Check;
|
package/dist/lib/validate.js
CHANGED
|
@@ -26,6 +26,15 @@ export const anEnvName = (value, path) => {
|
|
|
26
26
|
return [];
|
|
27
27
|
return [`${path}: atteso il nome di una variabile d'ambiente, ricevuto ${JSON.stringify(value)}`];
|
|
28
28
|
};
|
|
29
|
+
const HTTP = new Set(['http:', 'https:']);
|
|
30
|
+
export const anHttpOrigin = (value, path) => {
|
|
31
|
+
if (typeof value !== 'string')
|
|
32
|
+
return [problem(path, 'una stringa', value)];
|
|
33
|
+
const url = URL.parse(value);
|
|
34
|
+
if (url !== null && HTTP.has(url.protocol) && url.href === `${url.origin}/`)
|
|
35
|
+
return [];
|
|
36
|
+
return [`${path}: atteso un'origine http o https, senza percorso né query, ricevuto ${JSON.stringify(value)}`];
|
|
37
|
+
};
|
|
29
38
|
export const anArrayOf = (item) => (value, path) => Array.isArray(value)
|
|
30
39
|
? value.flatMap((entry, index) => item(entry, `${path}[${index}]`))
|
|
31
40
|
: [problem(path, 'un array', value)];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elia97/officina",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Gli strumenti dei progetti nati dai template di Elia: gate di qualità, generatori e verifiche di build e di produzione.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"test:pack": "bash scripts/pack-test.sh"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
|
-
"@biomejs/biome": "2.5.
|
|
58
|
+
"@biomejs/biome": "2.5.15",
|
|
59
59
|
"@types/node": "^26.5.1",
|
|
60
60
|
"@vitest/coverage-v8": "^4.1.11",
|
|
61
61
|
"fallow": "^3.26.0",
|