@elia97/officina 0.11.0 → 0.12.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 +81 -11
- package/dist/bin.js +4 -1
- package/dist/checks/doctor.js +13 -10
- package/dist/checks/migrate.d.ts +2 -0
- package/dist/checks/migrate.js +44 -0
- package/dist/checks/placeholders.js +15 -6
- package/dist/checks/secrets.d.ts +2 -0
- package/dist/checks/secrets.js +46 -0
- package/dist/checks/smoke.js +20 -4
- package/dist/index.d.ts +1 -0
- package/dist/lib/alignment.d.ts +1 -0
- package/dist/lib/alignment.js +4 -2
- package/dist/lib/check-placeholders.d.ts +3 -1
- package/dist/lib/check-placeholders.js +9 -3
- package/dist/lib/check-routes.js +5 -3
- package/dist/lib/cli.d.ts +1 -0
- package/dist/lib/cli.js +1 -0
- package/dist/lib/config-gaps.js +1 -6
- package/dist/lib/config.d.ts +26 -1
- package/dist/lib/config.js +28 -2
- package/dist/lib/database-auth.d.ts +5 -0
- package/dist/lib/database-auth.js +81 -0
- package/dist/lib/env-schema.d.ts +8 -0
- package/dist/lib/env-schema.js +37 -0
- package/dist/lib/migrate.d.ts +11 -0
- package/dist/lib/migrate.js +29 -0
- package/dist/lib/routes.js +5 -3
- package/dist/lib/secrets.d.ts +10 -0
- package/dist/lib/secrets.js +53 -0
- package/dist/lib/smoke-on-demand.d.ts +8 -0
- package/dist/lib/smoke-on-demand.js +65 -0
- package/dist/lib/smoke-production.d.ts +4 -1
- package/dist/lib/smoke-production.js +2 -0
- package/dist/lib/validate.d.ts +2 -0
- package/dist/lib/validate.js +9 -1
- package/dist/lib/vercel-build.js +0 -1
- package/dist/lib/workflows.d.ts +14 -0
- package/dist/lib/workflows.js +44 -3
- package/dist/smoke.d.ts +1 -0
- package/dist/smoke.js +1 -0
- package/package.json +1 -1
- package/presets/biome.base.json +2 -1
package/README.md
CHANGED
|
@@ -20,6 +20,7 @@ pnpm add -D @elia97/officina
|
|
|
20
20
|
"check:placeholders": "officina check placeholders",
|
|
21
21
|
"perf:bundle": "officina check bundle",
|
|
22
22
|
"check:links": "officina check links",
|
|
23
|
+
"check:secrets": "officina check secrets",
|
|
23
24
|
"smoke:prod": "officina check smoke",
|
|
24
25
|
"analytics:verify": "officina check analytics",
|
|
25
26
|
"lhci": "officina check lighthouse",
|
|
@@ -39,7 +40,7 @@ I gate leggono dalla cartella corrente, che deve essere la radice del repository
|
|
|
39
40
|
|
|
40
41
|
`check language` vuole in italiano i commenti del codice e i Markdown di documentazione. Lascia fuori per intero ciò che non è documentazione del progetto: i dizionari di `src/i18n/strings/` e i Markdown sotto `src/content/`, che sono copy nelle lingue del sito; le fonti del cliente in `docs/sources/`; i `CHANGELOG.md`, generati in inglese. Un file YAML di `src/content/` invece è codice, e i suoi commenti si controllano come altrove.
|
|
41
42
|
|
|
42
|
-
`check routes` segue i rimandi dei Markdown del progetto: i percorsi fra apici inversi dentro `docs`, `src`, `scripts`, `test`, `e2e`, `public`, `.claude` e `.github`; i rimandi a una sezione, nella forma `file.md` § Titolo; i link e le immagini con una destinazione relativa, anche a riferimento e verso una cartella; e due tipi di nomi senza cartella, i documenti in maiuscolo come `HOW_TO_USE.md`, cercati accanto a chi cita, alla radice e in `docs/`, e i file di configurazione che vetrina tiene alla radice, come `vercel.json
|
|
43
|
+
`check routes` segue i rimandi dei Markdown del progetto: i percorsi fra apici inversi dentro `docs`, `src`, `scripts`, `test`, `e2e`, `public`, `drizzle`, `.claude` e `.github`; i rimandi a una sezione, nella forma `file.md` § Titolo; i link e le immagini con una destinazione relativa, anche a riferimento e verso una cartella; e due tipi di nomi senza cartella, i documenti in maiuscolo come `HOW_TO_USE.md`, cercati accanto a chi cita, alla radice e in `docs/`, e i file di configurazione che vetrina tiene alla radice, come `vercel.json`, più `drizzle.config.ts` dei progetti con un database. Un link che esce dal repository è un rimando morto, perché in CI la cartella sopra non c'è. Restano fuori gli altri nomi senza cartella, come `head.astro`, gli URL, le ancore, il codice negli span e nei blocchi recintati, un nome che fa da testo a un link, dove conta la destinazione, i link e i nomi dei Markdown di `src/content/`, che sono copy del sito, e i nomi citati in `docs/ROADMAP.md` e nei `CHANGELOG.md`, che raccontano documenti usciti o da scrivere. Un blocco rientrato di quattro spazi non si riconosce come codice: un esempio va in un blocco recintato.
|
|
43
44
|
|
|
44
45
|
## Verifiche di build e di produzione
|
|
45
46
|
|
|
@@ -47,7 +48,8 @@ I gate leggono dalla cartella corrente, che deve essere la radice del repository
|
|
|
47
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
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 |
|
|
50
|
-
| `check
|
|
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
|
+
| `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 |
|
|
51
53
|
| `check analytics [GTM-…]` | il container GTM pubblico contro gli eventi del modulo di link-tracking del progetto (`analytics.linkTracking`) | — |
|
|
52
54
|
| `check lighthouse [url] [--local]` | Lighthouse CI sul `.lighthouserc.json` del progetto; con `url` misura un sito già servito, `--local` fa build, server statico e Chrome da sé | `src/pages` |
|
|
53
55
|
| `gen icons` | le icone del manifest, disegnate da `public/favicon.svg`, che è il suo ingresso obbligatorio | — |
|
|
@@ -58,10 +60,16 @@ Il CSS di una rotta è la somma dei fogli di `dist/client/_astro` che collega, o
|
|
|
58
60
|
|
|
59
61
|
`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.
|
|
60
62
|
|
|
63
|
+
`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.
|
|
64
|
+
|
|
61
65
|
Per Lighthouse un sito SSR si misura con `check lighthouse <url>`, sulla produzione o su un'anteprima: al posto del server del progetto visita quell'indirizzo, ed è l'unico modo di avere numeri di produzione. `--local` serve i file statici della build, quindi su un sito renderizzato a richiesta si ferma e lo dice, invece di misurare dei 404. Un'anteprima Vercel con la Deployment Protection risponde con la pagina di accesso, e il check non la può misurare: serve la produzione o un'anteprima non protetta. La porta del server locale segue `LH_PORT` sia con `--listen` sia con `--port`.
|
|
62
66
|
|
|
63
67
|
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.
|
|
64
68
|
|
|
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. Nella 0.12.0 un'anomalia è un avviso, che lo smoke stampa con `·` senza fallire; dalla 0.13.0 sarà un fallimento. 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
|
+
|
|
71
|
+
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
|
+
|
|
65
73
|
Il motore è uguale per tutti; ciò che cambia da un progetto all'altro sta in un file solo, `officina.config.ts` nella radice:
|
|
66
74
|
|
|
67
75
|
```ts
|
|
@@ -87,7 +95,7 @@ La radice del pacchetto esporta `defineConfig` e i tipi; i pezzi del motore stan
|
|
|
87
95
|
|
|
88
96
|
| Sottopercorso | Cosa esporta |
|
|
89
97
|
|---|---|
|
|
90
|
-
| `@elia97/officina/smoke` | `DEFAULT_CHECKS`, i
|
|
98
|
+
| `@elia97/officina/smoke` | `DEFAULT_CHECKS`, i sei controlli, `SECURITY_HEADERS`, `NON_HTML_ROUTES` |
|
|
91
99
|
| `@elia97/officina/bundle` | `CSS_BUDGET_GZIP` |
|
|
92
100
|
| `@elia97/officina/icons` | `ICON_SPECS` |
|
|
93
101
|
|
|
@@ -99,9 +107,11 @@ La radice del pacchetto esporta `defineConfig` e i tipi; i pezzi del motore stan
|
|
|
99
107
|
|
|
100
108
|
`features.generators` ha un valore in più delle altre, perché non tutti i siti in produzione nascono dallo scaffold: `'required'` è il comportamento di sempre; `false` spegne i generatori del pacchetto, e `doctor` smette di chiedere i punti di aggancio e gli ancoraggi invece di pretendere file che nessun codice di quel sito userebbe; `'project'` fa lo stesso ma lascia vivi i generatori che il progetto scrive in `officina.generators.mjs`, che a quel punto `doctor` pretende. Con `false` o `'project'`, `officina gen section|page|component|collection` si ferma dicendolo, mentre gli script `gen` e `gen:*` restano attesi in `package.json`: meglio un comando che spiega di uno script che manca. `gen icons` non c'entra con i generatori di codice e non cambia.
|
|
101
109
|
|
|
102
|
-
`
|
|
110
|
+
`features.database` e `features.auth` sono le due voci che il silenzio non accende, perché la maggior parte dei progetti un database non lo ha: seguono le dipendenze. `database` è accesa quando `package.json` dipende da `drizzle-orm`, `auth` quando dipende da `better-auth`, fra le `dependencies` o le `devDependencies`; senza la libreria la voce non si chiede e vale spenta. Dichiarata, vale la dichiarazione: `false` spegne la feature anche con la libreria, e `doctor` lo scrive nell'uscita; `'required'` la accende anche senza. `database.migrationUrlKey` è la variabile con cui girano le migrazioni, `DATABASE_URL_UNPOOLED` se non è dichiarata, e deve essere il nome di una variabile d'ambiente.
|
|
111
|
+
|
|
112
|
+
`placeholders` dice a `check placeholders` cosa guardare: `sources` sono i file da scandire, `contactEnvKeys` le variabili che `--env` pretende. Una lista dichiarata **sostituisce** quella del pacchetto, dizionari compresi: senza dichiarazione i sorgenti sono `src/lib/site.ts`, `src/lib/company.ts` e i dizionari di `src/i18n/strings`, quando la cartella esiste. Serve ai progetti che non nascono dallo scaffold: se partita IVA, recapiti e destinatario dei form stanno nel contenuto invece che in un modulo, i sorgenti del template non sono i suoi, e crearli per far passare il gate terrebbe gli stessi dati in due sedi. Un sorgente elencato che non esiste è un ritrovamento con il suo percorso, non un errore che ferma il comando. Con il database acceso `--env` pretende anche `DATABASE_URL`, e con l'autenticazione `BETTER_AUTH_SECRET`, qualunque sia `contactEnvKeys`: la variabile delle migrazioni no, perché al deploy arriva da un segreto di GitHub.
|
|
103
113
|
|
|
104
|
-
Nel deploy `check placeholders` gira due volte sui file di Vercel. Con `--env .vercel/.env.production.local`, dopo `vercel pull`, distingue una chiave che su Vercel non esiste, una vuota e una che porta un segnaposto del template, tutte e tre errori, da una variabile **
|
|
114
|
+
Nel deploy `check placeholders` gira due volte sui file di Vercel. Con `--env .vercel/.env.production.local`, dopo `vercel pull`, distingue una chiave che su Vercel non esiste, una vuota e una che porta un segnaposto del template, tutte e tre errori, da una variabile **Secret**: `vercel pull` non ne scarica il valore e scrive al suo posto `[SENSITIVE]`, quindi il check lo dice con un avviso, che non ferma il deploy, invece di darla per verificata. Il segnaposto però diventa un problema se la build legge la variabile, per esempio una variabile di `astro:env` con `access: 'public'`, che Astro incorpora nel codice: con `--output .vercel/output`, dopo `vercel build`, il check cerca `[SENSITIVE]` nei file della build e ferma il deploy nominandoli. Una variabile che la build legge va tenuta Config su Vercel, o letta a runtime, e una Secret non diventa Config: si toglie e si riaggiunge.
|
|
105
115
|
|
|
106
116
|
Il file lo carica Node, che fuori da `node_modules` toglie i tipi da sé: niente `enum`, niente alias `@/`. Due file `officina.config.*` insieme sono un errore, come un file senza default export; il resto lo valida `loadConfig` a runtime, nominando il percorso della voce sbagliata — `bundle.cssMaxGzip: atteso un numero, ricevuto una stringa` — e una voce sconosciuta, perché `sitUrl` al posto di `siteUrl` non deve passare in silenzio.
|
|
107
117
|
|
|
@@ -132,15 +142,20 @@ I passi dei workflow stanno in `actions/` di questo repository, come composite a
|
|
|
132
142
|
|
|
133
143
|
| Action | Passi | Cosa resta nel workflow del progetto |
|
|
134
144
|
|---|---|---|
|
|
135
|
-
| `actions/ci` | checkout, node, install, `astro sync`, `pnpm run ci`, build, `perf:bundle`, `check:links`; con `e2e: 'true'` anche Playwright | trigger, permessi, il job `ci` |
|
|
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:` |
|
|
136
146
|
| `actions/review` | `astro sync`, poi `fallow review` sul diff contro il merge-base | il job informativo |
|
|
137
|
-
| `actions/deploy` | risoluzione del tag, stessa versione di officina, gate, `vercel pull`, `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 |
|
|
147
|
+
| `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:` |
|
|
138
148
|
| `actions/lighthouse` | build equivalente alla produzione e `pnpm run lhci` | trigger, etichetta, `continue-on-error` |
|
|
139
149
|
|
|
140
150
|
```yaml
|
|
151
|
+
concurrency:
|
|
152
|
+
group: deploy-production
|
|
153
|
+
cancel-in-progress: false
|
|
154
|
+
|
|
141
155
|
jobs:
|
|
142
156
|
deploy:
|
|
143
157
|
runs-on: ubuntu-latest
|
|
158
|
+
timeout-minutes: 30
|
|
144
159
|
environment:
|
|
145
160
|
name: production
|
|
146
161
|
url: ${{ steps.deploy.outputs.url }}
|
|
@@ -156,17 +171,70 @@ jobs:
|
|
|
156
171
|
|
|
157
172
|
`astro sync` genera i tipi di `.astro/`, che fallow pretende e un checkout pulito non ha; se fallisce il passo lo segnala con un avviso e il job prosegue.
|
|
158
173
|
|
|
159
|
-
I tre segreti sono input obbligatori e l'action li mette nell'`env` dei soli tre passi che chiamano `vercel
|
|
174
|
+
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.
|
|
160
175
|
|
|
161
176
|
**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.
|
|
162
177
|
|
|
178
|
+
**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.
|
|
179
|
+
|
|
180
|
+
```yaml
|
|
181
|
+
- name: Branch Neon della corsa
|
|
182
|
+
id: neon
|
|
183
|
+
env:
|
|
184
|
+
NEON_API_KEY: ${{ secrets.NEON_API_KEY }}
|
|
185
|
+
run: |
|
|
186
|
+
[ -n "$NEON_API_KEY" ] || exit 0
|
|
187
|
+
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
|
188
|
+
echo "expires=$(date -u -d '+2 hours' +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
|
189
|
+
- uses: neondatabase/create-branch-action@v6
|
|
190
|
+
id: branch
|
|
191
|
+
if: steps.neon.outputs.enabled == 'true'
|
|
192
|
+
with:
|
|
193
|
+
project_id: ${{ vars.NEON_PROJECT_ID }}
|
|
194
|
+
api_key: ${{ secrets.NEON_API_KEY }}
|
|
195
|
+
parent_branch: test
|
|
196
|
+
branch_name: ci-${{ github.run_id }}-${{ github.run_attempt }}
|
|
197
|
+
expires_at: ${{ steps.neon.outputs.expires }}
|
|
198
|
+
- uses: Elia97/officina/actions/ci@vX.Y.Z
|
|
199
|
+
with:
|
|
200
|
+
migrate: db:migrate
|
|
201
|
+
database-url: ${{ steps.branch.outputs.db_url }}
|
|
202
|
+
- uses: neondatabase/delete-branch-action@v3
|
|
203
|
+
if: always() && steps.branch.outputs.branch_id != ''
|
|
204
|
+
with:
|
|
205
|
+
project_id: ${{ vars.NEON_PROJECT_ID }}
|
|
206
|
+
branch: ${{ steps.branch.outputs.branch_id }}
|
|
207
|
+
api_key: ${{ secrets.NEON_API_KEY }}
|
|
208
|
+
```
|
|
209
|
+
|
|
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` avvisa quando `ci.yml` passa ancora un branch condiviso, `TEST_DATABASE_URL`.
|
|
211
|
+
|
|
212
|
+
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
|
+
|
|
214
|
+
**Le migrazioni al deploy.** Con un database, anche `actions/deploy` riceve lo script delle migrazioni, con l'indirizzo di produzione:
|
|
215
|
+
|
|
216
|
+
```yaml
|
|
217
|
+
- uses: Elia97/officina/actions/deploy@vX.Y.Z
|
|
218
|
+
with:
|
|
219
|
+
migrate: db:migrate
|
|
220
|
+
database-url: ${{ secrets.PRODUCTION_DATABASE_URL }}
|
|
221
|
+
```
|
|
222
|
+
|
|
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` avvisa 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
|
+
|
|
225
|
+
**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
|
+
|
|
227
|
+
**Migrazioni compatibili con la produzione.** Le migrazioni si applicano prima della build: se poi la build o il deploy falliscono, la produzione resta sul codice di prima con lo schema nuovo, e lo schema non torna indietro né promuovendo il deployment precedente, quando fallisce lo smoke, né deployando un tag più vecchio. Ogni migrazione deve quindi reggere il codice in produzione, e additiva non basta: lo rompono, senza togliere niente, un `NOT NULL` senza default, un `UNIQUE`, un `CHECK` o una chiave esterna nuovi su dati che quel codice scrive, come un cambio di tipo o un vincolo più stretto. Ciò che toglie arriva dopo: una colonna si toglie quando nessun deployment che si potrebbe promuovere la legge più, e una rinomina passa da una colonna nuova, che il codice scrive insieme alla vecchia, prima di togliere la vecchia in una release successiva.
|
|
228
|
+
|
|
229
|
+
**Un tempo massimo.** Il passo delle migrazioni non ne ha uno, e il passo di una composite action non accetta `timeout-minutes`. Una migrazione che aspetta un lock blocca, finché non lo ottiene, le query di produzione su quella tabella: le migrazioni del progetto chiedono un `lock_timeout`, con `SET lock_timeout` di Postgres in testa al file, e il job di deploy un `timeout-minutes`, come nell'esempio del deploy.
|
|
230
|
+
|
|
163
231
|
Quello che si scarica al volo è fissato a una versione esatta, in un posto solo: la CLI di Vercel in `actions/deploy/action.yml`, `@lhci/cli` in `src/checks/lighthouse.ts`, `serve` in `src/sh/lhci-local.sh`. Che i primi due siano esatti, uguali ovunque e non indietro di una major su npm lo guarda ogni lunedì `.github/workflows/vercel-cli.yml`, con `officina check vercel-cli`.
|
|
164
232
|
|
|
165
233
|
Perché le action non restino ferme per sempre, Dependabot di questo repository copre l'ecosistema `github-actions` anche dentro `/actions/*`, dove le action di terze parti sono fissate allo SHA intero con la versione nel commento.
|
|
166
234
|
|
|
167
235
|
## doctor
|
|
168
236
|
|
|
169
|
-
`officina doctor` dice, in qualunque repository, cosa manca perché il progetto prenda tutto da fuori: i punti di aggancio dei generatori, gli ancoraggi delle pagine a sezioni e dei dizionari, gli script, le dipendenze e gli strumenti di `package.json`, i residui di ciò che è uscito (copia del metodo, script, documenti commerciali), i preset, i workflow di GitHub e Dependabot, `officina.config.ts
|
|
237
|
+
`officina doctor` dice, in qualunque repository, cosa manca perché il progetto prenda tutto da fuori: i punti di aggancio dei generatori, gli ancoraggi delle pagine a sezioni e dei dizionari, gli script, le dipendenze e gli strumenti di `package.json`, i residui di ciò che è uscito (copia del metodo, script, documenti commerciali), i preset, i workflow di GitHub e Dependabot, `officina.config.ts` e, con un database o l'autenticazione, ciò che li rende sicuri. Sta dentro `ci`, quindi un progetto allineato non torna indietro senza che il gate lo dica.
|
|
170
238
|
|
|
171
239
|
Di `.github/dependabot.yml` guarda la **forma**, non la presenza delle chiavi: lo legge come YAML e pretende che le chiavi del gruppo stiano sul gruppo — Dependabot rifiuta l'intero file se `commit-message` o `open-pull-requests-limit` stanno su una voce che dichiara `multi-ecosystem-group` — e che ogni ecosistema del gruppo abbia una seconda voce per il resto. Senza quella, `patterns` restringe l'intera voce e tutto il resto smette di aggiornarsi senza che niente diventi rosso. Le action le cerca con il nome che dà loro Dependabot, quello del repository senza la sottocartella: `Elia97/officina`. `Elia97/officina/*` non corrisponde a nessuna dipendenza, in `patterns` come in `ignore` o in `exclude-patterns`, e `doctor` lo segnala ovunque compaia.
|
|
172
240
|
|
|
@@ -183,7 +251,9 @@ Le altre sezioni misurano che il progetto e il pacchetto si muovano insieme:
|
|
|
183
251
|
- **una PR sola**: il `.github/dependabot.yml` del progetto deve avere un gruppo `multi-ecosystem-groups` che prende `@elia97/officina` da npm e `Elia97/officina` da `github-actions`. Senza, Dependabot ne apre due e il controllo qui sopra le boccia entrambe;
|
|
184
252
|
- **gli strumenti che preset e action danno per scontati**: `@biomejs/biome`, `lefthook`, `@commitlint/cli`, `@commitlint/config-conventional` e `fallow` fra le `devDependencies`, con la versione di Biome dentro l'intervallo che il preset dichiara di reggere.
|
|
185
253
|
|
|
186
|
-
`
|
|
254
|
+
Con `features.database` o `features.auth` accese c'è una sezione in più, «database e autenticazione», che chiede ciò che rende sicuro un progetto con dati e utenti. Con il database: gli script `db:generate` e `db:migrate`, `drizzle.config.ts`, gli input `migrate` e `database-url` di `actions/ci` in `ci.yml` e di `actions/deploy` in `deploy.yml`. Per ogni feature accesa, la sua chiave in `env.schema`, `DATABASE_URL` o `BETTER_AUTH_SECRET`, con `context: 'server'` e `access: 'secret'`: una variabile pubblica Astro la scrive nel codice della build. Lo schema si legge dal sorgente di `astro.config.mjs`, senza eseguirlo, quindi una chiave conta se è scritta lì con `context` e `access` per esteso.
|
|
255
|
+
|
|
256
|
+
`package.json`, `.claude/settings.json`, `biome.json` e `.mcp.json` si leggono come JSON, non cercandoci dentro delle stringhe: `"hooks"` in un valore qualunque non è un blocco `hooks`. Nei workflow i riferimenti alle action si cercano nel testo, perché la versione di uno SHA sta nel commento, e una riga commentata non soddisfa nessun controllo; gli input che un passo dà a un'action si leggono come YAML.
|
|
187
257
|
|
|
188
258
|
**Un requisito nuovo nasce come avviso.** Quando `doctor` impara a chiedere una cosa nuova, la mancanza è un `warning` nella versione che la introduce e diventa un `error` dalla successiva. `doctor` gira dentro `pnpm run ci` senza `--strict`, quindi la PR di Dependabot che porta la versione nuova passa, e il progetto ha una versione di tempo per adeguarsi. L'avviso serve a far passare quella PR, e dove non può passare comunque il requisito nasce come errore: con il filtro `Elia97/officina/*` l'aggiornamento arriva diviso in due PR, e nessuna delle due passa il controllo sulla stessa versione.
|
|
189
259
|
|
|
@@ -235,4 +305,4 @@ I test dei generatori girano sui punti di aggancio del progetto di prova, copiat
|
|
|
235
305
|
|
|
236
306
|
## Cosa arriverà
|
|
237
307
|
|
|
238
|
-
I preset di `
|
|
308
|
+
I preset di `monorepo`.
|
package/dist/bin.js
CHANGED
|
@@ -12,6 +12,7 @@ export const CHECKS = {
|
|
|
12
12
|
placeholders: () => import('./checks/placeholders.js'),
|
|
13
13
|
roadmap: () => import('./checks/roadmap.js'),
|
|
14
14
|
routes: () => import('./checks/routes.js'),
|
|
15
|
+
secrets: () => import('./checks/secrets.js'),
|
|
15
16
|
smoke: () => import('./checks/smoke.js'),
|
|
16
17
|
'vercel-cli': () => import('./checks/vercel-cli.js'),
|
|
17
18
|
};
|
|
@@ -20,9 +21,10 @@ const USAGE = `Uso:
|
|
|
20
21
|
officina gen [generatore] section, page, component, collection, più quelli del progetto
|
|
21
22
|
officina gen icons le icone del manifest da public/favicon.svg
|
|
22
23
|
officina doctor cosa manca al progetto perché i generatori funzionino
|
|
24
|
+
officina migrate --script <nome> le migrazioni sotto database.migrationUrlKey: da TEST_DATABASE_URL, o con --production da PRODUCTION_DATABASE_URL
|
|
23
25
|
|
|
24
26
|
Opzioni dei gate sui sorgenti: --diff, --base <ref>, --head <ref>, --strict, --format text|github
|
|
25
|
-
bundle e
|
|
27
|
+
bundle, links e secrets leggono dist/client; smoke [url] e analytics [GTM-…] vanno in rete; lighthouse [--local]
|
|
26
28
|
I valori del progetto (URL, budget, header, controlli propri) stanno in officina.config.ts
|
|
27
29
|
`;
|
|
28
30
|
// Ogni gruppo dice quale modulo carica e quali argomenti gli passa.
|
|
@@ -32,6 +34,7 @@ const GROUPS = {
|
|
|
32
34
|
? { load: () => import('./gen/icons.js'), args: args.slice(1) }
|
|
33
35
|
: { load: () => import('./gen/run.js'), args },
|
|
34
36
|
doctor: (args) => ({ load: () => import('./checks/doctor.js'), args }),
|
|
37
|
+
migrate: (args) => ({ load: () => import('./checks/migrate.js'), args }),
|
|
35
38
|
};
|
|
36
39
|
export async function run(argv) {
|
|
37
40
|
const [group = '', ...rest] = argv;
|
package/dist/checks/doctor.js
CHANGED
|
@@ -3,12 +3,13 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import process from 'node:process';
|
|
5
5
|
import { PROJECT_GENERATORS } from '../gen/plopfile.mjs';
|
|
6
|
-
import { dependencyGaps, leftoverGaps, presetGaps, scriptGaps, } from '../lib/alignment.js';
|
|
6
|
+
import { dependencyGaps, leftoverGaps, presetGaps, readManifest, scriptGaps, } from '../lib/alignment.js';
|
|
7
7
|
import { anchorGaps } from '../lib/anchors.js';
|
|
8
8
|
import { cliOptions, exitCode, printFindings } from '../lib/cli.js';
|
|
9
|
-
import { findConfigFile, generatorsSetting, loadConfig, } from '../lib/config.js';
|
|
9
|
+
import { featuresOffByChoice, featuresOn, findConfigFile, generatorsSetting, loadConfig, } from '../lib/config.js';
|
|
10
10
|
import { configGaps } from '../lib/config-gaps.js';
|
|
11
11
|
import { contractGaps } from '../lib/contract.js';
|
|
12
|
+
import { databaseAuthGaps } from '../lib/database-auth.js';
|
|
12
13
|
import { dependabotGaps } from '../lib/dependabot.js';
|
|
13
14
|
import { trackedAndUntracked } from '../lib/git.js';
|
|
14
15
|
import { expectedRoutes, missingRepresentatives, readPageFiles, unknownDisabled } from '../lib/routes.js';
|
|
@@ -22,14 +23,6 @@ function projectFiles(root) {
|
|
|
22
23
|
read: (path) => (existsSync(join(root, path)) ? readFileSync(join(root, path), 'utf8') : undefined),
|
|
23
24
|
};
|
|
24
25
|
}
|
|
25
|
-
function readManifest(files) {
|
|
26
|
-
try {
|
|
27
|
-
return JSON.parse(files.read('package.json') ?? '{}');
|
|
28
|
-
}
|
|
29
|
-
catch {
|
|
30
|
-
return {};
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
26
|
async function loadedConfig(root) {
|
|
34
27
|
if (findConfigFile(root) === undefined)
|
|
35
28
|
return undefined;
|
|
@@ -81,6 +74,15 @@ function configSection(root, config) {
|
|
|
81
74
|
const notes = disabled.length === 0 ? [] : [`spente da \`routes.disabled\`: ${disabled.join(', ')}`];
|
|
82
75
|
return { title: 'officina.config.ts', gaps: configGaps(config, gaps), notes };
|
|
83
76
|
}
|
|
77
|
+
const DATABASE_AUTH = 'database e autenticazione';
|
|
78
|
+
function databaseAuthSections(files, config, manifest) {
|
|
79
|
+
const on = featuresOn(config, manifest);
|
|
80
|
+
const off = featuresOffByChoice(config, manifest).map((name) => `\`features.${name}: false\``);
|
|
81
|
+
if (on.length === 0)
|
|
82
|
+
return off.length === 0 ? [] : [{ title: DATABASE_AUTH, off: off.join(' e ') }];
|
|
83
|
+
const notes = off.map((setting) => `spenta da ${setting}`);
|
|
84
|
+
return [{ title: DATABASE_AUTH, gaps: databaseAuthGaps(files, manifest, on), notes }];
|
|
85
|
+
}
|
|
84
86
|
export async function main() {
|
|
85
87
|
const root = process.cwd();
|
|
86
88
|
const files = projectFiles(root);
|
|
@@ -100,6 +102,7 @@ export async function main() {
|
|
|
100
102
|
gaps: [...workflowGaps(files, version), ...dependabotGaps(files)],
|
|
101
103
|
},
|
|
102
104
|
configSection(root, config),
|
|
105
|
+
...databaseAuthSections(files, usable(config), manifest),
|
|
103
106
|
];
|
|
104
107
|
console.log(`\nofficina doctor ${version} — cosa manca al progetto per prendere tutto da fuori\n`);
|
|
105
108
|
const findings = [];
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import { maskCommand } from '../lib/cli.js';
|
|
6
|
+
import { loadConfig, migrationUrlKey } from '../lib/config.js';
|
|
7
|
+
import { maskedValues, migrationEnv } from '../lib/migrate.js';
|
|
8
|
+
const written = (stream, text) => new Promise((resolve, reject) => {
|
|
9
|
+
stream.write(text, (error) => (error ? reject(error) : resolve()));
|
|
10
|
+
});
|
|
11
|
+
export async function main(args = process.argv.slice(2)) {
|
|
12
|
+
const options = { script: { type: 'string' }, production: { type: 'boolean' } };
|
|
13
|
+
const { script, production } = parseArgs({ args, options }).values;
|
|
14
|
+
if (!script) {
|
|
15
|
+
console.error('\n✗ manca `--script`: il nome dello script delle migrazioni, per esempio `db:migrate`.\n');
|
|
16
|
+
return 1;
|
|
17
|
+
}
|
|
18
|
+
const key = migrationUrlKey(await loadConfig(process.cwd()));
|
|
19
|
+
const migration = migrationEnv(process.env, key, production ? 'production' : 'test');
|
|
20
|
+
if ('error' in migration) {
|
|
21
|
+
console.error(`\n✗ ${migration.error}.\n`);
|
|
22
|
+
return 1;
|
|
23
|
+
}
|
|
24
|
+
if (process.env.GITHUB_ACTIONS) {
|
|
25
|
+
// Su una pipe piena libuv scrive il resto solo dopo `spawnSync`; il runner legge stderr prima di stdout
|
|
26
|
+
// in ogni lotto di righe.
|
|
27
|
+
const masks = `${maskedValues(migration.url).map(maskCommand).join('\n')}\n`;
|
|
28
|
+
await Promise.all([written(process.stdout, masks), written(process.stderr, masks)]);
|
|
29
|
+
}
|
|
30
|
+
const where = production ? 'in produzione' : 'sul branch di test';
|
|
31
|
+
console.log(`\nmigrate — \`pnpm run ${script}\` ${where}, con l'indirizzo in \`${key}\`\n`);
|
|
32
|
+
const { status, signal, error } = spawnSync('pnpm', ['run', script], { stdio: 'inherit', env: migration.env });
|
|
33
|
+
if (error !== undefined) {
|
|
34
|
+
console.error(`\n✗ pnpm non parte: ${error.message}.\n`);
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
if (signal !== null) {
|
|
38
|
+
console.error(`\n✗ \`pnpm run ${script}\` si è fermato per ${signal}.\n`);
|
|
39
|
+
return 1;
|
|
40
|
+
}
|
|
41
|
+
return status ?? 1;
|
|
42
|
+
}
|
|
43
|
+
if (import.meta.main)
|
|
44
|
+
process.exit(await main());
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import process from 'node:process';
|
|
4
4
|
import { parseArgs } from 'node:util';
|
|
5
|
-
import {
|
|
5
|
+
import { readManifest } from '../lib/alignment.js';
|
|
6
|
+
import { envFindings, outputFindings, placeholderSources, requiredEnvKeys, sourceFindings, } from '../lib/check-placeholders.js';
|
|
6
7
|
import { cliOptions, exitCode, printFindings } from '../lib/cli.js';
|
|
7
|
-
import { loadConfig } from '../lib/config.js';
|
|
8
|
+
import { featuresOn, loadConfig } from '../lib/config.js';
|
|
8
9
|
const MISSING_SOURCE = 'sorgente assente: `placeholders.sources` in officina.config.ts dice quali file guardare';
|
|
9
10
|
function sourceScan(paths) {
|
|
10
11
|
return paths.flatMap((path) => {
|
|
@@ -23,21 +24,29 @@ function outputScan(dir) {
|
|
|
23
24
|
return [{ path: dir, message: 'cartella della build assente: la scrive vercel build', severity: 'error' }];
|
|
24
25
|
return outputFindings(dir);
|
|
25
26
|
}
|
|
27
|
+
const projectFile = (path) => (existsSync(path) ? readFileSync(path, 'utf8') : undefined);
|
|
28
|
+
const ENV_ADVICE = 'Da impostare su Vercel, fra le variabili di produzione, prima del deploy: quali pretendere lo dice `placeholders.contactEnvKeys` in officina.config.ts';
|
|
29
|
+
function envAdvice(on) {
|
|
30
|
+
if (on.length === 0)
|
|
31
|
+
return ENV_ADVICE;
|
|
32
|
+
return `${ENV_ADVICE}, più le chiavi che pretendono ${on.map((name) => `\`features.${name}\``).join(' e ')}`;
|
|
33
|
+
}
|
|
26
34
|
function scan(env, output, config) {
|
|
27
35
|
if (output !== undefined) {
|
|
28
36
|
return {
|
|
29
37
|
scope: `build in ${output}`,
|
|
30
38
|
findings: outputScan(output),
|
|
31
|
-
clean: 'Nessuna variabile
|
|
32
|
-
advice: 'Una variabile che la build legge non può essere
|
|
39
|
+
clean: 'Nessuna variabile Secret entrata nella build.',
|
|
40
|
+
advice: 'Una variabile che la build legge non può essere Secret su Vercel: va tolta e riaggiunta come Config, o letta a runtime',
|
|
33
41
|
};
|
|
34
42
|
}
|
|
35
43
|
if (env !== undefined) {
|
|
44
|
+
const manifest = readManifest({ read: projectFile });
|
|
36
45
|
return {
|
|
37
46
|
scope: `ambiente in ${env}`,
|
|
38
|
-
findings: envScan(env,
|
|
47
|
+
findings: envScan(env, requiredEnvKeys(config, manifest)),
|
|
39
48
|
clean: 'Nessun segnaposto del template.',
|
|
40
|
-
advice:
|
|
49
|
+
advice: envAdvice(featuresOn(config, manifest)),
|
|
41
50
|
};
|
|
42
51
|
}
|
|
43
52
|
const paths = placeholderSources(config);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import { cliOptions, exitCode, missingInput, printFindings } from '../lib/cli.js';
|
|
6
|
+
import { envSchema } from '../lib/env-schema.js';
|
|
7
|
+
import { filesWithExtension } from '../lib/routes.js';
|
|
8
|
+
import { canaryLines, scanSecrets, secretFields } from '../lib/secrets.js';
|
|
9
|
+
const SCHEMA = 'astro.config.mjs';
|
|
10
|
+
const DIST = 'dist/client';
|
|
11
|
+
const ADVICE = 'Una chiave server e secret si legge solo nel codice che gira sul server: segui la strada che la porta nel file nominato';
|
|
12
|
+
function check(keys) {
|
|
13
|
+
const counted = keys.length === 1 ? '1 chiave' : `${keys.length} chiavi`;
|
|
14
|
+
console.log(`\ncheck:secrets — ${counted} server e secret di env.schema, cercate in ${DIST}\n`);
|
|
15
|
+
if (keys.length === 0) {
|
|
16
|
+
console.log(' Nessuna chiave segreta nello schema: niente da verificare.\n');
|
|
17
|
+
return 0;
|
|
18
|
+
}
|
|
19
|
+
if (missingInput(DIST, 'è la build in cui si cercano le chiavi: prima `pnpm build`'))
|
|
20
|
+
return 1;
|
|
21
|
+
const files = filesWithExtension(DIST, '');
|
|
22
|
+
if (files.length === 0) {
|
|
23
|
+
console.error(`\n✗ ${DIST} è vuota: senza file il controllo non afferma niente.\n`);
|
|
24
|
+
return 1;
|
|
25
|
+
}
|
|
26
|
+
const { findings, verified } = scanSecrets(keys, process.env, files, (path) => readFileSync(path));
|
|
27
|
+
printFindings(findings, cliOptions([]).format);
|
|
28
|
+
if (verified.length > 0)
|
|
29
|
+
console.log(` Verificate: ${verified.join(', ')}.`);
|
|
30
|
+
if (findings.some(({ severity }) => severity === 'error'))
|
|
31
|
+
console.log(`\n ${ADVICE}.\n`);
|
|
32
|
+
return exitCode(findings, false);
|
|
33
|
+
}
|
|
34
|
+
export function main(args = process.argv.slice(2)) {
|
|
35
|
+
const { canaries } = parseArgs({ args, options: { canaries: { type: 'boolean', default: false } } }).values;
|
|
36
|
+
if (missingInput(SCHEMA, 'le chiavi segrete stanno nel suo `env.schema`'))
|
|
37
|
+
return 1;
|
|
38
|
+
const secrets = secretFields(envSchema(readFileSync(SCHEMA, 'utf8')));
|
|
39
|
+
if (!canaries)
|
|
40
|
+
return check(secrets.map(({ key }) => key));
|
|
41
|
+
for (const line of canaryLines(secrets))
|
|
42
|
+
console.log(line);
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
if (import.meta.main)
|
|
46
|
+
process.exit(main());
|
package/dist/checks/smoke.js
CHANGED
|
@@ -1,27 +1,38 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// L'unico controllo che vede cosa serve davvero il bordo: i test del progetto fissano solo vercel.json.
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
4
|
import process from 'node:process';
|
|
5
|
+
import { readManifest } from '../lib/alignment.js';
|
|
4
6
|
import { missingInput } from '../lib/cli.js';
|
|
5
|
-
import { isRequired, loadConfig } from '../lib/config.js';
|
|
7
|
+
import { isOn, isRequired, loadConfig } from '../lib/config.js';
|
|
6
8
|
import { expectedRoutes, readPageFiles, smokeRoutes } from '../lib/routes.js';
|
|
9
|
+
import { onDemandPages } from '../lib/smoke-on-demand.js';
|
|
7
10
|
import { runChecks, SECURITY_HEADERS, waitForAlias, } from '../lib/smoke-production.js';
|
|
8
11
|
const PAGES_DIR = 'src/pages';
|
|
12
|
+
const projectFile = (path) => (existsSync(path) ? readFileSync(path, 'utf8') : undefined);
|
|
9
13
|
function printResults(results) {
|
|
10
14
|
for (const { check, status, detail } of results) {
|
|
11
15
|
if (status === 'pass')
|
|
12
16
|
console.log(`✓ ${check}`);
|
|
13
17
|
else if (status === 'skip')
|
|
14
18
|
console.log(`- ${check} (saltato: ${detail})`);
|
|
19
|
+
else if (status === 'warn')
|
|
20
|
+
console.log(`· ${check} — ${detail}`);
|
|
15
21
|
else
|
|
16
22
|
console.error(`✗ ${check} — ${detail}`);
|
|
17
23
|
}
|
|
18
24
|
}
|
|
19
25
|
const disabledResults = ({ disabled }) => disabled.map(({ label }) => ({ check: `pagina ${label}`, status: 'skip', detail: 'spenta da `routes.disabled`' }));
|
|
20
|
-
function printFailures(failures) {
|
|
26
|
+
function printFailures(failures, database) {
|
|
21
27
|
console.error(`\n✗ ${failures.length} controllo/i falliti:`);
|
|
22
28
|
console.error(`${failures.map(({ check, detail }) => ` - ${check} — ${detail}`).join('\n')}\n`);
|
|
23
29
|
console.error('La produzione è online e rotta. Torna indietro dalla dashboard di Vercel:');
|
|
24
30
|
console.error('Deployments → l’ultimo deployment di produzione sano → Promote to Production.\n');
|
|
31
|
+
if (!database)
|
|
32
|
+
return;
|
|
33
|
+
console.error('Con un database lo schema non torna indietro: le migrazioni già applicate restano,');
|
|
34
|
+
console.error('e il deployment che promuovi regge solo se sono compatibili con il suo codice.');
|
|
35
|
+
console.error('La regola è nel README di officina: «Migrazioni compatibili con la produzione».\n');
|
|
25
36
|
}
|
|
26
37
|
export async function main(args = []) {
|
|
27
38
|
const config = await loadConfig(process.cwd());
|
|
@@ -50,13 +61,18 @@ export async function main(args = []) {
|
|
|
50
61
|
const pages = smokeRoutes(expected, smoke.nonHtmlRoutes, routes.representatives);
|
|
51
62
|
console.log(`\nSmoke di produzione — ${baseUrl}\n`);
|
|
52
63
|
await waitForAlias(context, (ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
53
|
-
const
|
|
64
|
+
const onDemand = onDemandPages(expected, PAGES_DIR, routes.representatives);
|
|
65
|
+
const results = await runChecks({ ...context, onDemand }, pages, smoke.checks);
|
|
54
66
|
printResults([...results, ...disabledResults(expected)]);
|
|
55
67
|
const failures = results.filter(({ status }) => status === 'fail');
|
|
56
68
|
if (failures.length > 0) {
|
|
57
|
-
printFailures(failures);
|
|
69
|
+
printFailures(failures, isOn(config, readManifest({ read: projectFile }), 'database'));
|
|
58
70
|
return 1;
|
|
59
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.`);
|
|
60
76
|
console.log(`\n✓ Tutti i controlli passati su ${baseUrl}.\n`);
|
|
61
77
|
return 0;
|
|
62
78
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,4 +3,5 @@ export type { OfficinaConfig } from './lib/config.ts';
|
|
|
3
3
|
export { defineConfig } from './lib/config.ts';
|
|
4
4
|
export type { IconSpec } from './lib/icon-specs.ts';
|
|
5
5
|
export type { VerifiedRoute } from './lib/routes.ts';
|
|
6
|
+
export type { OnDemandPage } from './lib/smoke-on-demand.ts';
|
|
6
7
|
export type { CheckResult, SmokeCheck, SmokeContext } from './lib/smoke-production.ts';
|
package/dist/lib/alignment.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export declare const EXPECTED_SCRIPTS: Readonly<Record<string, string>>;
|
|
|
13
13
|
export declare const CI_STEPS: readonly string[];
|
|
14
14
|
export declare function scriptGaps({ scripts }: Manifest): ContractGap[];
|
|
15
15
|
export declare function dependencyGaps({ dependencies, devDependencies }: Manifest): ContractGap[];
|
|
16
|
+
export declare const readManifest: (files: Pick<ProjectFiles, 'read'>) => Manifest;
|
|
16
17
|
export declare function leftoverGaps(files: ProjectFiles): ContractGap[];
|
|
17
18
|
export declare const BIOME_PRESET = "@elia97/officina/biome";
|
|
18
19
|
export declare const BIOME_SCHEMA = "./node_modules/@biomejs/biome/configuration_schema.json";
|
package/dist/lib/alignment.js
CHANGED
|
@@ -7,6 +7,7 @@ export const EXPECTED_SCRIPTS = {
|
|
|
7
7
|
'check:placeholders': 'officina check placeholders',
|
|
8
8
|
'perf:bundle': 'officina check bundle',
|
|
9
9
|
'check:links': 'officina check links',
|
|
10
|
+
'check:secrets': 'officina check secrets',
|
|
10
11
|
'smoke:prod': 'officina check smoke',
|
|
11
12
|
'analytics:verify': 'officina check analytics',
|
|
12
13
|
lhci: 'officina check lighthouse',
|
|
@@ -27,8 +28,8 @@ export const CI_STEPS = [
|
|
|
27
28
|
'doctor',
|
|
28
29
|
];
|
|
29
30
|
const MANIFEST = 'package.json';
|
|
30
|
-
// TODO: dalla 0.
|
|
31
|
-
const NEW_SCRIPTS = new Set(['check:
|
|
31
|
+
// TODO: dalla 0.13.0 `check:secrets` è un errore di doctor: toglilo da NEW_SCRIPTS.
|
|
32
|
+
const NEW_SCRIPTS = new Set(['check:secrets']);
|
|
32
33
|
export function scriptGaps({ scripts = {} }) {
|
|
33
34
|
const gaps = [];
|
|
34
35
|
for (const [name, expected] of Object.entries(EXPECTED_SCRIPTS)) {
|
|
@@ -114,6 +115,7 @@ function readJson({ read }, path) {
|
|
|
114
115
|
return undefined;
|
|
115
116
|
}
|
|
116
117
|
}
|
|
118
|
+
export const readManifest = (files) => (readJson(files, MANIFEST) ?? {});
|
|
117
119
|
export function leftoverGaps(files) {
|
|
118
120
|
const gaps = LEFTOVERS.filter(({ path }) => holds(files.paths, path)).map(({ path, reason }) => ({
|
|
119
121
|
path,
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import type { Manifest } from './alignment.ts';
|
|
1
2
|
import { type Finding, type Hit } from './cli.ts';
|
|
2
|
-
import type
|
|
3
|
+
import { type OfficinaConfig } from './config.ts';
|
|
3
4
|
type EnvHit = Omit<Finding, 'path'>;
|
|
4
5
|
export declare function placeholderSources(config?: OfficinaConfig): string[];
|
|
5
6
|
export declare const contactEnvKeys: (config?: OfficinaConfig) => readonly string[];
|
|
7
|
+
export declare function requiredEnvKeys(config: OfficinaConfig, manifest: Manifest): string[];
|
|
6
8
|
export declare function sourceFindings(source: string): Hit[];
|
|
7
9
|
export declare function envFindings(content: string, keys: readonly string[]): EnvHit[];
|
|
8
10
|
export declare const outputFindings: (dir: string) => Finding[];
|
|
@@ -2,6 +2,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { parseEnv } from 'node:util';
|
|
4
4
|
import { readLines } from './cli.js';
|
|
5
|
+
import { DEPENDENCY_FEATURES, featuresOn } from './config.js';
|
|
5
6
|
const DICTIONARIES = 'src/i18n/strings';
|
|
6
7
|
const DEFAULT_SOURCES = ['src/lib/site.ts', 'src/lib/company.ts'];
|
|
7
8
|
const CONTACT_ENV_KEYS = ['CONTACT_FROM_EMAIL', 'CONTACT_FROM_NAME', 'CONTACT_TO_EMAIL'];
|
|
@@ -11,7 +12,7 @@ const STRING_LITERAL = /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g;
|
|
|
11
12
|
const COMMENT_ONLY = /^\s*(?:\/\/|\/\*|\*)/;
|
|
12
13
|
const ZERO_POSTAL_CODE = /\bpostalCode:\s*(['"])00000\1/;
|
|
13
14
|
const EMPTY_HREF = /\bhref:\s*(['"])#\1/;
|
|
14
|
-
// `SENSITIVE_ENV_VALUE_PLACEHOLDER` di vercel@62.2.0: `vercel pull` lo scrive al posto del valore di una
|
|
15
|
+
// `SENSITIVE_ENV_VALUE_PLACEHOLDER` di vercel@62.2.0: `vercel pull` lo scrive al posto del valore di una Secret.
|
|
15
16
|
const SENSITIVE = '[SENSITIVE]';
|
|
16
17
|
const WITHOUT_VALUE = 'il deploy andrebbe online senza il suo valore';
|
|
17
18
|
const dictionaries = () => existsSync(DICTIONARIES)
|
|
@@ -27,6 +28,11 @@ export function placeholderSources(config = {}) {
|
|
|
27
28
|
return [...DEFAULT_SOURCES, ...dictionaries()];
|
|
28
29
|
}
|
|
29
30
|
export const contactEnvKeys = (config = {}) => config.placeholders?.contactEnvKeys ?? CONTACT_ENV_KEYS;
|
|
31
|
+
export function requiredEnvKeys(config, manifest) {
|
|
32
|
+
const on = featuresOn(config, manifest);
|
|
33
|
+
const secrets = on.map((name) => DEPENDENCY_FEATURES[name].secret);
|
|
34
|
+
return [...new Set([...contactEnvKeys(config), ...secrets])];
|
|
35
|
+
}
|
|
30
36
|
function literalFindings(value) {
|
|
31
37
|
const messages = [];
|
|
32
38
|
const token = value.match(TOKEN);
|
|
@@ -64,7 +70,7 @@ export function envFindings(content, keys) {
|
|
|
64
70
|
{ line: declaration.n, severity, message: `${key} ${message}` },
|
|
65
71
|
];
|
|
66
72
|
if (value === SENSITIVE)
|
|
67
|
-
return hit('warning', 'è
|
|
73
|
+
return hit('warning', 'è Secret su Vercel: vercel pull non ne scarica il valore, e il deploy non lo può verificare');
|
|
68
74
|
if (value === '')
|
|
69
75
|
return hit('error', `è vuota: ${WITHOUT_VALUE}`);
|
|
70
76
|
if (TOKEN.test(value) || DOMAIN.test(value))
|
|
@@ -84,5 +90,5 @@ export const outputFindings = (dir) => filesUnder(dir)
|
|
|
84
90
|
.map((path) => ({
|
|
85
91
|
path,
|
|
86
92
|
severity: 'error',
|
|
87
|
-
message: `contiene ${SENSITIVE}, il segnaposto che vercel pull scrive per una variabile
|
|
93
|
+
message: `contiene ${SENSITIVE}, il segnaposto che vercel pull scrive per una variabile Secret: la build l'ha incorporato al posto del valore`,
|
|
88
94
|
}));
|