run402 1.54.4 → 1.55.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 +28 -0
- package/cli.mjs +7 -0
- package/core-dist/allowance-auth.js +42 -22
- package/lib/ci.mjs +395 -0
- package/lib/deploy-v2.mjs +115 -6
- package/lib/sdk.mjs +2 -2
- package/package.json +1 -1
- package/sdk/core-dist/allowance-auth.js +42 -22
- package/sdk/dist/ci-credentials.d.ts +22 -0
- package/sdk/dist/ci-credentials.d.ts.map +1 -0
- package/sdk/dist/ci-credentials.js +103 -0
- package/sdk/dist/ci-credentials.js.map +1 -0
- package/sdk/dist/index.d.ts +6 -0
- package/sdk/dist/index.d.ts.map +1 -1
- package/sdk/dist/index.js +5 -0
- package/sdk/dist/index.js.map +1 -1
- package/sdk/dist/namespaces/ci.d.ts +21 -0
- package/sdk/dist/namespaces/ci.d.ts.map +1 -0
- package/sdk/dist/namespaces/ci.js +253 -0
- package/sdk/dist/namespaces/ci.js.map +1 -0
- package/sdk/dist/namespaces/ci.types.d.ts +91 -0
- package/sdk/dist/namespaces/ci.types.d.ts.map +1 -0
- package/sdk/dist/namespaces/ci.types.js +8 -0
- package/sdk/dist/namespaces/ci.types.js.map +1 -0
- package/sdk/dist/namespaces/deploy.d.ts.map +1 -1
- package/sdk/dist/namespaces/deploy.js +45 -21
- package/sdk/dist/namespaces/deploy.js.map +1 -1
- package/sdk/dist/node/ci.d.ts +12 -0
- package/sdk/dist/node/ci.d.ts.map +1 -0
- package/sdk/dist/node/ci.js +30 -0
- package/sdk/dist/node/ci.js.map +1 -0
- package/sdk/dist/node/index.d.ts +7 -2
- package/sdk/dist/node/index.d.ts.map +1 -1
- package/sdk/dist/node/index.js +3 -2
- package/sdk/dist/node/index.js.map +1 -1
package/README.md
CHANGED
|
@@ -62,6 +62,34 @@ run402 subdomains claim my-app # → my-app.run402.com (auto-reas
|
|
|
62
62
|
|
|
63
63
|
`deploy-dir` hashes each file client-side and only uploads bytes the gateway doesn't already have. Re-deploying an unchanged tree returns immediately with `bytes_uploaded: 0`. Progress events stream to stderr.
|
|
64
64
|
|
|
65
|
+
### GitHub Actions OIDC deploys
|
|
66
|
+
|
|
67
|
+
Link once from a local shell that has your Run402 allowance, then commit the generated workflow and manifest:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
run402 ci link github --project prj_... --manifest run402.deploy.json
|
|
71
|
+
run402 ci list --project prj_...
|
|
72
|
+
run402 ci revoke cib_...
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`link github` infers `owner/repo` and the current branch, verifies the numeric GitHub repository id, creates a deploy-scoped CI binding, and writes `.github/workflows/run402-deploy.yml` unless you pass `--workflow`. The generated workflow is intentionally just the existing deploy command with OIDC enabled:
|
|
76
|
+
|
|
77
|
+
```yaml
|
|
78
|
+
permissions:
|
|
79
|
+
contents: read
|
|
80
|
+
id-token: write
|
|
81
|
+
|
|
82
|
+
jobs:
|
|
83
|
+
deploy:
|
|
84
|
+
runs-on: ubuntu-latest
|
|
85
|
+
steps:
|
|
86
|
+
- uses: actions/checkout@v4
|
|
87
|
+
- name: Deploy to run402
|
|
88
|
+
run: npx --yes run402@1.54.4 deploy apply --manifest 'run402.deploy.json' --project 'prj_...'
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
CI deploys can ship `site`, `functions`, and `database` changes. Keep secrets, domains, subdomains, routes, checks, and non-current base changes in a local `run402 deploy apply` where the full allowance-backed authority is present.
|
|
92
|
+
|
|
65
93
|
### Storage (paste-and-go CDN assets)
|
|
66
94
|
|
|
67
95
|
```bash
|
package/cli.mjs
CHANGED
|
@@ -26,6 +26,7 @@ Commands:
|
|
|
26
26
|
tier Manage tier subscription (status, set)
|
|
27
27
|
projects Manage projects (provision, list, query, inspect, delete)
|
|
28
28
|
deploy Deploy a full-stack app or static site (requires active tier)
|
|
29
|
+
ci Link GitHub Actions OIDC deploy bindings
|
|
29
30
|
functions Manage serverless functions (deploy, invoke, logs, list, delete)
|
|
30
31
|
secrets Manage project secrets (set, list, delete)
|
|
31
32
|
blob Direct-to-S3 blob storage (put, get, ls, rm, sign, diagnose) — up to 5 TiB
|
|
@@ -62,6 +63,7 @@ Getting started:
|
|
|
62
63
|
run402 init mpp Set up with MPP (Tempo Moderato)
|
|
63
64
|
run402 tier set prototype Subscribe to a tier
|
|
64
65
|
run402 deploy --manifest app.json
|
|
66
|
+
run402 ci link github --project prj_... --manifest run402.deploy.json
|
|
65
67
|
`;
|
|
66
68
|
|
|
67
69
|
if (cmd === '--version' || cmd === '-v') {
|
|
@@ -122,6 +124,11 @@ switch (cmd) {
|
|
|
122
124
|
await run([sub, ...rest].filter(Boolean));
|
|
123
125
|
break;
|
|
124
126
|
}
|
|
127
|
+
case "ci": {
|
|
128
|
+
const { run } = await import("./lib/ci.mjs");
|
|
129
|
+
await run(sub, rest);
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
125
132
|
case "functions": {
|
|
126
133
|
const { run } = await import("./lib/functions.mjs");
|
|
127
134
|
await run(sub, rest);
|
|
@@ -70,16 +70,44 @@ export function formatSIWEMessage(opts, address) {
|
|
|
70
70
|
opts.statement,
|
|
71
71
|
"",
|
|
72
72
|
`URI: ${opts.uri}`,
|
|
73
|
-
`Version: ${opts.version}`,
|
|
74
|
-
`Chain ID: ${opts.chainId}`,
|
|
73
|
+
`Version: ${opts.version ?? "1"}`,
|
|
74
|
+
`Chain ID: ${messageChainId(opts.chainId)}`,
|
|
75
75
|
`Nonce: ${opts.nonce}`,
|
|
76
76
|
`Issued At: ${opts.issuedAt}`,
|
|
77
77
|
];
|
|
78
78
|
if (opts.expirationTime) {
|
|
79
79
|
lines.push(`Expiration Time: ${opts.expirationTime}`);
|
|
80
80
|
}
|
|
81
|
+
if (opts.resources && opts.resources.length > 0) {
|
|
82
|
+
lines.push("Resources:");
|
|
83
|
+
for (const resource of opts.resources)
|
|
84
|
+
lines.push(`- ${resource}`);
|
|
85
|
+
}
|
|
81
86
|
return lines.join("\n");
|
|
82
87
|
}
|
|
88
|
+
export function buildSIWxAuthHeaders(opts) {
|
|
89
|
+
const message = formatSIWEMessage(opts, opts.allowance.address);
|
|
90
|
+
const signature = personalSign(opts.allowance.privateKey, opts.allowance.address, message);
|
|
91
|
+
const payload = {
|
|
92
|
+
domain: opts.domain,
|
|
93
|
+
address: toChecksumAddress(opts.allowance.address),
|
|
94
|
+
statement: opts.statement,
|
|
95
|
+
uri: opts.uri,
|
|
96
|
+
version: opts.version ?? "1",
|
|
97
|
+
chainId: payloadChainId(opts.chainId),
|
|
98
|
+
type: opts.type ?? "eip191",
|
|
99
|
+
nonce: opts.nonce,
|
|
100
|
+
issuedAt: opts.issuedAt,
|
|
101
|
+
expirationTime: opts.expirationTime,
|
|
102
|
+
signature,
|
|
103
|
+
};
|
|
104
|
+
if (opts.resources !== undefined) {
|
|
105
|
+
payload.resources = opts.resources;
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
"SIGN-IN-WITH-X": Buffer.from(JSON.stringify(payload)).toString("base64"),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
83
111
|
/**
|
|
84
112
|
* Get SIWX auth headers for the Run402 API.
|
|
85
113
|
* Returns null if no allowance is configured.
|
|
@@ -103,32 +131,24 @@ export function getAllowanceAuthHeaders(path, allowancePath) {
|
|
|
103
131
|
const now = new Date();
|
|
104
132
|
const issuedAt = now.toISOString();
|
|
105
133
|
const expirationTime = new Date(now.getTime() + 5 * 60 * 1000).toISOString();
|
|
106
|
-
|
|
134
|
+
return buildSIWxAuthHeaders({
|
|
135
|
+
allowance,
|
|
107
136
|
domain,
|
|
108
137
|
uri,
|
|
109
138
|
statement: "Sign in to Run402",
|
|
110
|
-
version: "1",
|
|
111
|
-
chainId: 84532, // Base Sepolia
|
|
112
|
-
nonce,
|
|
113
|
-
issuedAt,
|
|
114
|
-
expirationTime,
|
|
115
|
-
}, allowance.address);
|
|
116
|
-
const signature = personalSign(allowance.privateKey, allowance.address, message);
|
|
117
|
-
const payload = {
|
|
118
|
-
domain,
|
|
119
|
-
address: toChecksumAddress(allowance.address),
|
|
120
|
-
statement: "Sign in to Run402",
|
|
121
|
-
uri,
|
|
122
|
-
version: "1",
|
|
123
139
|
chainId: "eip155:84532",
|
|
124
|
-
type: "eip191",
|
|
125
140
|
nonce,
|
|
126
141
|
issuedAt,
|
|
127
142
|
expirationTime,
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
function messageChainId(chainId) {
|
|
146
|
+
if (typeof chainId === "number")
|
|
147
|
+
return String(chainId);
|
|
148
|
+
const match = /^eip155:(\d+)$/.exec(chainId);
|
|
149
|
+
return match ? match[1] : chainId;
|
|
150
|
+
}
|
|
151
|
+
function payloadChainId(chainId) {
|
|
152
|
+
return typeof chainId === "number" ? `eip155:${chainId}` : chainId;
|
|
133
153
|
}
|
|
134
154
|
//# sourceMappingURL=allowance-auth.js.map
|
package/lib/ci.mjs
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
CI_GITHUB_ACTIONS_PROVIDER,
|
|
7
|
+
DEFAULT_CI_DELEGATION_CHAIN_ID,
|
|
8
|
+
V1_CI_ALLOWED_ACTIONS,
|
|
9
|
+
V1_CI_ALLOWED_EVENTS_DEFAULT,
|
|
10
|
+
signCiDelegation,
|
|
11
|
+
} from "#sdk/node";
|
|
12
|
+
import { API, resolveProjectId } from "./config.mjs";
|
|
13
|
+
import { getSdk } from "./sdk.mjs";
|
|
14
|
+
import { reportSdkError, fail } from "./sdk-errors.mjs";
|
|
15
|
+
|
|
16
|
+
const { version: RUN402_VERSION } = JSON.parse(
|
|
17
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
const HELP = `run402 ci — Manage CI/OIDC deploy bindings
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
run402 ci link github [--project <id>] [--manifest <path>] [--repo <owner/repo>] [--branch <name> | --environment <name>] [--repository-id <id>] [--workflow <path>] [--expires-at <iso>] [--force]
|
|
24
|
+
run402 ci list [--project <id>]
|
|
25
|
+
run402 ci revoke <binding_id>
|
|
26
|
+
|
|
27
|
+
Subcommands:
|
|
28
|
+
link github Link this repo/branch or environment for GitHub Actions deploys
|
|
29
|
+
list List CI bindings for a project
|
|
30
|
+
revoke Revoke a CI binding
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
const SUB_HELP = {
|
|
34
|
+
link: `run402 ci link github — Link GitHub Actions OIDC for deploy apply
|
|
35
|
+
|
|
36
|
+
Usage:
|
|
37
|
+
run402 ci link github [--project <id>] [--manifest <path>] [--repo <owner/repo>] [--branch <name> | --environment <name>] [--repository-id <id>] [--workflow <path>] [--expires-at <iso>] [--force]
|
|
38
|
+
|
|
39
|
+
Options:
|
|
40
|
+
--project <id> Project ID (defaults to the active project)
|
|
41
|
+
--manifest <path> Manifest path used by the generated workflow (default: run402.deploy.json)
|
|
42
|
+
--repo <owner/repo> GitHub repo (default: inferred from origin remote)
|
|
43
|
+
--branch <name> Branch subject and push trigger (default: current branch)
|
|
44
|
+
--environment <name> GitHub environment subject; adds job.environment
|
|
45
|
+
--repository-id <id> Numeric GitHub repository id when API lookup is unavailable
|
|
46
|
+
--workflow <path> Workflow path (default: .github/workflows/run402-deploy.yml)
|
|
47
|
+
--expires-at <iso> Optional binding expiration timestamp
|
|
48
|
+
--force Overwrite an existing workflow file
|
|
49
|
+
|
|
50
|
+
Notes:
|
|
51
|
+
- v1 allows only push and workflow_dispatch events.
|
|
52
|
+
- v1 does not expose raw subject, wildcard, or pull-request deploy flags.
|
|
53
|
+
`,
|
|
54
|
+
list: `run402 ci list — List CI bindings
|
|
55
|
+
|
|
56
|
+
Usage:
|
|
57
|
+
run402 ci list [--project <id>]
|
|
58
|
+
`,
|
|
59
|
+
revoke: `run402 ci revoke — Revoke a CI binding
|
|
60
|
+
|
|
61
|
+
Usage:
|
|
62
|
+
run402 ci revoke <binding_id>
|
|
63
|
+
`,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
function parseFlags(args, allowed) {
|
|
67
|
+
const flags = {};
|
|
68
|
+
const positional = [];
|
|
69
|
+
for (let i = 0; i < args.length; i++) {
|
|
70
|
+
const arg = args[i];
|
|
71
|
+
if (!arg.startsWith("--")) {
|
|
72
|
+
positional.push(arg);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (!allowed.has(arg)) {
|
|
76
|
+
fail({
|
|
77
|
+
code: "BAD_USAGE",
|
|
78
|
+
message: `Unsupported flag: ${arg}`,
|
|
79
|
+
hint: "Run: run402 ci link github --help",
|
|
80
|
+
details: { flag: arg },
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
if (arg === "--force") {
|
|
84
|
+
flags.force = true;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (!args[i + 1] || args[i + 1].startsWith("--")) {
|
|
88
|
+
fail({
|
|
89
|
+
code: "BAD_USAGE",
|
|
90
|
+
message: `Missing value for ${arg}.`,
|
|
91
|
+
details: { flag: arg },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
flags[arg.slice(2).replace(/-/g, "_")] = args[++i];
|
|
95
|
+
}
|
|
96
|
+
return { flags, positional };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function rejectHighRiskFlags(args) {
|
|
100
|
+
const blocked = [
|
|
101
|
+
"--subject",
|
|
102
|
+
"--subject-match",
|
|
103
|
+
"--wildcard",
|
|
104
|
+
"--allow-event",
|
|
105
|
+
"--event",
|
|
106
|
+
"--pull-request",
|
|
107
|
+
"--pr",
|
|
108
|
+
"--no-repository-id",
|
|
109
|
+
];
|
|
110
|
+
const hit = args.find((arg) => blocked.includes(arg));
|
|
111
|
+
if (hit) {
|
|
112
|
+
fail({
|
|
113
|
+
code: "UNSUPPORTED_CI_FLAG",
|
|
114
|
+
message: `${hit} is intentionally not exposed by run402 ci link github v1.`,
|
|
115
|
+
hint: "Use --branch or --environment. PR deploys, raw subjects, wildcards, and soft repository-id binding are deferred.",
|
|
116
|
+
details: { flag: hit },
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function git(args) {
|
|
122
|
+
try {
|
|
123
|
+
return execFileSync("git", args, {
|
|
124
|
+
cwd: process.cwd(),
|
|
125
|
+
encoding: "utf8",
|
|
126
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
127
|
+
}).trim();
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function inferRepo() {
|
|
134
|
+
const remote = git(["remote", "get-url", "origin"]);
|
|
135
|
+
if (!remote) return null;
|
|
136
|
+
return parseGithubRemote(remote);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function parseGithubRemote(remote) {
|
|
140
|
+
const cleaned = remote.trim().replace(/\.git$/, "");
|
|
141
|
+
const ssh = cleaned.match(/^git@github\.com:([^/]+)\/(.+)$/);
|
|
142
|
+
if (ssh) return `${ssh[1]}/${ssh[2]}`;
|
|
143
|
+
const https = cleaned.match(/^https:\/\/github\.com\/([^/]+)\/(.+)$/);
|
|
144
|
+
if (https) return `${https[1]}/${https[2]}`;
|
|
145
|
+
const sshUrl = cleaned.match(/^ssh:\/\/git@github\.com\/([^/]+)\/(.+)$/);
|
|
146
|
+
if (sshUrl) return `${sshUrl[1]}/${sshUrl[2]}`;
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function inferBranch() {
|
|
151
|
+
return git(["branch", "--show-current"]);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function fetchGithubRepositoryId(repo) {
|
|
155
|
+
const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
|
|
156
|
+
const headers = {
|
|
157
|
+
Accept: "application/vnd.github+json",
|
|
158
|
+
"User-Agent": "run402-cli",
|
|
159
|
+
};
|
|
160
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
161
|
+
let res;
|
|
162
|
+
try {
|
|
163
|
+
res = await fetch(`https://api.github.com/repos/${repo}`, { headers });
|
|
164
|
+
} catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
const body = await res.json().catch(() => null);
|
|
168
|
+
if (!res.ok || typeof body?.id !== "number") return null;
|
|
169
|
+
return String(body.id);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function buildSubject(repo, { branch, environment }) {
|
|
173
|
+
if (environment) return `repo:${repo}:environment:${environment}`;
|
|
174
|
+
if (!branch) {
|
|
175
|
+
fail({
|
|
176
|
+
code: "CI_SUBJECT_REQUIRED",
|
|
177
|
+
message: "Could not infer a branch for the GitHub Actions subject.",
|
|
178
|
+
hint: "Pass --branch <name> or --environment <name>.",
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
return `repo:${repo}:ref:refs/heads/${branch}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function shellQuote(value) {
|
|
185
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function yamlString(value) {
|
|
189
|
+
return JSON.stringify(String(value));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function generateWorkflow({ branch, environment, manifest, projectId }) {
|
|
193
|
+
const pushBlock = branch
|
|
194
|
+
? ` push:\n branches: [${yamlString(branch)}]\n`
|
|
195
|
+
: ` push:\n`;
|
|
196
|
+
const environmentLine = environment ? ` environment: ${yamlString(environment)}\n` : "";
|
|
197
|
+
return `name: Run402 Deploy
|
|
198
|
+
|
|
199
|
+
on:
|
|
200
|
+
${pushBlock} workflow_dispatch:
|
|
201
|
+
|
|
202
|
+
permissions:
|
|
203
|
+
contents: read
|
|
204
|
+
id-token: write
|
|
205
|
+
|
|
206
|
+
jobs:
|
|
207
|
+
deploy:
|
|
208
|
+
runs-on: ubuntu-latest
|
|
209
|
+
${environmentLine} steps:
|
|
210
|
+
- uses: actions/checkout@v4
|
|
211
|
+
- name: Deploy to run402
|
|
212
|
+
run: npx --yes run402@${RUN402_VERSION} deploy apply --manifest ${shellQuote(manifest)} --project ${shellQuote(projectId)}
|
|
213
|
+
`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function linkGithub(args) {
|
|
217
|
+
rejectHighRiskFlags(args);
|
|
218
|
+
const { flags, positional } = parseFlags(args, new Set([
|
|
219
|
+
"--project",
|
|
220
|
+
"--manifest",
|
|
221
|
+
"--repo",
|
|
222
|
+
"--branch",
|
|
223
|
+
"--environment",
|
|
224
|
+
"--repository-id",
|
|
225
|
+
"--workflow",
|
|
226
|
+
"--expires-at",
|
|
227
|
+
"--force",
|
|
228
|
+
]));
|
|
229
|
+
if (positional[0] !== "github" || positional.length > 1) {
|
|
230
|
+
fail({
|
|
231
|
+
code: "BAD_USAGE",
|
|
232
|
+
message: "Missing provider: github.",
|
|
233
|
+
hint: "run402 ci link github [--project <id>]",
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
if (flags.branch && flags.environment) {
|
|
237
|
+
fail({
|
|
238
|
+
code: "BAD_USAGE",
|
|
239
|
+
message: "Choose either --branch or --environment, not both.",
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const projectId = resolveProjectId(flags.project);
|
|
244
|
+
const repo = flags.repo || inferRepo();
|
|
245
|
+
if (!repo || !/^[^/\s]+\/[^/\s]+$/.test(repo)) {
|
|
246
|
+
fail({
|
|
247
|
+
code: "GITHUB_REPO_REQUIRED",
|
|
248
|
+
message: "Could not infer GitHub owner/repo from git remote origin.",
|
|
249
|
+
hint: "Pass --repo <owner/repo>.",
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
const branch = flags.environment ? (flags.branch || inferBranch() || null) : (flags.branch || inferBranch());
|
|
253
|
+
const subject = buildSubject(repo, { branch, environment: flags.environment });
|
|
254
|
+
const repositoryId = flags.repository_id || await fetchGithubRepositoryId(repo);
|
|
255
|
+
if (!repositoryId) {
|
|
256
|
+
fail({
|
|
257
|
+
code: "GITHUB_REPOSITORY_ID_REQUIRED",
|
|
258
|
+
message: `Could not fetch the numeric GitHub repository id for ${repo}.`,
|
|
259
|
+
hint: "Set GITHUB_TOKEN/GH_TOKEN or pass --repository-id <id>.",
|
|
260
|
+
details: { repo },
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const workflowPath = flags.workflow || ".github/workflows/run402-deploy.yml";
|
|
265
|
+
const absWorkflowPath = resolve(process.cwd(), workflowPath);
|
|
266
|
+
if (existsSync(absWorkflowPath) && !flags.force) {
|
|
267
|
+
fail({
|
|
268
|
+
code: "WORKFLOW_EXISTS",
|
|
269
|
+
message: `Workflow already exists: ${workflowPath}`,
|
|
270
|
+
hint: "Pass --force to overwrite it.",
|
|
271
|
+
details: { workflow_path: workflowPath },
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const manifest = flags.manifest || "run402.deploy.json";
|
|
276
|
+
const nonce = randomBytes(16).toString("hex");
|
|
277
|
+
const values = {
|
|
278
|
+
project_id: projectId,
|
|
279
|
+
subject_match: subject,
|
|
280
|
+
allowed_actions: V1_CI_ALLOWED_ACTIONS,
|
|
281
|
+
allowed_events: V1_CI_ALLOWED_EVENTS_DEFAULT,
|
|
282
|
+
github_repository_id: repositoryId,
|
|
283
|
+
expires_at: flags.expires_at || null,
|
|
284
|
+
nonce,
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
let signedDelegation;
|
|
288
|
+
try {
|
|
289
|
+
signedDelegation = signCiDelegation(values, { apiBase: API });
|
|
290
|
+
} catch (err) {
|
|
291
|
+
fail({
|
|
292
|
+
code: "NO_ALLOWANCE",
|
|
293
|
+
message: err?.message || "No local allowance configured.",
|
|
294
|
+
hint: "Run: run402 init",
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const workflow = generateWorkflow({
|
|
299
|
+
branch,
|
|
300
|
+
environment: flags.environment || null,
|
|
301
|
+
manifest,
|
|
302
|
+
projectId,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
const binding = await getSdk({ disablePaidFetch: true }).ci.createBinding({
|
|
307
|
+
...values,
|
|
308
|
+
provider: CI_GITHUB_ACTIONS_PROVIDER,
|
|
309
|
+
signed_delegation: signedDelegation,
|
|
310
|
+
});
|
|
311
|
+
mkdirSync(dirname(absWorkflowPath), { recursive: true });
|
|
312
|
+
writeFileSync(absWorkflowPath, workflow, { encoding: "utf8", mode: 0o644 });
|
|
313
|
+
console.log(JSON.stringify({
|
|
314
|
+
status: "ok",
|
|
315
|
+
binding_id: binding.id,
|
|
316
|
+
project_id: projectId,
|
|
317
|
+
provider: CI_GITHUB_ACTIONS_PROVIDER,
|
|
318
|
+
subject_match: subject,
|
|
319
|
+
allowed_events: [...V1_CI_ALLOWED_EVENTS_DEFAULT],
|
|
320
|
+
allowed_actions: [...V1_CI_ALLOWED_ACTIONS],
|
|
321
|
+
github_repository_id: repositoryId,
|
|
322
|
+
github_repository_id_status: flags.repository_id ? "provided" : "verified",
|
|
323
|
+
workflow_path: workflowPath,
|
|
324
|
+
manifest_path: manifest,
|
|
325
|
+
run402_version: RUN402_VERSION,
|
|
326
|
+
delegation_chain_id: DEFAULT_CI_DELEGATION_CHAIN_ID,
|
|
327
|
+
bootstrap_caveat: "Commit the generated workflow and manifest before expecting GitHub Actions deploys.",
|
|
328
|
+
consent_summary: [
|
|
329
|
+
"This binding lets matching GitHub Actions workflows deploy site, function, and database changes to the project.",
|
|
330
|
+
"It does not allow direct secrets, domains, subdomains, lifecycle, billing, contracts, or faucet API calls.",
|
|
331
|
+
],
|
|
332
|
+
revocation_residuals: [
|
|
333
|
+
"Revocation stops future CI gateway requests.",
|
|
334
|
+
"Revocation does not undo already-deployed code, stop in-flight deploy operations, rotate exfiltrated keys, or remove deployed functions.",
|
|
335
|
+
],
|
|
336
|
+
}, null, 2));
|
|
337
|
+
} catch (err) {
|
|
338
|
+
reportSdkError(err);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function list(args) {
|
|
343
|
+
const { flags } = parseFlags(args, new Set(["--project"]));
|
|
344
|
+
const project = resolveProjectId(flags.project);
|
|
345
|
+
try {
|
|
346
|
+
const result = await getSdk({ disablePaidFetch: true }).ci.listBindings({ project });
|
|
347
|
+
console.log(JSON.stringify({ status: "ok", project_id: project, ...result }, null, 2));
|
|
348
|
+
} catch (err) {
|
|
349
|
+
reportSdkError(err);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function revoke(args) {
|
|
354
|
+
const bindingId = args.find((arg) => !arg.startsWith("--"));
|
|
355
|
+
if (!bindingId) {
|
|
356
|
+
fail({
|
|
357
|
+
code: "BAD_USAGE",
|
|
358
|
+
message: "Missing <binding_id>.",
|
|
359
|
+
hint: "run402 ci revoke <binding_id>",
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
try {
|
|
363
|
+
const binding = await getSdk({ disablePaidFetch: true }).ci.revokeBinding(bindingId);
|
|
364
|
+
console.log(JSON.stringify({
|
|
365
|
+
status: "ok",
|
|
366
|
+
binding,
|
|
367
|
+
revocation_residuals: [
|
|
368
|
+
"Revocation stops future CI gateway requests.",
|
|
369
|
+
"Revocation does not undo already-deployed code, stop in-flight deploy operations, rotate exfiltrated keys, or remove deployed functions.",
|
|
370
|
+
],
|
|
371
|
+
}, null, 2));
|
|
372
|
+
} catch (err) {
|
|
373
|
+
reportSdkError(err);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export async function run(sub, args) {
|
|
378
|
+
if (!sub || sub === "--help" || sub === "-h") {
|
|
379
|
+
console.log(HELP);
|
|
380
|
+
process.exit(0);
|
|
381
|
+
}
|
|
382
|
+
if (Array.isArray(args) && (args.includes("--help") || args.includes("-h"))) {
|
|
383
|
+
console.log(SUB_HELP[sub] || HELP);
|
|
384
|
+
process.exit(0);
|
|
385
|
+
}
|
|
386
|
+
switch (sub) {
|
|
387
|
+
case "link": await linkGithub(args); break;
|
|
388
|
+
case "list": await list(args); break;
|
|
389
|
+
case "revoke": await revoke(args); break;
|
|
390
|
+
default:
|
|
391
|
+
console.error(`Unknown subcommand: ${sub}\n`);
|
|
392
|
+
console.log(HELP);
|
|
393
|
+
process.exit(1);
|
|
394
|
+
}
|
|
395
|
+
}
|
package/lib/deploy-v2.mjs
CHANGED
|
@@ -25,9 +25,10 @@
|
|
|
25
25
|
|
|
26
26
|
import { readFileSync } from "node:fs";
|
|
27
27
|
import { resolve, dirname, isAbsolute, join } from "node:path";
|
|
28
|
+
import { githubActionsCredentials } from "#sdk/node";
|
|
28
29
|
import { getSdk } from "./sdk.mjs";
|
|
29
30
|
import { reportSdkError, fail } from "./sdk-errors.mjs";
|
|
30
|
-
import { allowanceAuthHeaders, resolveProjectId } from "./config.mjs";
|
|
31
|
+
import { API, allowanceAuthHeaders, getActiveProjectId, resolveProjectId } from "./config.mjs";
|
|
31
32
|
|
|
32
33
|
const APPLY_HELP = `run402 deploy apply — Unified deploy primitive (v1.34+)
|
|
33
34
|
|
|
@@ -213,7 +214,10 @@ async function applyCmd(args) {
|
|
|
213
214
|
});
|
|
214
215
|
}
|
|
215
216
|
if (opts.project) spec.project_id = opts.project;
|
|
216
|
-
|
|
217
|
+
const useGithubActionsOidc = hasGithubActionsOidcEnv();
|
|
218
|
+
if (!spec.project_id) {
|
|
219
|
+
spec.project_id = useGithubActionsOidc ? resolveCiProjectId() : resolveProjectId(null);
|
|
220
|
+
}
|
|
217
221
|
|
|
218
222
|
// Translate { project_id, ... } envelope → ReleaseSpec ({ project, ... })
|
|
219
223
|
// The SDK ReleaseSpec uses `project` rather than `project_id`; both shapes
|
|
@@ -222,18 +226,123 @@ async function applyCmd(args) {
|
|
|
222
226
|
const releaseSpec = mapManifestToReleaseSpec(spec);
|
|
223
227
|
const idempotencyKey = spec.idempotency_key;
|
|
224
228
|
|
|
225
|
-
|
|
226
|
-
|
|
229
|
+
let sdkOpts;
|
|
230
|
+
if (useGithubActionsOidc) {
|
|
231
|
+
sdkOpts = {
|
|
232
|
+
credentials: githubActionsCredentials({ projectId: spec.project_id, apiBase: API }),
|
|
233
|
+
disablePaidFetch: true,
|
|
234
|
+
};
|
|
235
|
+
} else {
|
|
236
|
+
// Preserve the aggressive early exit when no allowance is configured.
|
|
237
|
+
allowanceAuthHeaders("/deploy/v2/plans");
|
|
238
|
+
}
|
|
227
239
|
|
|
228
240
|
try {
|
|
229
|
-
const result = await getSdk().deploy.apply(releaseSpec, {
|
|
241
|
+
const result = await getSdk(sdkOpts).deploy.apply(releaseSpec, {
|
|
230
242
|
onEvent: makeStderrEventWriter(opts.quiet),
|
|
231
243
|
idempotencyKey,
|
|
232
244
|
});
|
|
233
245
|
console.log(JSON.stringify({ status: "ok", ...result }, null, 2));
|
|
234
246
|
} catch (err) {
|
|
235
|
-
|
|
247
|
+
reportDeployApplyError(err, useGithubActionsOidc);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function hasGithubActionsOidcEnv(env = process.env) {
|
|
252
|
+
return env.GITHUB_ACTIONS === "true" &&
|
|
253
|
+
Boolean(env.ACTIONS_ID_TOKEN_REQUEST_URL) &&
|
|
254
|
+
Boolean(env.ACTIONS_ID_TOKEN_REQUEST_TOKEN);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function resolveCiProjectId(env = process.env) {
|
|
258
|
+
const projectId = getActiveProjectId() || env.RUN402_PROJECT_ID;
|
|
259
|
+
if (!projectId) {
|
|
260
|
+
fail({
|
|
261
|
+
code: "CI_PROJECT_REQUIRED",
|
|
262
|
+
message: "GitHub Actions OIDC deploy requires a project id.",
|
|
263
|
+
hint: "Pass --project <prj_...> in the workflow command, include project_id in the manifest, or set RUN402_PROJECT_ID.",
|
|
264
|
+
details: { sources: ["--project", "manifest.project_id", "active_project", "RUN402_PROJECT_ID"] },
|
|
265
|
+
});
|
|
236
266
|
}
|
|
267
|
+
return projectId;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const CI_DEPLOY_ERROR_GUIDANCE = {
|
|
271
|
+
invalid_token: {
|
|
272
|
+
hint: "Ensure the workflow has permissions: id-token: write and is running in the repository/branch linked with run402 ci link github.",
|
|
273
|
+
next_actions: [
|
|
274
|
+
"Check the workflow permissions block includes id-token: write.",
|
|
275
|
+
"Re-run run402 ci link github if the repository, branch, or environment changed.",
|
|
276
|
+
],
|
|
277
|
+
},
|
|
278
|
+
access_denied: {
|
|
279
|
+
hint: "The OIDC token was valid, but no active Run402 CI binding allowed this workflow.",
|
|
280
|
+
next_actions: [
|
|
281
|
+
"Run run402 ci list --project <prj_...> locally to inspect bindings.",
|
|
282
|
+
"Run run402 ci link github again for this repository/branch/environment.",
|
|
283
|
+
],
|
|
284
|
+
},
|
|
285
|
+
event_not_allowed: {
|
|
286
|
+
hint: "This binding only allows push and workflow_dispatch events in v1.",
|
|
287
|
+
next_actions: [
|
|
288
|
+
"Trigger the workflow with push or workflow_dispatch.",
|
|
289
|
+
"Create a separate follow-up design before enabling PR deploy events.",
|
|
290
|
+
],
|
|
291
|
+
},
|
|
292
|
+
repository_id_mismatch: {
|
|
293
|
+
hint: "The GitHub repository id in the OIDC token does not match the linked binding.",
|
|
294
|
+
next_actions: [
|
|
295
|
+
"Run run402 ci link github again from the current repository.",
|
|
296
|
+
"If automatic lookup fails, pass --repository-id with the numeric GitHub repository id.",
|
|
297
|
+
],
|
|
298
|
+
},
|
|
299
|
+
forbidden_spec_field: {
|
|
300
|
+
hint: "CI deploys in v1 can deploy site/functions/database content only; link locally for secrets, routes, subdomains, checks, or oversized manifests.",
|
|
301
|
+
next_actions: [
|
|
302
|
+
"Remove forbidden fields such as secrets, routes, subdomains, or checks from the CI manifest.",
|
|
303
|
+
"Keep the normalized manifest small enough to avoid manifest_ref.",
|
|
304
|
+
],
|
|
305
|
+
},
|
|
306
|
+
forbidden_plan: {
|
|
307
|
+
hint: "The gateway rejected this deploy plan for CI. Keep CI deploys to the v1 allowed resources and re-link if policy changed.",
|
|
308
|
+
next_actions: [
|
|
309
|
+
"Inspect the gateway error details for the rejected resource.",
|
|
310
|
+
"Run the deploy locally with run402 deploy apply for operations outside the CI allowlist.",
|
|
311
|
+
],
|
|
312
|
+
},
|
|
313
|
+
payment_required: {
|
|
314
|
+
hint: "The project tier or payment state does not allow this CI deploy.",
|
|
315
|
+
next_actions: [
|
|
316
|
+
"Run run402 tier status --project <prj_...> locally.",
|
|
317
|
+
"Renew or upgrade the project tier, then re-run the workflow.",
|
|
318
|
+
],
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
function reportDeployApplyError(err, useGithubActionsOidc) {
|
|
323
|
+
if (!useGithubActionsOidc) return reportSdkError(err);
|
|
324
|
+
return reportSdkError(enhanceCiDeployError(err));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function enhanceCiDeployError(err) {
|
|
328
|
+
const existingBody = err?.body && typeof err.body === "object" && !Array.isArray(err.body)
|
|
329
|
+
? err.body
|
|
330
|
+
: {};
|
|
331
|
+
const code = existingBody.code || err?.code || (err?.status === 402 ? "payment_required" : null);
|
|
332
|
+
const guidance = code ? CI_DEPLOY_ERROR_GUIDANCE[code] : null;
|
|
333
|
+
if (!guidance) return err;
|
|
334
|
+
|
|
335
|
+
const enhanced = Object.assign(new Error(err?.message || existingBody.message || String(code)), err);
|
|
336
|
+
enhanced.body = {
|
|
337
|
+
...existingBody,
|
|
338
|
+
code,
|
|
339
|
+
message: existingBody.message || err?.message || "GitHub Actions OIDC deploy failed.",
|
|
340
|
+
hint: existingBody.hint || guidance.hint,
|
|
341
|
+
next_actions: Array.isArray(existingBody.next_actions) && existingBody.next_actions.length > 0
|
|
342
|
+
? existingBody.next_actions
|
|
343
|
+
: guidance.next_actions,
|
|
344
|
+
};
|
|
345
|
+
return enhanced;
|
|
237
346
|
}
|
|
238
347
|
|
|
239
348
|
async function resumeCmd(args) {
|