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.
@@ -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
+ }
@@ -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
  "};",
@@ -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`
@@ -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
- if (!options.queue)
69
- return text("Queue not configured", 503);
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({ kind: "webhook", provider: options.provider, type, payload });
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