@forumone/create-throughline 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/dist/generator.d.ts.map +1 -1
- package/dist/generator.js +4 -0
- package/dist/generator.js.map +1 -1
- package/dist/post-install.d.ts.map +1 -1
- package/dist/post-install.js +10 -3
- package/dist/post-install.js.map +1 -1
- package/dist/templates/base/.env.example +9 -13
- package/dist/templates/base/README.md +55 -2
- package/dist/templates/base/apps/web/package.json.template +11 -2
- package/dist/templates/base/apps/web/scripts/check-migrations.ts +147 -0
- package/dist/templates/base/apps/web/scripts/lib/target.ts +95 -0
- package/dist/templates/base/apps/web/scripts/write-migration-snapshot.ts +119 -0
- package/dist/templates/base/apps/web/src/app/api/inngest/route.ts.template +8 -1
- package/dist/templates/base/apps/web/src/lib/database.ts +158 -0
- package/dist/templates/base/apps/web/src/payload.config.ts.template +73 -16
- package/dist/templates/base/github/scripts/check-generated.sh +44 -0
- package/dist/templates/base/github/workflows/ci.yml +131 -0
- package/dist/templates/base/package.json.template +6 -1
- package/dist/templates/base/scripts/check-audit.ts +117 -0
- package/dist/templates/base/scripts/check-single-instance.ts +113 -0
- package/dist/templates/base/scripts/check-tested-packages.ts +165 -0
- package/dist/templates/base/turbo.json +16 -1
- package/dist/templates/with-reference-ds/design-system/scripts/build-manifest.ts +59 -29
- package/dist/templates/with-reference-ds/design-system/scripts/validate.ts +26 -1
- package/dist/versions.json +1 -1
- package/package.json +2 -2
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { InngestFunction } from 'inngest'
|
|
1
2
|
import { serve } from 'inngest/next'
|
|
2
3
|
import { getPayload } from 'payload'
|
|
3
4
|
import config from '@/payload.config'
|
|
@@ -42,7 +43,13 @@ async function publishScheduled({ collection, id, reasoning }: ScheduledPublishR
|
|
|
42
43
|
|
|
43
44
|
const scheduledCollections = [{ slug: 'pages' }]
|
|
44
45
|
|
|
45
|
-
|
|
46
|
+
/*
|
|
47
|
+
Typed with this app's own `InngestFunction.Any`. The registry is generic in what
|
|
48
|
+
`createFunctions` returns and defaults to `unknown`, because the platform never
|
|
49
|
+
touches those functions — and one `unknown` element leaves `serve()` unable to
|
|
50
|
+
type the whole array below.
|
|
51
|
+
*/
|
|
52
|
+
const integrationRegistry = getIntegrationRegistry<InngestFunction.Any>(payload)
|
|
46
53
|
const integrationContext = getIntegrationContext(payload)
|
|
47
54
|
const integrationFunctions =
|
|
48
55
|
integrationRegistry && integrationContext
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Which connection string to connect with, and the name of the variable it came
|
|
3
|
+
from.
|
|
4
|
+
|
|
5
|
+
Three things make this more than `process.env.DATABASE_URI!`.
|
|
6
|
+
|
|
7
|
+
**The Vercel–Neon integration does not set `DATABASE_URI`.** It injects
|
|
8
|
+
`DATABASE_URL`, `DATABASE_URL_UNPOOLED`, `POSTGRES_URL`,
|
|
9
|
+
`POSTGRES_URL_NON_POOLING` and friends. Left alone, the first deploy fails at
|
|
10
|
+
page-data collection with a connection error that says nothing about a missing
|
|
11
|
+
variable.
|
|
12
|
+
|
|
13
|
+
**The app wants the pooler and a migration cannot use it.** Neon's pooled
|
|
14
|
+
endpoint is PgBouncer in transaction mode: right for a serving function, whose
|
|
15
|
+
connections are many and short, and unusable for DDL, where Drizzle relies on
|
|
16
|
+
prepared statements and session state. `migrate` hangs or fails partway. The two
|
|
17
|
+
hostnames differ only by a `-pooler` suffix on the endpoint id.
|
|
18
|
+
|
|
19
|
+
**`DATABASE_URI` leads both lists, deliberately.** It is the variable you scope
|
|
20
|
+
per environment — a Preview-scoped `DATABASE_URI` pointing at a preview branch —
|
|
21
|
+
while the integration's variables are project-wide and resolve to production. A
|
|
22
|
+
preview build that fell through to one of those while migrating would migrate
|
|
23
|
+
production.
|
|
24
|
+
|
|
25
|
+
If you deploy somewhere other than Vercel + Neon, set `DATABASE_URI` and the
|
|
26
|
+
rest of the list is never consulted.
|
|
27
|
+
*/
|
|
28
|
+
const RUNTIME_CANDIDATES = [
|
|
29
|
+
'DATABASE_URI',
|
|
30
|
+
// Neon via the Vercel integration, pooled. Correct for a serving instance.
|
|
31
|
+
'DATABASE_URL',
|
|
32
|
+
'POSTGRES_URL',
|
|
33
|
+
// Direct, as a fallback: a project with no pooler still boots.
|
|
34
|
+
'DATABASE_URL_UNPOOLED',
|
|
35
|
+
'POSTGRES_URL_NON_POOLING',
|
|
36
|
+
] as const
|
|
37
|
+
|
|
38
|
+
/*
|
|
39
|
+
The DDL path: an optional migration-only credential first, then direct endpoints
|
|
40
|
+
ahead of pooled ones. The pooled pair is kept at the end rather than dropped, so
|
|
41
|
+
a migration whose only reachable string is pooled meets the explanation in
|
|
42
|
+
`describeProblem` instead of "no connection string".
|
|
43
|
+
|
|
44
|
+
`MIGRATION_DATABASE_URL` lets the app run as a role that can read and write rows
|
|
45
|
+
and no more, while migrations run as the schema owner. It is consulted only by
|
|
46
|
+
the process that migrates — see `isMigrationProcess` — because a platform with
|
|
47
|
+
no build-only variables would otherwise hand the DDL role straight back to the
|
|
48
|
+
running app.
|
|
49
|
+
*/
|
|
50
|
+
const MIGRATION_CANDIDATES = [
|
|
51
|
+
'MIGRATION_DATABASE_URL',
|
|
52
|
+
'DATABASE_URI',
|
|
53
|
+
'DATABASE_URL_UNPOOLED',
|
|
54
|
+
'POSTGRES_URL_NON_POOLING',
|
|
55
|
+
'DATABASE_URL',
|
|
56
|
+
'POSTGRES_URL',
|
|
57
|
+
] as const
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Whether this process is `payload migrate` rather than a server or a build.
|
|
61
|
+
*
|
|
62
|
+
* `PAYLOAD_MIGRATING=1` is set by the `migrate` script in package.json and by
|
|
63
|
+
* nothing else. `NEXT_RUNTIME` is defined in every server bundle Next emits, so
|
|
64
|
+
* no route or page can satisfy both conditions even if the first were set.
|
|
65
|
+
*/
|
|
66
|
+
export function isMigrationProcess(): boolean {
|
|
67
|
+
return process.env.PAYLOAD_MIGRATING === '1' && !process.env.NEXT_RUNTIME
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let announced = false
|
|
71
|
+
|
|
72
|
+
/*
|
|
73
|
+
One line in the deploy log saying which variable a migration connected through.
|
|
74
|
+
Without it, a migration that quietly fell through to the app's credential looks
|
|
75
|
+
exactly like one that worked. The name, never the value: a build log is not a
|
|
76
|
+
secret store.
|
|
77
|
+
*/
|
|
78
|
+
function announceMigrationCredential(name: string): void {
|
|
79
|
+
if (announced) return
|
|
80
|
+
announced = true
|
|
81
|
+
process.stderr.write(`payload migrate: connecting via ${name}\n`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/*
|
|
85
|
+
What is wrong with a connection string, in words, before Postgres says
|
|
86
|
+
"password authentication failed" and leaves you guessing. A string wrapped in
|
|
87
|
+
quotes or prefixed with `psql` fails as `getaddrinfo ENOTFOUND`; a masked or
|
|
88
|
+
truncated password fails as an auth error. The checks below catch the shapes
|
|
89
|
+
that can be seen without connecting. Nothing here ever prints the value.
|
|
90
|
+
*/
|
|
91
|
+
function describeProblem(name: string, value: string, migrating: boolean): string | undefined {
|
|
92
|
+
const raw = value.trim()
|
|
93
|
+
|
|
94
|
+
if (/^psql\s/i.test(raw)) {
|
|
95
|
+
return `${name} starts with "psql". Copy the connection string itself, not the command that uses it.`
|
|
96
|
+
}
|
|
97
|
+
if (/^["']|["']$/.test(raw)) {
|
|
98
|
+
return `${name} is wrapped in quotes. Most hosts store the value literally — paste it without them.`
|
|
99
|
+
}
|
|
100
|
+
if (!/^postgres(ql)?:\/\//i.test(raw)) {
|
|
101
|
+
return `${name} does not start with postgres:// or postgresql://.`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let url: URL
|
|
105
|
+
try {
|
|
106
|
+
url = new URL(raw)
|
|
107
|
+
} catch {
|
|
108
|
+
return `${name} is not a valid URL.`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!url.password) {
|
|
112
|
+
return `${name} has no password. Neon's console masks it until you reveal or copy it.`
|
|
113
|
+
}
|
|
114
|
+
if (/^[•*·]+$/.test(decodeURIComponent(url.password))) {
|
|
115
|
+
return `${name}'s password is a row of bullets — it was copied while the console still had it masked.`
|
|
116
|
+
}
|
|
117
|
+
if (migrating && url.hostname.includes('-pooler')) {
|
|
118
|
+
return (
|
|
119
|
+
`${name} points at Neon's pooled endpoint. Migrations need the direct one: PgBouncer in ` +
|
|
120
|
+
`transaction mode does not support the prepared statements Drizzle's DDL relies on. Use ` +
|
|
121
|
+
`the host without "-pooler".`
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return undefined
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The connection string, and the name of the variable it came from.
|
|
130
|
+
*
|
|
131
|
+
* The name matters: `DATABASE_URI` leads both lists, so one left in a shell or a
|
|
132
|
+
* pulled `.env` file wins without saying so, and every Neon branch's database is
|
|
133
|
+
* called `neondb` — the host and database name alone may not tell you which
|
|
134
|
+
* environment you are about to write to. See `scripts/lib/target.ts`.
|
|
135
|
+
*/
|
|
136
|
+
export function resolveDatabaseConnection(): { name: string; value: string } {
|
|
137
|
+
const migrating = isMigrationProcess()
|
|
138
|
+
const candidates: readonly string[] = migrating ? MIGRATION_CANDIDATES : RUNTIME_CANDIDATES
|
|
139
|
+
|
|
140
|
+
for (const name of candidates) {
|
|
141
|
+
const value = process.env[name]
|
|
142
|
+
if (value && value.trim() !== '') {
|
|
143
|
+
const problem = describeProblem(name, value, migrating)
|
|
144
|
+
if (problem) throw new Error(problem)
|
|
145
|
+
if (migrating) announceMigrationCredential(name)
|
|
146
|
+
return { name, value: value.trim() }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
throw new Error(
|
|
151
|
+
`No database connection string. Set DATABASE_URI, or connect a Neon database in Vercel — ` +
|
|
152
|
+
`the integration provides ${candidates.slice(1).join(', ')}, and every one was empty.`,
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function databaseConnectionString(): string {
|
|
157
|
+
return resolveDatabaseConnection().value
|
|
158
|
+
}
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
import { fileURLToPath } from 'node:url'
|
|
3
3
|
import { buildConfig } from 'payload'
|
|
4
|
-
import type { CollectionConfig } from 'payload'
|
|
4
|
+
import type { Access, CollectionConfig } from 'payload'
|
|
5
5
|
import { postgresAdapter } from '@payloadcms/db-postgres'
|
|
6
6
|
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
|
7
7
|
import {
|
|
8
8
|
auditPlugin,
|
|
9
|
-
createApiKeysCollection,
|
|
10
9
|
createInngestClient,
|
|
10
|
+
createMcpToolCollector,
|
|
11
11
|
} from '@forumone/throughline-core'
|
|
12
|
+
import { mcpPlugin } from '@payloadcms/plugin-mcp'
|
|
12
13
|
import { componentsPlugin } from '@forumone/throughline-components'
|
|
13
14
|
import { publishingPlugin } from '@forumone/throughline-publishing'
|
|
14
15
|
import {
|
|
@@ -20,6 +21,7 @@ import { auditQueryPlugin } from '@forumone/throughline-audit'
|
|
|
20
21
|
import { integrationsPlugin } from '@forumone/throughline-integrations'
|
|
21
22
|
import { emailPlugin } from '@forumone/throughline-email'
|
|
22
23
|
import { formsPlugin } from '@forumone/throughline-forms'
|
|
24
|
+
import { databaseConnectionString } from './lib/database'
|
|
23
25
|
{{#if useReferenceDs}}import type { Manifest } from '@forumone/throughline-design-contract'
|
|
24
26
|
import designSystemManifest from '{{designSystemName}}/manifest' with { type: 'json' }
|
|
25
27
|
{{/if}}
|
|
@@ -29,6 +31,23 @@ const __dirname = path.dirname(__filename)
|
|
|
29
31
|
|
|
30
32
|
const inngest = createInngestClient({ id: '{{projectName}}' })
|
|
31
33
|
|
|
34
|
+
/*
|
|
35
|
+
Where every Throughline server puts its tools for `mcpPlugin`, which serves them
|
|
36
|
+
all on one `/api/mcp`. Each plugin declares its tools as the config is built and
|
|
37
|
+
binds the handlers at `onInit`; `mcpPlugin` reads this array at both moments.
|
|
38
|
+
Hand over `mcpTools.tools` itself — a spread or `.slice()` would hand over
|
|
39
|
+
something nobody fills.
|
|
40
|
+
*/
|
|
41
|
+
const mcpTools = createMcpToolCollector()
|
|
42
|
+
|
|
43
|
+
// A person in `users` with the admin role. `req.user` can also be an MCP key
|
|
44
|
+
// document, which has no roles and is never an admin, so the collection is
|
|
45
|
+
// checked first — once payload-types.ts exists, TypeScript insists on it.
|
|
46
|
+
const isAdmin: Access = ({ req: { user } }) =>
|
|
47
|
+
user?.collection === 'users' &&
|
|
48
|
+
Array.isArray(user.roles) &&
|
|
49
|
+
(user.roles as unknown[]).includes('admin')
|
|
50
|
+
|
|
32
51
|
// Users collection — extend with your own fields and SSO/auth provider.
|
|
33
52
|
const Users: CollectionConfig = {
|
|
34
53
|
slug: 'users',
|
|
@@ -93,16 +112,30 @@ export default buildConfig({
|
|
|
93
112
|
admin: { user: 'users' },
|
|
94
113
|
editor: lexicalEditor(),
|
|
95
114
|
db: postgresAdapter({
|
|
96
|
-
|
|
115
|
+
// See lib/database.ts: which variable wins, and why a migration refuses
|
|
116
|
+
// Neon's pooled endpoint.
|
|
117
|
+
pool: { connectionString: databaseConnectionString() },
|
|
118
|
+
migrationDir: path.resolve(__dirname, 'migrations'),
|
|
119
|
+
// Never push schema: migrations are the only way this database changes.
|
|
120
|
+
// The adapter pushes whenever NODE_ENV !== 'production', which is true of
|
|
121
|
+
// every script and dev server. One pointed at a shared database silently
|
|
122
|
+
// syncs its schema and records a `dev` migration, and the next
|
|
123
|
+
// `payload migrate` in CI then stops to ask about it — with no terminal to
|
|
124
|
+
// answer — and skips the migration it was meant to apply.
|
|
125
|
+
push: false,
|
|
97
126
|
}),
|
|
98
|
-
|
|
127
|
+
// MCP keys live in `payload-mcp-api-keys`, which `mcpPlugin` below brings.
|
|
128
|
+
collections: [Users, Pages],
|
|
99
129
|
secret: process.env.PAYLOAD_SECRET!,
|
|
100
130
|
typescript: {
|
|
101
131
|
outputFile: path.resolve(__dirname, 'payload-types.ts'),
|
|
102
132
|
},
|
|
103
133
|
plugins: [
|
|
104
|
-
// Order matters
|
|
105
|
-
//
|
|
134
|
+
// Order matters twice. `auditPlugin` first: every other Throughline plugin
|
|
135
|
+
// requires the audit log at init. And every tool-bearing plugin before
|
|
136
|
+
// `mcpPlugin`, which reads the declared tools when the config is built — a
|
|
137
|
+
// plugin registered after it gets no per-key checkbox, and its tools are
|
|
138
|
+
// denied to every key with no error anywhere.
|
|
106
139
|
auditPlugin({ inngest }),
|
|
107
140
|
|
|
108
141
|
componentsPlugin({
|
|
@@ -110,6 +143,7 @@ export default buildConfig({
|
|
|
110
143
|
{{else}}// TODO: point at your design system manifest
|
|
111
144
|
manifest: { type: 'url', url: 'https://your-design-system.example.com/manifest.json' },
|
|
112
145
|
{{/if}}matching: { strategy: 'tfidf' },
|
|
146
|
+
mcpTools,
|
|
113
147
|
}),
|
|
114
148
|
|
|
115
149
|
publishingPlugin({
|
|
@@ -119,6 +153,7 @@ export default buildConfig({
|
|
|
119
153
|
approvalResolver: {
|
|
120
154
|
getActiveApproval: async () => null, // TODO: wire to approvals plugin
|
|
121
155
|
},
|
|
156
|
+
mcpTools,
|
|
122
157
|
}),
|
|
123
158
|
|
|
124
159
|
approvalsPlugin({
|
|
@@ -135,9 +170,10 @@ export default buildConfig({
|
|
|
135
170
|
return []
|
|
136
171
|
},
|
|
137
172
|
},
|
|
173
|
+
mcpTools,
|
|
138
174
|
}),
|
|
139
175
|
|
|
140
|
-
auditQueryPlugin({}),
|
|
176
|
+
auditQueryPlugin({ mcpTools }),
|
|
141
177
|
|
|
142
178
|
emailPlugin({
|
|
143
179
|
inngest,
|
|
@@ -162,18 +198,39 @@ export default buildConfig({
|
|
|
162
198
|
formsPlugin({
|
|
163
199
|
inngest,
|
|
164
200
|
ipHashSecret: process.env.FORMS_IP_HASH_SECRET!,
|
|
165
|
-
// TODO: list every place a form is allowed to send to
|
|
166
|
-
// refuses to
|
|
201
|
+
// TODO: list every place a form is allowed to send to, and delete the
|
|
202
|
+
// placeholder. The plugin refuses to start with an empty list — the
|
|
203
|
+
// allowlist is the security perimeter — and refuses to deliver anywhere
|
|
204
|
+
// not on it. example.com is reserved and accepts no mail, so the
|
|
205
|
+
// placeholder routes nothing anywhere while it stands.
|
|
167
206
|
allowedDestinations: [
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
207
|
+
{
|
|
208
|
+
type: 'email',
|
|
209
|
+
value: 'forms@example.com',
|
|
210
|
+
label: 'Placeholder',
|
|
211
|
+
description: 'Replace before launch: example.com accepts no mail.',
|
|
212
|
+
},
|
|
174
213
|
],
|
|
214
|
+
mcpTools,
|
|
175
215
|
}),
|
|
176
216
|
|
|
177
|
-
integrationsPlugin({ inngest }),
|
|
217
|
+
integrationsPlugin({ inngest, mcpTools }),
|
|
218
|
+
|
|
219
|
+
// Last of the tool-bearing chain. An MCP key is a credential: the plugin
|
|
220
|
+
// ships its collection with no access rules — "anybody signed in" — so it
|
|
221
|
+
// is narrowed to admins here.
|
|
222
|
+
mcpPlugin({
|
|
223
|
+
mcp: { tools: mcpTools.tools },
|
|
224
|
+
overrideApiKeyCollection: (collection) => ({
|
|
225
|
+
...collection,
|
|
226
|
+
access: {
|
|
227
|
+
...collection.access,
|
|
228
|
+
read: isAdmin,
|
|
229
|
+
create: isAdmin,
|
|
230
|
+
update: isAdmin,
|
|
231
|
+
delete: isAdmin,
|
|
232
|
+
},
|
|
233
|
+
}),
|
|
234
|
+
}),
|
|
178
235
|
],
|
|
179
236
|
})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# payload-types.ts and importMap.js are committed but generated from
|
|
4
|
+
# payload.config.ts. Regenerating and diffing is what stops them drifting from
|
|
5
|
+
# the config:
|
|
6
|
+
#
|
|
7
|
+
# - a stale importMap.js is a 500 on any admin screen that renders a component
|
|
8
|
+
# the map has never heard of — a plugin upgrade that swaps in a custom
|
|
9
|
+
# Publish button is enough
|
|
10
|
+
# - stale types make everything downstream compile against a schema that no
|
|
11
|
+
# longer exists
|
|
12
|
+
#
|
|
13
|
+
# Run from the repository root, with the environment payload.config.ts demands.
|
|
14
|
+
# **No database is needed** — the config parses a connection string and never
|
|
15
|
+
# reaches it — which is what lets this run on every pull request.
|
|
16
|
+
|
|
17
|
+
set -euo pipefail
|
|
18
|
+
|
|
19
|
+
TYPES='apps/web/src/payload-types.ts'
|
|
20
|
+
MAP='apps/web/src/app/(payload)/admin/importMap.js'
|
|
21
|
+
|
|
22
|
+
# `git status --porcelain` rather than `git diff`: a file that was never
|
|
23
|
+
# committed is untracked, and `git diff` says nothing about it.
|
|
24
|
+
pnpm --dir apps/web run generate:types
|
|
25
|
+
if [ -n "$(git status --porcelain -- "$TYPES")" ]; then
|
|
26
|
+
git --no-pager diff -- "$TYPES" || true
|
|
27
|
+
echo "::error::payload-types.ts is stale or uncommitted — run 'pnpm --dir apps/web generate:types' and commit it."
|
|
28
|
+
exit 1
|
|
29
|
+
fi
|
|
30
|
+
|
|
31
|
+
# importMap.js is compared as a *set* of lines. Payload emits plugin entries in
|
|
32
|
+
# an order that differs between macOS and Linux, so a byte comparison fails on a
|
|
33
|
+
# file whose content is identical. Order does not matter to correctness; a
|
|
34
|
+
# missing entry does, and that is what this catches.
|
|
35
|
+
sort "$MAP" > "${RUNNER_TEMP:-/tmp}/importmap-committed"
|
|
36
|
+
pnpm --dir apps/web run generate:importmap
|
|
37
|
+
sort "$MAP" > "${RUNNER_TEMP:-/tmp}/importmap-regenerated"
|
|
38
|
+
|
|
39
|
+
if ! diff -u "${RUNNER_TEMP:-/tmp}/importmap-committed" "${RUNNER_TEMP:-/tmp}/importmap-regenerated"; then
|
|
40
|
+
echo "::error::importMap.js is stale — run 'pnpm --dir apps/web generate:importmap' and commit it."
|
|
41
|
+
exit 1
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
git checkout -- "$MAP"
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
# Two tiers.
|
|
4
|
+
#
|
|
5
|
+
# fast every pull request and every push to main. No database: every gate
|
|
6
|
+
# here needs the config to parse, not to connect.
|
|
7
|
+
# verify pushes to main, and on demand. A Postgres service, the migrations,
|
|
8
|
+
# and a production build — `next build` boots Payload to collect page
|
|
9
|
+
# data, so it needs a migrated database.
|
|
10
|
+
#
|
|
11
|
+
# A green `fast` is what a pull request is reviewed on; `verify` is what says
|
|
12
|
+
# main still builds and migrates. Add a path filter to run `verify` on pull
|
|
13
|
+
# requests that touch the config, the migrations or the dependencies, once this
|
|
14
|
+
# project has enough of all three to make it worth the minutes.
|
|
15
|
+
|
|
16
|
+
on:
|
|
17
|
+
pull_request:
|
|
18
|
+
push:
|
|
19
|
+
branches: [main]
|
|
20
|
+
workflow_dispatch:
|
|
21
|
+
|
|
22
|
+
concurrency:
|
|
23
|
+
group: ci-${{ github.ref }}
|
|
24
|
+
cancel-in-progress: true
|
|
25
|
+
|
|
26
|
+
# What payload.config.ts and the plugins check at load, as placeholders. The
|
|
27
|
+
# config parses the connection string and never connects in `fast`; secrets only
|
|
28
|
+
# have to be long enough to pass the plugins' own checks. Never a real value.
|
|
29
|
+
env:
|
|
30
|
+
DATABASE_URI: postgres://ci:ci@localhost:5432/ci
|
|
31
|
+
PAYLOAD_SECRET: ci-placeholder-not-a-secret-000000000000
|
|
32
|
+
APPROVAL_TOKEN_SECRET: ci-placeholder-not-a-secret-000000000000
|
|
33
|
+
FORMS_IP_HASH_SECRET: ci-placeholder-not-a-secret-000000000000
|
|
34
|
+
RESEND_API_KEY: re_ci_placeholder
|
|
35
|
+
EMAIL_FROM_ADDRESS: ci@example.com
|
|
36
|
+
NEXT_PUBLIC_SERVER_URL: http://localhost:3000
|
|
37
|
+
|
|
38
|
+
jobs:
|
|
39
|
+
fast:
|
|
40
|
+
runs-on: ubuntu-latest
|
|
41
|
+
timeout-minutes: 20
|
|
42
|
+
steps:
|
|
43
|
+
- uses: actions/checkout@v4
|
|
44
|
+
|
|
45
|
+
- uses: pnpm/action-setup@v4
|
|
46
|
+
|
|
47
|
+
- uses: actions/setup-node@v4
|
|
48
|
+
with:
|
|
49
|
+
node-version: 22
|
|
50
|
+
cache: pnpm
|
|
51
|
+
|
|
52
|
+
# First, because a lockfile missing an entry passes every other gate
|
|
53
|
+
# locally and fails here.
|
|
54
|
+
- name: Install
|
|
55
|
+
run: pnpm install --frozen-lockfile
|
|
56
|
+
|
|
57
|
+
- name: Typecheck
|
|
58
|
+
run: pnpm typecheck
|
|
59
|
+
|
|
60
|
+
- name: Lint
|
|
61
|
+
run: pnpm lint
|
|
62
|
+
|
|
63
|
+
- name: Check singletons resolve once
|
|
64
|
+
run: pnpm check:instances
|
|
65
|
+
|
|
66
|
+
- name: Check every package that ships code has tests
|
|
67
|
+
run: pnpm check:tested
|
|
68
|
+
|
|
69
|
+
- name: Check the migration chain
|
|
70
|
+
run: pnpm check:migrations
|
|
71
|
+
|
|
72
|
+
# Builds the design system's manifest and lints it against the contract:
|
|
73
|
+
# an unknown token, a composition rule naming a missing component, an
|
|
74
|
+
# example pointing at a story that does not exist.
|
|
75
|
+
- name: Validate the design-system manifest
|
|
76
|
+
run: pnpm validate
|
|
77
|
+
|
|
78
|
+
- name: Check generated Payload artifacts are current
|
|
79
|
+
run: bash .github/scripts/check-generated.sh
|
|
80
|
+
|
|
81
|
+
- name: Test
|
|
82
|
+
run: pnpm test
|
|
83
|
+
|
|
84
|
+
# Non-blocking: a new advisory is news, not necessarily a defect, and
|
|
85
|
+
# blocking every open pull request the morning a CVE is published is how
|
|
86
|
+
# a gate gets removed rather than fixed. Read it.
|
|
87
|
+
- name: Audit production dependencies
|
|
88
|
+
run: pnpm check:audit
|
|
89
|
+
continue-on-error: true
|
|
90
|
+
|
|
91
|
+
verify:
|
|
92
|
+
if: github.event_name != 'pull_request'
|
|
93
|
+
runs-on: ubuntu-latest
|
|
94
|
+
timeout-minutes: 25
|
|
95
|
+
|
|
96
|
+
services:
|
|
97
|
+
postgres:
|
|
98
|
+
image: postgres:17-alpine
|
|
99
|
+
env:
|
|
100
|
+
POSTGRES_USER: ci
|
|
101
|
+
POSTGRES_PASSWORD: ci
|
|
102
|
+
POSTGRES_DB: ci
|
|
103
|
+
ports:
|
|
104
|
+
- 5432:5432
|
|
105
|
+
options: >-
|
|
106
|
+
--health-cmd pg_isready
|
|
107
|
+
--health-interval 10s
|
|
108
|
+
--health-timeout 5s
|
|
109
|
+
--health-retries 5
|
|
110
|
+
|
|
111
|
+
steps:
|
|
112
|
+
- uses: actions/checkout@v4
|
|
113
|
+
|
|
114
|
+
- uses: pnpm/action-setup@v4
|
|
115
|
+
|
|
116
|
+
- uses: actions/setup-node@v4
|
|
117
|
+
with:
|
|
118
|
+
node-version: 22
|
|
119
|
+
cache: pnpm
|
|
120
|
+
|
|
121
|
+
- name: Install
|
|
122
|
+
run: pnpm install --frozen-lockfile
|
|
123
|
+
|
|
124
|
+
# A fresh database, migrated exactly as a deploy would migrate it. A
|
|
125
|
+
# migration that does not apply cleanly from empty fails here rather than
|
|
126
|
+
# on the first environment somebody creates.
|
|
127
|
+
- name: Migrate
|
|
128
|
+
run: pnpm --dir apps/web migrate
|
|
129
|
+
|
|
130
|
+
- name: Build
|
|
131
|
+
run: pnpm build
|
|
@@ -7,14 +7,19 @@
|
|
|
7
7
|
"dev": "{{#if useReferenceDs}}pnpm --filter \"./design-system\" build && {{/if}}turbo run dev",
|
|
8
8
|
"storybook": "turbo run storybook",
|
|
9
9
|
"build-storybook": "turbo run build-storybook",
|
|
10
|
-
"validate": "turbo run validate",
|
|
10
|
+
"validate": "turbo run validate --filter=./design-system",
|
|
11
11
|
"lint": "turbo run lint",
|
|
12
12
|
"typecheck": "turbo run typecheck",
|
|
13
13
|
"test": "turbo run test",
|
|
14
|
+
"check:instances": "tsx scripts/check-single-instance.ts",
|
|
15
|
+
"check:tested": "tsx scripts/check-tested-packages.ts",
|
|
16
|
+
"check:audit": "tsx scripts/check-audit.ts",
|
|
17
|
+
"check:migrations": "pnpm --dir apps/web run check:migrations",
|
|
14
18
|
"format": "prettier --write ."
|
|
15
19
|
},
|
|
16
20
|
"devDependencies": {
|
|
17
21
|
"prettier": "^3.4.0",
|
|
22
|
+
"tsx": "^4.21.0",
|
|
18
23
|
"turbo": "^2.3.0",
|
|
19
24
|
"typescript": "^5.6.0"
|
|
20
25
|
},
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
`pnpm audit` over the production tree, with an allowlist that cannot rot.
|
|
5
|
+
|
|
6
|
+
pnpm check:audit
|
|
7
|
+
|
|
8
|
+
**Why not `pnpm audit --prod --audit-level=high` on its own.** A Payload + Next
|
|
9
|
+
production tree is rarely clean: there are usually high advisories in it that no
|
|
10
|
+
request the site serves can reach. A gate that is red on every run is a gate
|
|
11
|
+
people stop reading.
|
|
12
|
+
|
|
13
|
+
**Why not `pnpm.auditConfig.ignoreGhsas`.** It filters, and that is all it does.
|
|
14
|
+
A list of ids in `package.json` carries no reason, and nothing notices when one
|
|
15
|
+
stops applying — the dependency is dropped or patched, and the entry sits there
|
|
16
|
+
suppressing a finding it no longer describes.
|
|
17
|
+
|
|
18
|
+
So this asserts the allowlist in both directions. An advisory that is not on the
|
|
19
|
+
list is reported. An entry that no longer appears in the audit is *also*
|
|
20
|
+
reported, as something to delete. That second half is why this is a script.
|
|
21
|
+
|
|
22
|
+
It is non-blocking in CI. A new advisory is news, not necessarily a defect, and
|
|
23
|
+
blocking every open pull request the morning a CVE is published is how a gate
|
|
24
|
+
gets removed rather than fixed.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
interface Allowed {
|
|
28
|
+
ghsa: string
|
|
29
|
+
module: string
|
|
30
|
+
/** Which path it is on, and why that path cannot reach a request. */
|
|
31
|
+
why: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* High and critical advisories in the production tree that are accepted.
|
|
36
|
+
*
|
|
37
|
+
* "Not exploitable" is not a reason. Say which dependency path it arrives on
|
|
38
|
+
* and what would have to be true for a request to reach it.
|
|
39
|
+
*/
|
|
40
|
+
const ALLOWED: Allowed[] = []
|
|
41
|
+
|
|
42
|
+
interface Advisory {
|
|
43
|
+
severity: string
|
|
44
|
+
module_name: string
|
|
45
|
+
title: string
|
|
46
|
+
github_advisory_id?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function audit(): Advisory[] {
|
|
50
|
+
let stdout: string
|
|
51
|
+
try {
|
|
52
|
+
// Exits non-zero whenever it finds anything at or above the level, so the
|
|
53
|
+
// throw is the normal case and the output is on it.
|
|
54
|
+
stdout = execFileSync('pnpm', ['audit', '--prod', '--audit-level=high', '--json'], {
|
|
55
|
+
encoding: 'utf8',
|
|
56
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
57
|
+
})
|
|
58
|
+
} catch (error) {
|
|
59
|
+
stdout = (error as { stdout?: string }).stdout ?? ''
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (stdout.trim() === '') return []
|
|
63
|
+
const parsed = JSON.parse(stdout) as { advisories?: Record<string, Advisory> }
|
|
64
|
+
// One advisory reached through several dependency paths is reported once
|
|
65
|
+
// per path; it is one finding.
|
|
66
|
+
const unique = new Map<string, Advisory>()
|
|
67
|
+
for (const advisory of Object.values(parsed.advisories ?? {})) {
|
|
68
|
+
if (advisory.severity !== 'high' && advisory.severity !== 'critical') continue
|
|
69
|
+
unique.set(advisory.github_advisory_id ?? `${advisory.module_name}:${advisory.title}`, advisory)
|
|
70
|
+
}
|
|
71
|
+
return [...unique.values()]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function main(): void {
|
|
75
|
+
const found = audit()
|
|
76
|
+
const seen = new Set(found.map((a) => a.github_advisory_id).filter(Boolean) as string[])
|
|
77
|
+
const allowed = new Map(ALLOWED.map((entry) => [entry.ghsa, entry]))
|
|
78
|
+
|
|
79
|
+
const unexpected = found.filter(
|
|
80
|
+
(a) => !a.github_advisory_id || !allowed.has(a.github_advisory_id),
|
|
81
|
+
)
|
|
82
|
+
const stale = ALLOWED.filter((entry) => !seen.has(entry.ghsa))
|
|
83
|
+
|
|
84
|
+
for (const advisory of found) {
|
|
85
|
+
if (advisory.github_advisory_id && allowed.has(advisory.github_advisory_id)) {
|
|
86
|
+
console.log(` = ${advisory.module_name.padEnd(18)} ${advisory.github_advisory_id} accepted`)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (unexpected.length > 0) {
|
|
91
|
+
console.log(`\n${unexpected.length} advisory/advisories not on the allowlist:\n`)
|
|
92
|
+
for (const advisory of unexpected) {
|
|
93
|
+
console.log(` ! ${advisory.severity.padEnd(8)} ${advisory.module_name}`)
|
|
94
|
+
console.log(` ${advisory.github_advisory_id ?? '(no GHSA id)'} — ${advisory.title}`)
|
|
95
|
+
}
|
|
96
|
+
console.log(
|
|
97
|
+
'\nEither upgrade past it, or add it to ALLOWED in scripts/check-audit.ts with the\n' +
|
|
98
|
+
'dependency path it arrives on and why no request can reach it.',
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (stale.length > 0) {
|
|
103
|
+
console.log(
|
|
104
|
+
`\n${stale.length} allowlist entry/entries no longer apply and should be deleted:\n`,
|
|
105
|
+
)
|
|
106
|
+
for (const entry of stale) console.log(` - ${entry.ghsa} (${entry.module})`)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (unexpected.length === 0 && stale.length === 0) {
|
|
110
|
+
console.log(`\nNothing new. ${found.length} known advisory/advisories, each with a reason.`)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
process.exitCode = 1
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
main()
|