@elia97/officina 0.12.0 → 0.14.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 +17 -5
- package/dist/bin.js +2 -0
- package/dist/checks/bundle.js +13 -2
- package/dist/checks/scope.d.ts +2 -0
- package/dist/checks/scope.js +13 -0
- package/dist/checks/smoke.js +0 -4
- package/dist/lib/alignment.js +2 -5
- package/dist/lib/bundle-css.d.ts +7 -0
- package/dist/lib/bundle-css.js +19 -0
- package/dist/lib/ci-scope.d.ts +6 -0
- package/dist/lib/ci-scope.js +20 -0
- 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/package.json +2 -2
package/README.md
CHANGED
|
@@ -46,7 +46,7 @@ I gate leggono dalla cartella corrente, che deve essere la radice del repository
|
|
|
46
46
|
|
|
47
47
|
| Comando | Cosa guarda | Da dove prende le rotte |
|
|
48
48
|
|---|---|---|
|
|
49
|
-
| `check bundle` | il JavaScript e il CSS di `dist/client`, gzip, contro un budget per rotta | `src/pages`; sotto SSR, il manifest della build Vercel |
|
|
49
|
+
| `check bundle` | il JavaScript e il CSS di `dist/client`, gzip, contro un budget per rotta; nel CSS, le media query in sintassi di intervallo | `src/pages`; sotto SSR, il manifest della build Vercel |
|
|
50
50
|
| `check links` | gli `<a href>` di `dist/client`: ogni link interno porta a qualcosa che la build serve, e nessuno a `#` | i file di `dist/client` e le rotte a richiesta del manifest della build Vercel |
|
|
51
51
|
| `check secrets [--canaries]` | i file di `dist/client`: nessuno contiene il valore o il canary di una chiave server e secret di `env.schema` | — |
|
|
52
52
|
| `check smoke [url]` | la produzione viva: pagine, pagine rese a richiesta, header di sicurezza, BotID, host canonico, barra finale | `src/pages`, più le rotte non HTML |
|
|
@@ -58,6 +58,16 @@ Gli script di una pagina statica si leggono dal suo HTML, anche sotto una `base`
|
|
|
58
58
|
|
|
59
59
|
Il CSS di una rotta è la somma dei fogli di `dist/client/_astro` che collega, ognuno contato una volta: per una pagina statica i `<link rel="stylesheet">` del suo HTML, anche sotto una `base`, da un `assetsPrefix` o con una query; per una rotta renderizzata a richiesta i fogli esterni che il manifest le assegna, e una rotta per cui il manifest non li dice è un fallimento. Il tetto è `bundle.cssMaxGzip`, e il rapporto ha una riga per ogni combinazione di fogli, perché le rotte che collegano gli stessi fogli pesano uguale. Non si contano il CSS dentro l'HTML, nei `<style>`, come il JavaScript incorporato, i link che non bloccano il rendering (`preload`, `prefetch`) e i fogli fuori da `_astro`, da `public/` o da un CDN. Un foglio citato che in `_astro` non c'è è un fallimento. Un foglio che nessuna rotta misurata collega si pesa da solo contro lo stesso tetto, e nel rapporto porta «da solo»: il CSS di un `import()` dinamico, quello dei componenti dentro un contenuto MDX reso a richiesta, che il manifest non assegna alla pagina, o tutti i fogli di una build le cui rotte non si leggono. Da solo pesa meno del vero, ma nessun foglio oltre il tetto passa in silenzio.
|
|
60
60
|
|
|
61
|
+
Il CSS costruito non deve contenere media query in sintassi di intervallo, come `@media (width>=40rem)`. Safari le legge solo dalla 16.4, e un Safari più vecchio ignora la regola intera: un sito scritto mobile-first si impagina come su un telefono. Le lascia Lightning CSS, che minifica il CSS della build, quando non sa per quali browser scrive: Astro 7 costruisce con `build.target: 'esnext'`, Vite 8 ne ricava `cssTarget`, e il minificatore non abbassa la sintassi e toglie anche i prefissi, come `-webkit-backdrop-filter`. Nessun browser recente mostra il difetto, e `astro dev` non minifica: si vede solo nel CSS costruito. `check bundle` legge i fogli di `dist/client/_astro` e i `<style>` dell'HTML delle pagine, dove Astro mette i fogli piccoli, e fallisce su ogni media query con un confronto nel preludio, `<`, `>` o `=`, i segni che soltanto la sintassi di intervallo usa; i commenti non contano, e il rapporto nomina il file e le query. Restano fuori le query di `@container`, i fogli fuori da `_astro`, i `<style>` delle pagine rese a richiesta, che nella build non hanno HTML, e i prefissi tolti, che il controllo non vede.
|
|
62
|
+
|
|
63
|
+
Il controllo non si spegne, e fallisce dalla versione che lo porta, senza un giro di avvisi: un progetto senza `cssTarget` ha già il difetto, e la correzione è una riga in `astro.config.mjs`, con i browser che il progetto sostiene. `min-width` e `max-width` sono la stessa query in ogni browser, quindi abbassare non costa niente.
|
|
64
|
+
|
|
65
|
+
```js
|
|
66
|
+
export default defineConfig({
|
|
67
|
+
vite: { build: { cssTarget: ['chrome111', 'edge111', 'firefox114', 'safari15.4', 'ios15.4'] } },
|
|
68
|
+
})
|
|
69
|
+
```
|
|
70
|
+
|
|
61
71
|
`check links` legge gli `<a href>` di ogni HTML di `dist/client`, fuori dai commenti, dagli `<script>`, dagli `<style>` e dai valori degli attributi, dove Astro lascia `<` e `>` come sono. È interno un link relativo, uno che comincia con `/` o uno assoluto verso l'origine di `siteUrl`, e se ne confronta il percorso, senza query né ancora, con o senza barra finale; un link relativo si risolve come con `trailingSlash: 'never'`, che è quello dei template. Vale se porta a un file della build, pagina o altro file, o a una rotta che il manifest della build Vercel rende a richiesta, endpoint e `/` di lingua compresi, confrontata con il pattern che Astro scrive lì; senza manifest, o con un altro adapter, a una pagina `.astro` con `prerender = false`, e il rapporto lo dice. Che la pagina dietro una rotta a richiesta esista lo sa solo il server, e una rotta come `/[...slug]` accetta ogni percorso: il rapporto conta i link che passano così. `href="#"`, `href=""` e un `href` senza valore sono segnaposto, e falliscono come un link rotto. Il rapporto raggruppa per destinazione e nomina le pagine che la citano: un link rotto nel footer è un problema solo, non uno per pagina. Restano fuori, e il rapporto li conta, i link esterni, che chiederebbero la rete, `mailto:`, `tel:` e gli altri schemi, e l'id a cui punta un'ancora. Una `dist/client` senza HTML è un fallimento, perché il controllo non affermerebbe niente: un progetto che rende ogni pagina a richiesta lo spegne con `features.links: false`. Due limiti: con una `base` i link non si risolvono, e un redirect di `vercel.json` non è una pagina, quindi un link che ci porta passa per rotto, e si corregge puntando alla destinazione.
|
|
62
72
|
|
|
63
73
|
`check secrets` legge le chiavi di `env.schema` con `context: 'server'` e `access: 'secret'`, dal sorgente di `astro.config.mjs` come `doctor`, e le cerca in ogni file di `dist/client`: un import sbagliato basta a portare un segreto nel browser. Per ogni chiave cerca il valore che ha nell'ambiente, anche nelle forme in cui Astro lo scrive nel markup e negli script, e il suo canary, `officina-<CHIAVE>-canary`, che `--canaries` stampa come righe `CHIAVE=valore` da mettere nell'ambiente della build. `actions/ci` lo fa dopo `pnpm run ci`, così i test che `ci` lancia non vedono i canary e la build sì. Il canary si dà solo ai campi `envField.string` senza validazioni (`max`, `min`, `length`, `url`, `includes`, `startsWith`, `endsWith`), perché Astro valida ogni variabile sul suo tipo e su quelle, e un canary che non le rispetta fermerebbe la build: una chiave così, in CI, resta da verificare. Una chiave che compare in un file è un fallimento che nomina la chiave e il file. Una chiave senza valore né canary nell'ambiente, o con un valore di meno di 8 caratteri, che cercato darebbe falsi positivi, non si verifica: il rapporto la dice con un avviso, invece di contarla fra le verificate. Uno schema senza chiavi segrete non ha niente da verificare, e una `dist/client` vuota è un fallimento.
|
|
@@ -66,7 +76,7 @@ Per Lighthouse un sito SSR si misura con `check lighthouse <url>`, sulla produzi
|
|
|
66
76
|
|
|
67
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.
|
|
68
78
|
|
|
69
|
-
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.
|
|
79
|
+
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.
|
|
70
80
|
|
|
71
81
|
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.
|
|
72
82
|
|
|
@@ -142,7 +152,7 @@ I passi dei workflow stanno in `actions/` di questo repository, come composite a
|
|
|
142
152
|
|
|
143
153
|
| Action | Passi | Cosa resta nel workflow del progetto |
|
|
144
154
|
|---|---|---|
|
|
145
|
-
| `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:` |
|
|
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; 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:` |
|
|
146
156
|
| `actions/review` | `astro sync`, poi `fallow review` sul diff contro il merge-base | il job informativo |
|
|
147
157
|
| `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:` |
|
|
148
158
|
| `actions/lighthouse` | build equivalente alla produzione e `pnpm run lhci` | trigger, etichetta, `continue-on-error` |
|
|
@@ -173,6 +183,8 @@ jobs:
|
|
|
173
183
|
|
|
174
184
|
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.
|
|
175
185
|
|
|
186
|
+
**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`.
|
|
187
|
+
|
|
176
188
|
**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.
|
|
177
189
|
|
|
178
190
|
**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.
|
|
@@ -207,7 +219,7 @@ I tre segreti sono input obbligatori e l'action li mette nell'`env` dei soli tre
|
|
|
207
219
|
api_key: ${{ secrets.NEON_API_KEY }}
|
|
208
220
|
```
|
|
209
221
|
|
|
210
|
-
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`
|
|
222
|
+
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`.
|
|
211
223
|
|
|
212
224
|
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.
|
|
213
225
|
|
|
@@ -220,7 +232,7 @@ Prima di `pnpm run ci` l'action lancia `officina migrate --script <migrate>`, ch
|
|
|
220
232
|
database-url: ${{ secrets.PRODUCTION_DATABASE_URL }}
|
|
221
233
|
```
|
|
222
234
|
|
|
223
|
-
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`
|
|
235
|
+
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.
|
|
224
236
|
|
|
225
237
|
**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.
|
|
226
238
|
|
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;
|
package/dist/checks/bundle.js
CHANGED
|
@@ -5,7 +5,7 @@ import { join, relative } from 'node:path';
|
|
|
5
5
|
import process from 'node:process';
|
|
6
6
|
import { gzipSync } from 'node:zlib';
|
|
7
7
|
import { budgetFor, CSS_BUDGET_GZIP, deferredClosure, htmlEntries, htmlStylesheets, parseEdges, staticClosure, } from '../lib/bundle-budget.js';
|
|
8
|
-
import { measureCss, routeCount } from '../lib/bundle-css.js';
|
|
8
|
+
import { inlineStyles, measureCss, rangeQueryFailures, routeCount } from '../lib/bundle-css.js';
|
|
9
9
|
import { readSsr } from '../lib/bundle-ssr.js';
|
|
10
10
|
import { missingInput } from '../lib/cli.js';
|
|
11
11
|
import { loadConfig } from '../lib/config.js';
|
|
@@ -89,6 +89,17 @@ function checkCss(pages, cssMaxGzip, width) {
|
|
|
89
89
|
printCss(css, cssMaxGzip, width);
|
|
90
90
|
return css.failures;
|
|
91
91
|
}
|
|
92
|
+
function checkRangeSyntax() {
|
|
93
|
+
const sheets = filesWithExtension(ASSETS, '.css').map((file) => ({ file, css: readFileSync(file, 'utf8') }));
|
|
94
|
+
const pages = filesWithExtension(DIST, '.html').map((file) => ({
|
|
95
|
+
file,
|
|
96
|
+
css: inlineStyles(readFileSync(file, 'utf8')),
|
|
97
|
+
}));
|
|
98
|
+
const failures = rangeQueryFailures([...sheets, ...pages]);
|
|
99
|
+
if (failures.length === 0)
|
|
100
|
+
console.log('\nMedia query in sintassi di intervallo: nessuna.');
|
|
101
|
+
return failures;
|
|
102
|
+
}
|
|
92
103
|
function printSsrNote(expected, ssr) {
|
|
93
104
|
for (const note of ssr.notes)
|
|
94
105
|
console.log(`\nNOTA ${note}`);
|
|
@@ -124,7 +135,7 @@ export async function main() {
|
|
|
124
135
|
failures.push(...ssr.failures, ...unknownChunkFailures(pages), ...overBudgetFailures(pages));
|
|
125
136
|
const width = Math.max('ROTTA'.length, ...pages.map((p) => p.route.length));
|
|
126
137
|
printPages(pages, width);
|
|
127
|
-
failures.push(...checkCss(routes, cssMaxGzip, width));
|
|
138
|
+
failures.push(...checkCss(routes, cssMaxGzip, width), ...checkRangeSyntax());
|
|
128
139
|
printSsrNote(expected, ssr);
|
|
129
140
|
printDisabled(expected);
|
|
130
141
|
if (failures.length > 0) {
|
|
@@ -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
|
@@ -69,10 +69,6 @@ export async function main(args = []) {
|
|
|
69
69
|
printFailures(failures, isOn(config, readManifest({ read: projectFile }), 'database'));
|
|
70
70
|
return 1;
|
|
71
71
|
}
|
|
72
|
-
const warnings = results.filter(({ status }) => status === 'warn').length;
|
|
73
|
-
// TODO: dalla 0.13.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.13.0 fanno fallire lo smoke.`);
|
|
76
72
|
console.log(`\n✓ Tutti i controlli passati su ${baseUrl}.\n`);
|
|
77
73
|
return 0;
|
|
78
74
|
}
|
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.13.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) {
|
package/dist/lib/bundle-css.d.ts
CHANGED
|
@@ -12,5 +12,12 @@ export type CssMeasure = {
|
|
|
12
12
|
groups: CssGroup[];
|
|
13
13
|
failures: string[];
|
|
14
14
|
};
|
|
15
|
+
export type CssSource = {
|
|
16
|
+
file: string;
|
|
17
|
+
css: string;
|
|
18
|
+
};
|
|
15
19
|
export declare const routeCount: (count: number) => string;
|
|
16
20
|
export declare function measureCss(routes: readonly RouteStylesheets[], sheets: readonly Stylesheet[], maxGzip: number, assets: string): CssMeasure;
|
|
21
|
+
export declare function rangeMediaQueries(css: string): string[];
|
|
22
|
+
export declare const inlineStyles: (html: string) => string;
|
|
23
|
+
export declare function rangeQueryFailures(sources: readonly CssSource[]): string[];
|
package/dist/lib/bundle-css.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
const CSS_COMMENT = /\/\*[\s\S]*?\*\//g;
|
|
2
|
+
const MEDIA_PRELUDE = /(?<=@media\b)[^{;]*/gi;
|
|
3
|
+
const RANGE_COMPARISON = /[<>=]/;
|
|
4
|
+
const STYLE_ELEMENT = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
|
|
1
5
|
const kb = (bytes) => `${(bytes / 1024).toFixed(1)} KB`;
|
|
2
6
|
export const routeCount = (count) => (count === 1 ? '1 rotta' : `${count} rotte`);
|
|
3
7
|
function overBudget({ stylesheets, gzip, routes }, maxGzip) {
|
|
@@ -31,3 +35,18 @@ export function measureCss(routes, sheets, maxGzip, assets) {
|
|
|
31
35
|
failures.push(...measured.filter((group) => group.gzip > maxGzip).map((group) => overBudget(group, maxGzip)));
|
|
32
36
|
return { groups: measured, failures };
|
|
33
37
|
}
|
|
38
|
+
export function rangeMediaQueries(css) {
|
|
39
|
+
const preludes = [...css.replace(CSS_COMMENT, ' ').matchAll(MEDIA_PRELUDE)].map(([prelude]) => prelude.trim());
|
|
40
|
+
return [...new Set(preludes.filter((prelude) => RANGE_COMPARISON.test(prelude)))];
|
|
41
|
+
}
|
|
42
|
+
export const inlineStyles = (html) => [...html.matchAll(STYLE_ELEMENT)].map(([, css]) => css).join('\n');
|
|
43
|
+
export function rangeQueryFailures(sources) {
|
|
44
|
+
return sources.flatMap(({ file, css }) => {
|
|
45
|
+
const queries = rangeMediaQueries(css);
|
|
46
|
+
if (queries.length === 0)
|
|
47
|
+
return [];
|
|
48
|
+
return [
|
|
49
|
+
`${file}: media query in sintassi di intervallo, che Safari legge solo dalla 16.4 — ${queries.join(', ')}; si abbassano con \`vite.build.cssTarget\``,
|
|
50
|
+
];
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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.13.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) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elia97/officina",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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",
|