astroidjs 0.15.0 → 0.17.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 +7 -5
- package/bin/astroid.mjs +357 -5
- package/dist/auth/index.js +2 -2
- package/dist/commerce/roles.js +1 -1
- package/dist/commerce/sync.js +2 -1
- package/dist/config.js +9 -9
- package/dist/portfolio/scaffold.js +5 -1
- package/dist/project/actions.js +6 -1
- package/dist/project/generate.js +4 -3
- package/dist/project/index.d.ts +3 -0
- package/dist/project/index.js +3 -0
- package/dist/project/previews.d.ts +18 -0
- package/dist/project/previews.js +215 -0
- package/dist/project/provision.d.ts +77 -0
- package/dist/project/provision.js +135 -0
- package/dist/project/release.d.ts +12 -0
- package/dist/project/release.js +118 -0
- package/dist/queues/scaffold.js +5 -0
- package/dist/queues/webhook.d.ts +16 -0
- package/dist/queues/webhook.js +20 -3
- package/dist/schema/collections.js +9 -8
- package/dist/schema/index.d.ts +1 -0
- package/dist/schema/index.js +1 -0
- package/dist/schema/media-base.d.ts +13 -0
- package/dist/schema/media-base.js +33 -0
- package/dist/worker/generate.js +44 -18
- package/package.json +3 -3
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// The `previews` block check `astroid doctor` runs (louise-toolkit ADR 0017).
|
|
4
|
+
//
|
|
5
|
+
// A site's staging is Cloudflare's Worker Previews: `main` and every pull
|
|
6
|
+
// request run as Previews of the one Worker, with the settings in the
|
|
7
|
+
// `previews` block of `wrangler.jsonc`. Previews inherit NOTHING from the top
|
|
8
|
+
// level, which is the point and also the trap:
|
|
9
|
+
//
|
|
10
|
+
// - A binding the code reads that the block leaves out is `undefined` on a
|
|
11
|
+
// Preview, and the Worker throws (Cloudflare's error 1101).
|
|
12
|
+
// - A binding the block copies verbatim points a Preview at production's
|
|
13
|
+
// database or bucket, so a branch writes production data. That's the
|
|
14
|
+
// failure staging exists to prevent, and nothing else would catch it.
|
|
15
|
+
// - Crons, routes, and queue consumers don't target Previews, so putting
|
|
16
|
+
// them in the block does nothing and reads as if it did.
|
|
17
|
+
//
|
|
18
|
+
// Pure: text in, findings out, so it's tested directly and the CLI only prints.
|
|
19
|
+
/**
|
|
20
|
+
* Parse JSONC: strip `//` and block comments outside strings, then trailing
|
|
21
|
+
* commas. `wrangler.jsonc` is written by hand and commented heavily, which is
|
|
22
|
+
* why `doctor` reads the rest of it by regex; this check needs the structure.
|
|
23
|
+
*/
|
|
24
|
+
export function parseJsonc(text) {
|
|
25
|
+
let out = "";
|
|
26
|
+
let inString = false;
|
|
27
|
+
for (let i = 0; i < text.length; i++) {
|
|
28
|
+
const c = text[i];
|
|
29
|
+
const next = text[i + 1];
|
|
30
|
+
if (inString) {
|
|
31
|
+
out += c;
|
|
32
|
+
if (c === "\\")
|
|
33
|
+
out += text[++i] ?? "";
|
|
34
|
+
else if (c === '"')
|
|
35
|
+
inString = false;
|
|
36
|
+
}
|
|
37
|
+
else if (c === '"') {
|
|
38
|
+
inString = true;
|
|
39
|
+
out += c;
|
|
40
|
+
}
|
|
41
|
+
else if (c === "/" && next === "/") {
|
|
42
|
+
while (i < text.length && text[i] !== "\n")
|
|
43
|
+
i++;
|
|
44
|
+
out += "\n";
|
|
45
|
+
}
|
|
46
|
+
else if (c === "/" && next === "*") {
|
|
47
|
+
i += 2;
|
|
48
|
+
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
|
|
49
|
+
i++;
|
|
50
|
+
i++;
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
out += c;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1"));
|
|
57
|
+
}
|
|
58
|
+
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
59
|
+
const list = (v) => (Array.isArray(v) ? v.filter(isObject) : []);
|
|
60
|
+
const STORAGE = [
|
|
61
|
+
{
|
|
62
|
+
what: "D1 database",
|
|
63
|
+
get: (c) => list(c.d1_databases),
|
|
64
|
+
name: (b) => b.binding,
|
|
65
|
+
resource: (b) => String(b.database_id ?? b.database_name ?? ""),
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
what: "KV namespace",
|
|
69
|
+
get: (c) => list(c.kv_namespaces),
|
|
70
|
+
name: (b) => b.binding,
|
|
71
|
+
resource: (b) => String(b.id ?? ""),
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
what: "R2 bucket",
|
|
75
|
+
get: (c) => list(c.r2_buckets),
|
|
76
|
+
name: (b) => b.binding,
|
|
77
|
+
resource: (b) => String(b.bucket_name ?? ""),
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
what: "Secrets Store secret",
|
|
81
|
+
get: (c) => list(c.secrets_store_secrets),
|
|
82
|
+
name: (b) => b.binding,
|
|
83
|
+
resource: (b) => `${String(b.store_id ?? "")}/${String(b.secret_name ?? "")}`,
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
what: "Analytics Engine dataset",
|
|
87
|
+
get: (c) => list(c.analytics_engine_datasets),
|
|
88
|
+
name: (b) => b.binding,
|
|
89
|
+
resource: (b) => String(b.dataset ?? ""),
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
what: "Vectorize index",
|
|
93
|
+
get: (c) => list(c.vectorize),
|
|
94
|
+
name: (b) => b.binding,
|
|
95
|
+
resource: (b) => String(b.index_name ?? ""),
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
what: "queue producer",
|
|
99
|
+
get: (c) => list(isObject(c.queues) ? c.queues.producers : undefined),
|
|
100
|
+
name: (b) => b.binding,
|
|
101
|
+
resource: (b) => String(b.queue ?? ""),
|
|
102
|
+
optional: "queue consumers can't target a Preview, so a Preview leaves the queue unbound and the side effects run inline",
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
what: "Workflow",
|
|
106
|
+
get: (c) => list(c.workflows),
|
|
107
|
+
name: (b) => b.binding,
|
|
108
|
+
resource: (b) => String(b.name ?? ""),
|
|
109
|
+
optional: "a Preview calls the production Workflow's code and bindings, so it leaves the binding out and falls back",
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
/** Bindings with no staging resource behind them: a Preview needs the key, and
|
|
113
|
+
* nothing to provision. A Durable Object namespace is one: Cloudflare gives
|
|
114
|
+
* each Preview its own instances of the class. */
|
|
115
|
+
const API_BINDINGS = [
|
|
116
|
+
"ai",
|
|
117
|
+
"images",
|
|
118
|
+
"browser",
|
|
119
|
+
"send_email",
|
|
120
|
+
"version_metadata",
|
|
121
|
+
"durable_objects",
|
|
122
|
+
];
|
|
123
|
+
/**
|
|
124
|
+
* Check the `previews` block of a `wrangler.jsonc` against its production
|
|
125
|
+
* settings. Returns what passed and what didn't; an error means a Preview
|
|
126
|
+
* would crash or touch production.
|
|
127
|
+
*/
|
|
128
|
+
export function checkWranglerPreviews(text) {
|
|
129
|
+
const findings = { ok: [], errors: [], warnings: [] };
|
|
130
|
+
let config;
|
|
131
|
+
try {
|
|
132
|
+
const parsed = parseJsonc(text);
|
|
133
|
+
if (!isObject(parsed))
|
|
134
|
+
throw new Error("not an object");
|
|
135
|
+
config = parsed;
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
findings.errors.push(`wrangler.jsonc doesn't parse (${err.message}).`);
|
|
139
|
+
return findings;
|
|
140
|
+
}
|
|
141
|
+
const previews = config.previews;
|
|
142
|
+
if (!isObject(previews)) {
|
|
143
|
+
findings.warnings.push("wrangler.jsonc has no `previews` block, so this site has no staging. Branch builds " +
|
|
144
|
+
"either fail or run with production's data (louise-toolkit ADR 0017).");
|
|
145
|
+
return findings;
|
|
146
|
+
}
|
|
147
|
+
for (const key of ["triggers", "routes"]) {
|
|
148
|
+
if (key in previews) {
|
|
149
|
+
findings.errors.push(`\`previews.${key}\` does nothing: ${key === "triggers" ? "Cron Triggers" : "routes"} ` +
|
|
150
|
+
"target production only. Remove it; Preview hosts come from a route with " +
|
|
151
|
+
"`previews_enabled` at the top level.");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (isObject(previews.queues) && "consumers" in previews.queues) {
|
|
155
|
+
findings.errors.push("`previews.queues.consumers` does nothing: queue consumers can't target a Preview. Remove it.");
|
|
156
|
+
}
|
|
157
|
+
for (const kind of STORAGE) {
|
|
158
|
+
const staging = new Map(kind.get(previews).map((b) => [kind.name(b), b]));
|
|
159
|
+
for (const prod of kind.get(config)) {
|
|
160
|
+
const name = String(kind.name(prod));
|
|
161
|
+
const preview = staging.get(kind.name(prod));
|
|
162
|
+
if (!preview) {
|
|
163
|
+
if (kind.optional)
|
|
164
|
+
findings.ok.push(`previews: ${kind.what} \`${name}\` left out (${kind.optional})`);
|
|
165
|
+
else
|
|
166
|
+
findings.errors.push(`\`previews\` has no ${kind.what} \`${name}\`. A Preview inherits nothing, so code ` +
|
|
167
|
+
`that reads \`env.${name}\` throws there. Bind it to a staging ${kind.what}.`);
|
|
168
|
+
}
|
|
169
|
+
else if (kind.resource(preview) === kind.resource(prod)) {
|
|
170
|
+
findings.errors.push(`\`previews\` binds ${kind.what} \`${name}\` to production's (${kind.resource(prod)}), ` +
|
|
171
|
+
"so every branch would read and write production data. Bind it to a staging one.");
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
findings.ok.push(`previews: ${kind.what} \`${name}\` bound to a staging resource`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
for (const key of API_BINDINGS) {
|
|
179
|
+
if (!(key in config))
|
|
180
|
+
continue;
|
|
181
|
+
if (key in previews)
|
|
182
|
+
findings.ok.push(`previews: \`${key}\` binding present`);
|
|
183
|
+
else
|
|
184
|
+
findings.errors.push(`\`previews\` has no \`${key}\` binding. A Preview inherits nothing, so code that uses ` +
|
|
185
|
+
"it throws there. Copy the binding into `previews`; it needs no staging resource.");
|
|
186
|
+
}
|
|
187
|
+
const prodVars = isObject(config.vars) ? config.vars : {};
|
|
188
|
+
const stagingVars = isObject(previews.vars) ? previews.vars : {};
|
|
189
|
+
const missingVars = Object.keys(prodVars).filter((key) => !(key in stagingVars));
|
|
190
|
+
if (missingVars.length) {
|
|
191
|
+
findings.errors.push(`\`previews.vars\` is missing ${missingVars.map((v) => `\`${v}\``).join(", ")}. Vars aren't ` +
|
|
192
|
+
"inherited, so each is undefined on a Preview. Give each a staging value.");
|
|
193
|
+
}
|
|
194
|
+
else if (Object.keys(prodVars).length) {
|
|
195
|
+
findings.ok.push(`previews: all ${Object.keys(prodVars).length} vars have a staging value`);
|
|
196
|
+
}
|
|
197
|
+
for (const key of ["SITE_URL", "MEDIA_URL"]) {
|
|
198
|
+
const value = prodVars[key];
|
|
199
|
+
// A path such as `/media` resolves against whichever host serves it, so a
|
|
200
|
+
// Preview sharing production's path still serves its own bucket.
|
|
201
|
+
const isOrigin = typeof value === "string" && /^https?:\/\//.test(value);
|
|
202
|
+
if (key in prodVars && isOrigin && stagingVars[key] === value) {
|
|
203
|
+
findings.errors.push(`\`previews.vars.${key}\` is production's (${String(prodVars[key])}), so a Preview ` +
|
|
204
|
+
"links to or serves from production. Point it at staging.");
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const previewHost = list(config.routes).find((r) => r.previews_enabled === true);
|
|
208
|
+
if (previewHost)
|
|
209
|
+
findings.ok.push(`previews: served on \`<name>.${String(previewHost.pattern)}\``);
|
|
210
|
+
else
|
|
211
|
+
findings.warnings.push("No route has `previews_enabled`, so Previews are reachable only on workers.dev. Add a " +
|
|
212
|
+
'preview-only custom domain: `{ "pattern": "staging.example.com", "custom_domain": true, ' +
|
|
213
|
+
'"previews_enabled": true, "enabled": false }`.');
|
|
214
|
+
return findings;
|
|
215
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/** One resource to create. `placeholder` is the text its ID replaces. */
|
|
2
|
+
export interface ProvisionStep {
|
|
3
|
+
kind: "d1" | "kv" | "r2";
|
|
4
|
+
name: string;
|
|
5
|
+
/** The `wrangler` arguments that create it. */
|
|
6
|
+
args: string[];
|
|
7
|
+
placeholder?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The value provision gives a staging secret it creates: `random` is a fresh
|
|
11
|
+
* random value for each site, and `turnstile-test` is
|
|
12
|
+
* {@link TURNSTILE_TEST_SECRET}.
|
|
13
|
+
*/
|
|
14
|
+
export type StagingSecretValue = "random" | "turnstile-test";
|
|
15
|
+
/**
|
|
16
|
+
* The staging secrets provision creates, by the binding name in the `previews`
|
|
17
|
+
* block. Nothing else is created: a secret with any other binding, and every
|
|
18
|
+
* production secret, is left for a person.
|
|
19
|
+
*/
|
|
20
|
+
export declare const ASTROID_STAGING_SECRET_VALUES: Readonly<Record<string, StagingSecretValue>>;
|
|
21
|
+
/**
|
|
22
|
+
* Cloudflare's Turnstile test secret key, which passes every token. Staging
|
|
23
|
+
* uses it so a Preview's forms and sign-in work without a real widget.
|
|
24
|
+
*/
|
|
25
|
+
export declare const TURNSTILE_TEST_SECRET = "1x0000000000000000000000000000000AA";
|
|
26
|
+
/** A Secrets Store secret the config binds. */
|
|
27
|
+
export interface ProvisionSecret {
|
|
28
|
+
binding: string;
|
|
29
|
+
storeId: string;
|
|
30
|
+
secretName: string;
|
|
31
|
+
/** `production` for a top-level binding, `staging` for one in `previews`. */
|
|
32
|
+
environment: "production" | "staging";
|
|
33
|
+
/**
|
|
34
|
+
* Set when provision creates the secret itself, and says what value it gets.
|
|
35
|
+
* Absent means only a person can set it.
|
|
36
|
+
*/
|
|
37
|
+
create?: StagingSecretValue;
|
|
38
|
+
}
|
|
39
|
+
export interface ProvisionPlan {
|
|
40
|
+
steps: ProvisionStep[];
|
|
41
|
+
secrets: ProvisionSecret[];
|
|
42
|
+
/** Whether `account_id` is set, so wrangler doesn't have to pick one. */
|
|
43
|
+
hasAccount: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* The `account_id` from `wrangler.jsonc`, when it's set. Wrangler prefers it
|
|
46
|
+
* to `CLOUDFLARE_ACCOUNT_ID`, so every command provision runs uses it.
|
|
47
|
+
*/
|
|
48
|
+
accountId?: string;
|
|
49
|
+
}
|
|
50
|
+
/** Build the plan from a `wrangler.jsonc`, top level and `previews` alike. */
|
|
51
|
+
export declare function provisionPlan(text: string): ProvisionPlan;
|
|
52
|
+
/** Put a created resource's ID in place of its placeholder, everywhere. */
|
|
53
|
+
export declare function applyProvisionedId(text: string, placeholder: string, id: string): string;
|
|
54
|
+
/** What provision does with a staging secret it creates itself. */
|
|
55
|
+
export interface StagingSecretStep {
|
|
56
|
+
secret: ProvisionSecret & {
|
|
57
|
+
create: StagingSecretValue;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* `create` when the store doesn't have it, and `exists` when it does, so a
|
|
61
|
+
* re-run leaves it alone. `unknown` when the store couldn't be listed, so
|
|
62
|
+
* provision can't tell and creates nothing.
|
|
63
|
+
*/
|
|
64
|
+
status: "create" | "exists" | "unknown";
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Decide, for each staging secret provision creates, whether it still needs
|
|
68
|
+
* creating. `existing` maps a store ID to the secret names already in it; a
|
|
69
|
+
* store missing from the map couldn't be listed.
|
|
70
|
+
*/
|
|
71
|
+
export declare function stagingSecretSteps(secrets: readonly ProvisionSecret[], existing: ReadonlyMap<string, ReadonlySet<string>>): StagingSecretStep[];
|
|
72
|
+
/**
|
|
73
|
+
* The secret names in the output of `wrangler secrets-store secret list`.
|
|
74
|
+
* Wrangler prints a table rather than JSON, with the name in the first column,
|
|
75
|
+
* so this reads the first cell of each row and drops the header.
|
|
76
|
+
*/
|
|
77
|
+
export declare function secretNamesFromList(output: string): string[];
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// The plan `astroid provision` runs: which Cloudflare resources a site's
|
|
4
|
+
// `wrangler.jsonc` still names by placeholder, and which secrets it binds.
|
|
5
|
+
//
|
|
6
|
+
// A placeholder names the command that creates its resource, such as
|
|
7
|
+
// `<run: wrangler d1 create acme-staging>`, so the plan reads it rather than
|
|
8
|
+
// guessing a name. The same placeholder can stand for two bindings that share
|
|
9
|
+
// one namespace, and replacing the text everywhere it appears fills both.
|
|
10
|
+
// Buckets carry a name instead of an ID, so every bucket the file names is
|
|
11
|
+
// created, and one that already exists is fine.
|
|
12
|
+
//
|
|
13
|
+
// Two staging secrets need no person, so the plan marks them for provision to
|
|
14
|
+
// create: the session secret, which can be any random value as long as it isn't
|
|
15
|
+
// production's, and the Turnstile secret, which is Cloudflare's test secret that
|
|
16
|
+
// always passes. Every other secret, and every production one, still needs a
|
|
17
|
+
// person, so the CLI prints those instead.
|
|
18
|
+
//
|
|
19
|
+
// Pure: text in, plan out, so it's tested directly and the CLI only runs it.
|
|
20
|
+
import { parseJsonc } from "./previews.js";
|
|
21
|
+
/**
|
|
22
|
+
* The staging secrets provision creates, by the binding name in the `previews`
|
|
23
|
+
* block. Nothing else is created: a secret with any other binding, and every
|
|
24
|
+
* production secret, is left for a person.
|
|
25
|
+
*/
|
|
26
|
+
export const ASTROID_STAGING_SECRET_VALUES = {
|
|
27
|
+
SESSION_SECRET: "random",
|
|
28
|
+
// deepcode ignore HardcodedNonCryptoSecret: A value kind, not a credential.
|
|
29
|
+
TURNSTILE_SECRET: "turnstile-test",
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Cloudflare's Turnstile test secret key, which passes every token. Staging
|
|
33
|
+
* uses it so a Preview's forms and sign-in work without a real widget.
|
|
34
|
+
*/
|
|
35
|
+
// deepcode ignore HardcodedNonCryptoSecret: Cloudflare's public Turnstile test secret, not a credential.
|
|
36
|
+
export const TURNSTILE_TEST_SECRET = "1x0000000000000000000000000000000AA";
|
|
37
|
+
const PLACEHOLDER = /<run:\s*wrangler\s+(d1\s+create|kv\s+namespace\s+create)\s+([^\s>]+)\s*>/g;
|
|
38
|
+
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
39
|
+
const list = (v) => (Array.isArray(v) ? v.filter(isObject) : []);
|
|
40
|
+
/** Whether a config value is filled in, rather than empty or a `<...>` placeholder. */
|
|
41
|
+
const isReal = (v) => v.length > 0 && !v.startsWith("<");
|
|
42
|
+
/** Build the plan from a `wrangler.jsonc`, top level and `previews` alike. */
|
|
43
|
+
export function provisionPlan(text) {
|
|
44
|
+
const steps = [];
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
for (const match of text.matchAll(PLACEHOLDER)) {
|
|
47
|
+
const placeholder = match[0];
|
|
48
|
+
if (seen.has(placeholder))
|
|
49
|
+
continue;
|
|
50
|
+
seen.add(placeholder);
|
|
51
|
+
const name = match[2];
|
|
52
|
+
steps.push(match[1].startsWith("d1")
|
|
53
|
+
? { kind: "d1", name, args: ["d1", "create", name], placeholder }
|
|
54
|
+
: { kind: "kv", name, args: ["kv", "namespace", "create", name], placeholder });
|
|
55
|
+
}
|
|
56
|
+
const config = parseJsonc(text);
|
|
57
|
+
const top = isObject(config) ? config : {};
|
|
58
|
+
const previews = isObject(top.previews) ? top.previews : {};
|
|
59
|
+
const buckets = new Set([...list(top.r2_buckets), ...list(previews.r2_buckets)]
|
|
60
|
+
.map((b) => b.bucket_name)
|
|
61
|
+
.filter((name) => typeof name === "string" && !name.startsWith("<")));
|
|
62
|
+
for (const name of buckets) {
|
|
63
|
+
steps.push({ kind: "r2", name, args: ["r2", "bucket", "create", name] });
|
|
64
|
+
}
|
|
65
|
+
const secrets = [];
|
|
66
|
+
for (const [environment, section] of [
|
|
67
|
+
["production", top],
|
|
68
|
+
["staging", previews],
|
|
69
|
+
]) {
|
|
70
|
+
for (const s of list(section.secrets_store_secrets)) {
|
|
71
|
+
const secret = {
|
|
72
|
+
binding: String(s.binding ?? ""),
|
|
73
|
+
storeId: String(s.store_id ?? ""),
|
|
74
|
+
secretName: String(s.secret_name ?? ""),
|
|
75
|
+
environment,
|
|
76
|
+
};
|
|
77
|
+
const create = Object.hasOwn(ASTROID_STAGING_SECRET_VALUES, secret.binding)
|
|
78
|
+
? ASTROID_STAGING_SECRET_VALUES[secret.binding]
|
|
79
|
+
: undefined;
|
|
80
|
+
// Only staging, and only against a real store and name: a placeholder
|
|
81
|
+
// can't be created against, so it's left for a person.
|
|
82
|
+
if (environment === "staging" &&
|
|
83
|
+
create &&
|
|
84
|
+
isReal(secret.storeId) &&
|
|
85
|
+
isReal(secret.secretName)) {
|
|
86
|
+
secret.create = create;
|
|
87
|
+
}
|
|
88
|
+
secrets.push(secret);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const account = top.account_id;
|
|
92
|
+
const hasAccount = typeof account === "string" && isReal(account);
|
|
93
|
+
return { steps, secrets, hasAccount, ...(hasAccount ? { accountId: account } : {}) };
|
|
94
|
+
}
|
|
95
|
+
/** Put a created resource's ID in place of its placeholder, everywhere. */
|
|
96
|
+
export function applyProvisionedId(text, placeholder, id) {
|
|
97
|
+
return text.split(placeholder).join(id);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Decide, for each staging secret provision creates, whether it still needs
|
|
101
|
+
* creating. `existing` maps a store ID to the secret names already in it; a
|
|
102
|
+
* store missing from the map couldn't be listed.
|
|
103
|
+
*/
|
|
104
|
+
export function stagingSecretSteps(secrets, existing) {
|
|
105
|
+
const steps = [];
|
|
106
|
+
for (const secret of secrets) {
|
|
107
|
+
if (!secret.create)
|
|
108
|
+
continue;
|
|
109
|
+
const names = existing.get(secret.storeId);
|
|
110
|
+
steps.push({
|
|
111
|
+
secret: { ...secret, create: secret.create },
|
|
112
|
+
status: !names ? "unknown" : names.has(secret.secretName) ? "exists" : "create",
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return steps;
|
|
116
|
+
}
|
|
117
|
+
// Wrangler colors the table on a terminal: an escape, then `[`, digits, and `m`.
|
|
118
|
+
const ANSI_COLOR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
|
|
119
|
+
/**
|
|
120
|
+
* The secret names in the output of `wrangler secrets-store secret list`.
|
|
121
|
+
* Wrangler prints a table rather than JSON, with the name in the first column,
|
|
122
|
+
* so this reads the first cell of each row and drops the header.
|
|
123
|
+
*/
|
|
124
|
+
export function secretNamesFromList(output) {
|
|
125
|
+
const names = [];
|
|
126
|
+
for (const line of output.replace(ANSI_COLOR, "").split("\n")) {
|
|
127
|
+
const trimmed = line.trim();
|
|
128
|
+
if (!trimmed.startsWith("│"))
|
|
129
|
+
continue;
|
|
130
|
+
const name = trimmed.split("│")[1]?.trim();
|
|
131
|
+
if (name && name !== "Name")
|
|
132
|
+
names.push(name);
|
|
133
|
+
}
|
|
134
|
+
return names;
|
|
135
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** The branch Workers Builds deploys to production from. */
|
|
2
|
+
export declare const ASTROID_DEPLOY_BRANCH = "deploy/production";
|
|
3
|
+
/** Where the workflow lives, relative to the repository root. */
|
|
4
|
+
export declare const ASTROID_RELEASE_WORKFLOW_PATH = ".github/workflows/release.yml";
|
|
5
|
+
/** The Actions variable that holds the release app's App ID. */
|
|
6
|
+
export declare const ASTROID_RELEASE_APP_ID_VAR = "RELEASE_APP_ID";
|
|
7
|
+
/** The Actions secret that holds the release app's private key. */
|
|
8
|
+
export declare const ASTROID_RELEASE_APP_KEY_SECRET = "RELEASE_APP_PRIVATE_KEY";
|
|
9
|
+
/** Where the one-time release setup is written down. */
|
|
10
|
+
export declare const ASTROID_RELEASE_SETUP_URL = "https://docs.astroidjs.org/guide/releases/";
|
|
11
|
+
/** The release workflow's contents. Pure, and the same for every site. */
|
|
12
|
+
export declare function generateAstroidReleaseWorkflow(): string;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// The release workflow every site runs (louise-toolkit ADR 0017, amended).
|
|
4
|
+
//
|
|
5
|
+
// Releases are trunk-based: a `v<version>` tag on a commit of `main` is the
|
|
6
|
+
// release, and a `release/<version>` branch exists only when a released version
|
|
7
|
+
// needs a patch and `main` has moved on. Workers Builds deploys on branch
|
|
8
|
+
// pushes and can't watch tags, so the tag's workflow moves one branch,
|
|
9
|
+
// `deploy/production`, to the tagged commit, and Workers Builds deploys that.
|
|
10
|
+
// Nobody commits to the branch; it's a tag carried as a branch, and GitHub never
|
|
11
|
+
// holds a Cloudflare credential.
|
|
12
|
+
//
|
|
13
|
+
// The push uses a GitHub App's token, not the workflow's own `GITHUB_TOKEN`. A
|
|
14
|
+
// repository ruleset keeps everyone else off `deploy/production`, and GitHub
|
|
15
|
+
// rejects the GitHub Actions app as a ruleset bypass actor, so a ruleset that
|
|
16
|
+
// guards the branch blocks `GITHUB_TOKEN` too. A GitHub App can be the bypass
|
|
17
|
+
// actor. The setup is in the docs site's Releases guide.
|
|
18
|
+
//
|
|
19
|
+
// A regenerated file, like the worker trio: `astroid generate` rewrites it and
|
|
20
|
+
// `astroid doctor` fails when it drifts, so a hand edit can't quietly change
|
|
21
|
+
// which commits reach production.
|
|
22
|
+
/** The branch Workers Builds deploys to production from. */
|
|
23
|
+
export const ASTROID_DEPLOY_BRANCH = "deploy/production";
|
|
24
|
+
/** Where the workflow lives, relative to the repository root. */
|
|
25
|
+
export const ASTROID_RELEASE_WORKFLOW_PATH = ".github/workflows/release.yml";
|
|
26
|
+
/** The Actions variable that holds the release app's App ID. */
|
|
27
|
+
export const ASTROID_RELEASE_APP_ID_VAR = "RELEASE_APP_ID";
|
|
28
|
+
/** The Actions secret that holds the release app's private key. */
|
|
29
|
+
export const ASTROID_RELEASE_APP_KEY_SECRET = "RELEASE_APP_PRIVATE_KEY";
|
|
30
|
+
/** Where the one-time release setup is written down. */
|
|
31
|
+
export const ASTROID_RELEASE_SETUP_URL = "https://docs.astroidjs.org/guide/releases/";
|
|
32
|
+
/** The release workflow's contents. Pure, and the same for every site. */
|
|
33
|
+
export function generateAstroidReleaseWorkflow() {
|
|
34
|
+
const appId = ASTROID_RELEASE_APP_ID_VAR;
|
|
35
|
+
const appKey = ASTROID_RELEASE_APP_KEY_SECRET;
|
|
36
|
+
return `# Generated by astroid. Don't edit: \`astroid generate\` rewrites this file and
|
|
37
|
+
# \`astroid doctor\` fails when it drifts.
|
|
38
|
+
#
|
|
39
|
+
# A release is a tag, v<major>.<minor>.<patch>, on a commit of main, or of a
|
|
40
|
+
# release/<version> branch when a released version needs a patch. This moves
|
|
41
|
+
# ${ASTROID_DEPLOY_BRANCH} to the tagged commit, and Workers Builds deploys it to
|
|
42
|
+
# production. Nobody commits to ${ASTROID_DEPLOY_BRANCH}; a repository ruleset lets
|
|
43
|
+
# only the release GitHub App update it. To roll back, tag the earlier commit
|
|
44
|
+
# with the next patch version.
|
|
45
|
+
#
|
|
46
|
+
# The push uses the release app's token, from the ${appId} variable and
|
|
47
|
+
# the ${appKey} secret. The workflow's own GITHUB_TOKEN can't push:
|
|
48
|
+
# GitHub rejects the GitHub Actions app as a ruleset bypass actor, so the
|
|
49
|
+
# ruleset blocks it too. One-time setup, the app and the ruleset:
|
|
50
|
+
# ${ASTROID_RELEASE_SETUP_URL}
|
|
51
|
+
name: Release
|
|
52
|
+
|
|
53
|
+
on:
|
|
54
|
+
push:
|
|
55
|
+
tags: ["v*"]
|
|
56
|
+
|
|
57
|
+
permissions:
|
|
58
|
+
contents: write
|
|
59
|
+
|
|
60
|
+
# One release at a time, in the order the tags arrived.
|
|
61
|
+
concurrency:
|
|
62
|
+
group: release
|
|
63
|
+
cancel-in-progress: false
|
|
64
|
+
|
|
65
|
+
jobs:
|
|
66
|
+
release:
|
|
67
|
+
runs-on: ubuntu-latest
|
|
68
|
+
steps:
|
|
69
|
+
- name: Check the release app
|
|
70
|
+
env:
|
|
71
|
+
APP_ID: \${{ vars.${appId} }}
|
|
72
|
+
APP_KEY: \${{ secrets.${appKey} }}
|
|
73
|
+
run: |
|
|
74
|
+
if [ -z "$APP_ID" ] || [ -z "$APP_KEY" ]; then
|
|
75
|
+
echo "::error::Releasing needs the ${appId} variable and the ${appKey} secret, from the GitHub App that the ${ASTROID_DEPLOY_BRANCH} ruleset lets through. Set them up as ${ASTROID_RELEASE_SETUP_URL} describes."
|
|
76
|
+
exit 1
|
|
77
|
+
fi
|
|
78
|
+
|
|
79
|
+
- name: Mint the release app's token
|
|
80
|
+
id: app-token
|
|
81
|
+
uses: actions/create-github-app-token@v2
|
|
82
|
+
with:
|
|
83
|
+
app-id: \${{ vars.${appId} }}
|
|
84
|
+
private-key: \${{ secrets.${appKey} }}
|
|
85
|
+
|
|
86
|
+
- uses: actions/checkout@v4
|
|
87
|
+
with:
|
|
88
|
+
fetch-depth: 0
|
|
89
|
+
# The push below uses the credentials checkout leaves in place.
|
|
90
|
+
token: \${{ steps.app-token.outputs.token }}
|
|
91
|
+
|
|
92
|
+
- name: Check the tag
|
|
93
|
+
run: |
|
|
94
|
+
tag="$GITHUB_REF_NAME"
|
|
95
|
+
if ! [[ "$tag" =~ ^v[0-9]+\\.[0-9]+\\.[0-9]+$ ]]; then
|
|
96
|
+
echo "::error::A release tag is v<major>.<minor>.<patch>, such as v1.4.0. Got $tag."
|
|
97
|
+
exit 1
|
|
98
|
+
fi
|
|
99
|
+
sha="$(git rev-list -n 1 "$tag")"
|
|
100
|
+
if git merge-base --is-ancestor "$sha" origin/main; then
|
|
101
|
+
echo "$tag is on main."
|
|
102
|
+
elif git branch -r --contains "$sha" | grep -q "origin/release/"; then
|
|
103
|
+
echo "$tag is on a release branch."
|
|
104
|
+
else
|
|
105
|
+
echo "::error::$tag isn't on main or a release/ branch, so it can't be released."
|
|
106
|
+
exit 1
|
|
107
|
+
fi
|
|
108
|
+
echo "SHA=$sha" >> "$GITHUB_ENV"
|
|
109
|
+
|
|
110
|
+
- name: Move ${ASTROID_DEPLOY_BRANCH} to the tag
|
|
111
|
+
run: git push --force origin "$SHA:refs/heads/${ASTROID_DEPLOY_BRANCH}"
|
|
112
|
+
|
|
113
|
+
- name: Create the GitHub release
|
|
114
|
+
env:
|
|
115
|
+
GH_TOKEN: \${{ github.token }}
|
|
116
|
+
run: gh release create "$GITHUB_REF_NAME" --verify-tag --generate-notes
|
|
117
|
+
`;
|
|
118
|
+
}
|
package/dist/queues/scaffold.js
CHANGED
|
@@ -196,10 +196,14 @@ export function generateAstroidWebhookRoute(config, forProvider) {
|
|
|
196
196
|
"// Unprovisioned (the secret is absent or still the placeholder) answers 503,",
|
|
197
197
|
"// which keeps the provider retrying—so events delivered before you set the",
|
|
198
198
|
"// secret land afterwards instead of being lost.",
|
|
199
|
+
"//",
|
|
200
|
+
"// A staging Preview has no queue, since a consumer can't target one, so",
|
|
201
|
+
"// there the event runs in the request through the same handler instead.",
|
|
199
202
|
'import type { APIRoute } from "astro";',
|
|
200
203
|
'import { handleWebhook, readModuleSecret } from "astroidjs";',
|
|
201
204
|
'import { env } from "cloudflare:workers";',
|
|
202
205
|
`import { ${p.verifier} } from ${JSON.stringify(p.module)};`,
|
|
206
|
+
'import { handleQueueMessage } from "../../../queue";',
|
|
203
207
|
"",
|
|
204
208
|
"export const prerender = false;",
|
|
205
209
|
"",
|
|
@@ -211,6 +215,7 @@ export function generateAstroidWebhookRoute(config, forProvider) {
|
|
|
211
215
|
` provider: ${JSON.stringify(provider)},`,
|
|
212
216
|
` secret: await readModuleSecret(env.${COMMERCE_PROVIDER_SECRETS[provider].webhook}),`,
|
|
213
217
|
` queue: env.${ASTROID_QUEUE_BINDING},`,
|
|
218
|
+
" inline: (message) => handleQueueMessage(env, message),",
|
|
214
219
|
` verify: ({ raw, secret }) => ${p.call},`,
|
|
215
220
|
" });",
|
|
216
221
|
"};",
|
package/dist/queues/webhook.d.ts
CHANGED
|
@@ -30,6 +30,22 @@ export interface WebhookRouteOptions {
|
|
|
30
30
|
verify: (input: WebhookVerifyInput) => boolean | Promise<boolean>;
|
|
31
31
|
/** The queue binding, or null/undefined when Queues aren't provisioned. */
|
|
32
32
|
queue?: QueueProducer | null;
|
|
33
|
+
/**
|
|
34
|
+
* Run a message in the request instead, when `queue` is absent. A staging
|
|
35
|
+
* Preview leaves the queue unbound, because a queue consumer can't target a
|
|
36
|
+
* Preview (louise-toolkit ADR 0017), so without this every webhook a Preview
|
|
37
|
+
* receives answers 503 and the provider retries it forever.
|
|
38
|
+
*
|
|
39
|
+
* Pass the same handler the queue consumer runs: `(message) =>
|
|
40
|
+
* handleQueueMessage(env, message)`. Throwing answers 503, so the provider
|
|
41
|
+
* redelivers, which is the retry the queue would have given it.
|
|
42
|
+
*
|
|
43
|
+
* It's a fallback, not an alternative: a sync that outlasts the provider's
|
|
44
|
+
* delivery timeout reads to the provider as a failure, which is why
|
|
45
|
+
* production enqueues. On staging, with a sandbox catalog, that's a fair
|
|
46
|
+
* trade, and the queue wins whenever both are there.
|
|
47
|
+
*/
|
|
48
|
+
inline?: (message: AstroidQueueMessage) => Promise<void>;
|
|
33
49
|
/**
|
|
34
50
|
* Pull the event type out of the parsed payload. Defaults to a `type` field;
|
|
35
51
|
* override for providers that name it differently (Fourthwall's `testMode`
|
package/dist/queues/webhook.js
CHANGED
|
@@ -65,10 +65,27 @@ export async function handleWebhook(request, url, options) {
|
|
|
65
65
|
if (options.accept && !options.accept(type, payload)) {
|
|
66
66
|
return text("Ignored", 202);
|
|
67
67
|
}
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
const message = {
|
|
69
|
+
kind: "webhook",
|
|
70
|
+
provider: options.provider,
|
|
71
|
+
type,
|
|
72
|
+
payload,
|
|
73
|
+
};
|
|
74
|
+
if (!options.queue) {
|
|
75
|
+
if (!options.inline)
|
|
76
|
+
return text("Queue not configured", 503);
|
|
77
|
+
try {
|
|
78
|
+
await options.inline(message);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// Same contract as a failed send: the event is real, so ask for it again.
|
|
82
|
+
return text("Processing failed", 503);
|
|
83
|
+
}
|
|
84
|
+
// 200, not 202: the work already happened.
|
|
85
|
+
return text("Processed", 200);
|
|
86
|
+
}
|
|
70
87
|
try {
|
|
71
|
-
await options.queue.send(
|
|
88
|
+
await options.queue.send(message);
|
|
72
89
|
}
|
|
73
90
|
catch {
|
|
74
91
|
// The signature was good, so this event is real and worth keeping. 503 asks
|