Deployments work end to end: fixes from the first live run
Verified on syntaqx/automation-lab: the Deployments plan bought with Stripe's test card; production built (28 s) and served its assets, wrangler vars, config for all environments and the Production row of a secret; pull request #6 got a preview with the Preview row, noindex, and the check 'g1t / deploy: Preview is live'; closing it took the preview down; every build was charged at cost plus 20%. - API: a build's reports came back as 500 ('Can't modify immutable headers'), so the sandbox gave up after 'started'. The pass-through now answers with a fresh response. - API: request bodies are turned to snake_case, so never reached the secrets store. It reads (camelCase still works) and the docs say so. - is named as callers send it. - The deployments service logs each report a build sends, and the runner a sandbox's exit code, so a failed build says why in the logs.
| 361 | 361 | init.with_method(Method::Post) | |
| 362 | 362 | .with_headers(headers) | |
| 363 | 363 | .with_body(Some(worker::js_sys::Uint8Array::from(body.as_slice()).into())); | |
| 364 | − | return env | |
| 364 | + | let mut answer = env | |
| 365 | 365 | .service("DEPLOYMENTS")? | |
| 366 | 366 | .fetch_request(Request::new_with_init(&target, &init)?) | |
| 367 | − | .await; | |
| 367 | + | .await?; | |
| 368 | + | // A fresh response: a fetched one's headers cannot be changed, and | |
| 369 | + | // every response gets the API's own on the way out. | |
| 370 | + | let status = answer.status_code(); | |
| 371 | + | return Ok(Response::from_bytes(answer.bytes().await?)? | |
| 372 | + | .with_status(status) | |
| 373 | + | .with_headers({ | |
| 374 | + | let headers = worker::Headers::new(); | |
| 375 | + | headers.set("content-type", "application/json")?; | |
| 376 | + | headers | |
| 377 | + | })); | |
| 368 | 378 | } | |
| 369 | 379 | ||
| 370 | 380 | // A sandbox's artifacts and cache, with its job's token, which is not a |
| 579 | 579 | "The secrets of a repository (with the workspace's rows that reach it) or of a workspace: each row's key, the environments it applies to, and whether workflows (`secrets.NAME`), deployments, or both read it. Values are never returned. Members only." | |
| 580 | 580 | } | |
| 581 | 581 | Op::SetActionsSecret => { | |
| 582 | − | "Add or change a secret's row. Without `id` or `environments`, the key's row for every environment, as GitHub's API addresses a secret. `availableTo` is workflows and/or deployments (both, for a new row); `environments` limits it to some, such as production or preview, so a key can hold a value per environment. A variable's row can become a secret this way; a secret never becomes a variable. A repository's need a member; a workspace's an owner. Workspace tokens, G1T_TOKEN included, cannot change them." | |
| 582 | + | "Add or change a secret's row. Without `id` or `environments`, the key's row for every environment, as GitHub's API addresses a secret. `available_to` is workflows and/or deployments (both, for a new row); `environments` limits it to some, such as production or preview, so a key can hold a value per environment. A variable's row can become a secret this way; a secret never becomes a variable. A repository's need a member; a workspace's an owner. Workspace tokens, G1T_TOKEN included, cannot change them." | |
| 583 | 583 | } | |
| 584 | 584 | Op::DeleteActionsSecret => "Remove a secret: one row by `id`, or every row of the key.", | |
| 585 | 585 | Op::ListActionsVariables => { | |
| 989 | 989 | "setting": { "type": "string", "description": "The key, such as NPM_TOKEN." }, | |
| 990 | 990 | "value": { "type": "string", "description": "Needed for a new row; left out, the row keeps its value." }, | |
| 991 | 991 | "id": { "type": "string", "description": "The row to change, from a list. Left out: the key's row for every environment." }, | |
| 992 | − | "availableTo": { | |
| 992 | + | "available_to": { | |
| 993 | 993 | "type": "array", | |
| 994 | 994 | "items": { "type": "string", "enum": ["workflows", "deployments"] }, | |
| 995 | 995 | "description": "Who reads it. Both for a new row." | |
| 1734 | 1734 | if let Some(value) = input["value"].as_str() { | |
| 1735 | 1735 | args["value"] = json!(value); | |
| 1736 | 1736 | } | |
| 1737 | − | for key in ["availableTo", "environments", "repositories"] { | |
| 1737 | + | // Request bodies arrive in snake_case; the actions service | |
| 1738 | + | // takes `availableTo`. | |
| 1739 | + | for (key, to) in [("available_to", "availableTo"), ("environments", "environments"), ("repositories", "repositories")] { | |
| 1738 | 1740 | if let Some(list) = strings(input, key) { | |
| 1739 | − | args[key] = json!(list); | |
| 1741 | + | args[to] = json!(list); | |
| 1740 | 1742 | } | |
| 1741 | 1743 | } | |
| 1742 | 1744 | for key in ["id", "note"] { |
| 119 | 119 | | Field | | | |
| 120 | 120 | | --- | --- | | |
| 121 | 121 | | `id` | The row to change or remove, from a list. | | |
| 122 | − | | `availableTo` | `["workflows"]`, `["deployments"]` or both. | | |
| 122 | + | | `available_to` | `["workflows"]`, `["deployments"]` or both. `availableTo` works too. | | |
| 123 | 123 | | `environments` | `["production"]`, `["preview", "staging"]`; `[]` for all. | | |
| 124 | 124 | | `repositories` | A workspace's row: repository names; `[]` for every one. | | |
| 125 | 125 | | `note` | Where to rotate it, or who to ask. | | |
| 127 | 127 | ```sh | |
| 128 | 128 | curl -X PUT https://api.g1t.sh/repos/acme/web/actions/secrets/STRIPE_KEY \ | |
| 129 | 129 | -H "Authorization: Bearer $YOUR_TOKEN" \ | |
| 130 | − | -d '{"value":"sk_live_…","environments":["production"],"availableTo":["deployments"]}' | |
| 130 | + | -d '{"value":"sk_live_…","environments":["production"],"available_to":["deployments"]}' | |
| 131 | 131 | ``` | |
| 132 | 132 | ||
| 133 | 133 | Unlike GitHub's, a secret is sent as plain `value` over HTTPS, not |
| 147 | 147 | | `rerun_workflow_run` | `repo`, `id` | Run it again; `failed_only` for the jobs that did not succeed. Members only. | `POST /repos/{owner}/{name}/actions/runs/{id}/rerun` | | |
| 148 | 148 | | `update_workflow` | `repo`, `workflow`, `enabled` | Turn a workflow on or off. Members only. | `PATCH /repos/{owner}/{name}/actions/workflows/{workflow}` | | |
| 149 | 149 | | `list_actions_secrets` | `repo` or `workspace` | Secrets' rows: key, environments, who reads them. Never values. | `GET /repos/{owner}/{name}/actions/secrets`, `GET /workspaces/{workspace}/actions/secrets` | | |
| 150 | − | | `set_actions_secret` | `setting` | Add or change a secret's row: `value`, and optionally `id`, `environments`, `availableTo`, `repositories`, `note`. | `PUT …/actions/secrets/{name}` | | |
| 150 | + | | `set_actions_secret` | `setting` | Add or change a secret's row: `value`, and optionally `id`, `environments`, `available_to`, `repositories`, `note`. | `PUT …/actions/secrets/{name}` | | |
| 151 | 151 | | `delete_actions_secret` | `setting` | Remove one row (`id`) or every row of the key. | `DELETE …/actions/secrets/{name}` | | |
| 152 | 152 | | `list_actions_variables` | `repo` or `workspace` | Config rows with their values. | `GET …/actions/variables` | | |
| 153 | 153 | | `set_actions_variable` | `setting` | Add or change a config row, as for secrets. | `POST …/actions/variables`, `PATCH …/variables/{name}` | |
| 247 | 247 | production/preview, or a job's `environment:`), and whether workflows, | |
| 248 | 248 | deployments or both read it. API: `{repo}/actions/secrets` and | |
| 249 | 249 | `{repo}/actions/variables` (GitHub's routes) with extra `environments`, | |
| 250 | − | `availableTo`, `repositories`, `note`, `id`. Trusted jobs get | |
| 250 | + | `available_to`, `repositories`, `note`, `id`. Trusted jobs get | |
| 251 | 251 | `secrets.G1T_TOKEN` (the workspace's token; `GITHUB_TOKEN` is its alias), | |
| 252 | 252 | which cannot change secrets. Guide: | |
| 253 | 253 | https://docs.g1t.sh/guides/secrets-and-variables/ |
| 318 | 318 | pub value: Option<String>, | |
| 319 | 319 | /// `workflows` and/or `deployments`; left out, unchanged (both, for a | |
| 320 | 320 | /// new row). | |
| 321 | − | #[serde(default, alias = "availableTo")] | |
| 321 | + | // Named as callers send it: an `alias` is not honoured beside the | |
| 322 | + | // flattened owner in the Worker's build. | |
| 323 | + | #[serde(default, rename = "availableTo")] | |
| 322 | 324 | pub available_to: Option<Vec<String>>, | |
| 323 | 325 | /// The environments it applies to; empty is every one. Left out, | |
| 324 | 326 | /// unchanged. | |
| 391 | 393 | /// Minutes before the job is stopped. | |
| 392 | 394 | pub timeout_minutes: u32, | |
| 393 | 395 | } | |
| 396 | + | ||
| 397 | + | #[cfg(test)] | |
| 398 | + | mod setting_args_tests { | |
| 399 | + | use super::*; | |
| 400 | + | ||
| 401 | + | #[test] | |
| 402 | + | fn who_reads_a_row_is_read_as_the_site_and_api_send_it() { | |
| 403 | + | let args: SetSettingArgs = serde_json::from_value(serde_json::json!({ | |
| 404 | + | "actor": { "id": "usr_1", "username": "a" }, | |
| 405 | + | "repo": { "namespace": "acme", "name": "web" }, | |
| 406 | + | "kind": "secret", | |
| 407 | + | "name": "STRIPE_KEY", | |
| 408 | + | "availableTo": ["deployments"], | |
| 409 | + | "environments": ["production"], | |
| 410 | + | })) | |
| 411 | + | .unwrap(); | |
| 412 | + | assert_eq!(args.available_to, Some(vec!["deployments".to_owned()])); | |
| 413 | + | } | |
| 414 | + | } |
| 558 | 558 | } | |
| 559 | 559 | ||
| 560 | 560 | async job(id: string, step: string, body: Record<string, unknown>): Promise<Response> { | |
| 561 | + | // Each report, for the logs: a build's own failure says why. | |
| 562 | + | console.log("build", id, step, typeof body.message === "string" ? body.message.slice(0, 500) : ""); | |
| 561 | 563 | const row = await this.building(id, body.token); | |
| 562 | 564 | if (!row) return Response.json(fail("not_found", "No such build, or it has finished."), { status: 404 }); | |
| 563 | 565 | const cloudflare = this.cloudflare; |
| 150 | 150 | await this.start({ envVars, enableInternet: true }); | |
| 151 | 151 | } | |
| 152 | 152 | ||
| 153 | − | override async onStop({ exitCode }: StopParams): Promise<void> { | |
| 153 | + | override async onStop({ exitCode, reason }: StopParams): Promise<void> { | |
| 154 | 154 | if (exitCode === 0) return; | |
| 155 | 155 | const run = await this.ctx.storage.get<Run>("run"); | |
| 156 | + | console.log("sandbox stopped", run?.kind, "exit", exitCode, reason); | |
| 156 | 157 | if (!run) return; | |
| 157 | 158 | if (run.kind === "actions") { | |
| 158 | 159 | // Refused harmlessly if the job reported its end before it stopped. |