Compare changes
Choose two branches to see what one has that the other does not, then open a pull request for it.
2 commits
5 files+143−20/5 viewed
| 60 | 60 | type PipelineStage, | |
| 61 | 61 | PIPELINE, | |
| 62 | 62 | TIME, | |
| 63 | + | actorIds, | |
| 63 | 64 | ageBuckets, | |
| 64 | 65 | eventItem, | |
| 65 | 66 | firstPassRate, | |
| 66 | 67 | groupActivity, | |
| 68 | + | nameActor, | |
| 67 | 69 | passRate, | |
| 68 | 70 | pipelineStage, | |
| 69 | 71 | queuedNumbers, | |
| 87 | 89 | } from "../../lib/project-kind"; | |
| 88 | 90 | import { AboutEditor, KindMenu, LinkList } from "../../components/project-about"; | |
| 89 | 91 | import { DocsHead, ElsewhereHead, type ExternalDeployment, OtherHead, WhereItRuns } from "../../components/project-head"; | |
| 90 | − | import { actions, agents, deployments, events as eventLog, packages, projects, repos, work } from "../../lib/services.server"; | |
| 92 | + | import { actions, agents, deployments, events as eventLog, identity, packages, projects, repos, work } from "../../lib/services.server"; | |
| 91 | 93 | import { madeByG1t } from "../../lib/opened-by"; | |
| 92 | 94 | import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server"; | |
| 93 | 95 | import { accessTo, countsFor, refusal, repoFor } from "../../lib/access.server"; | |
| 323 | 325 | ||
| 324 | 326 | // --- Activity and health ------------------------------------------------------- | |
| 325 | 327 | const eventList: G1tEvent[] = recent ?? []; | |
| 326 | − | const items = eventList | |
| 328 | + | const logged = eventList | |
| 327 | 329 | .map((event) => eventItem(event, path)) | |
| 328 | 330 | .filter((item): item is ActivityItem => item != null); | |
| 331 | + | // The log names people by account id: their usernames, in one lookup. | |
| 332 | + | const ids = actorIds(logged.map((item) => item.actor)); | |
| 333 | + | const names = ids.length > 0 ? await soft(identity.usernames(ids)) : {}; | |
| 334 | + | const items = logged.map((item) => ({ ...item, actor: nameActor(item.actor, names) })); | |
| 329 | 335 | const checkEvents = eventList.flatMap((event) => | |
| 330 | 336 | event.type === "checks.completed" ? [{ repo: "", number: event.data.number, at: Date.parse(event.time), passed: event.data.status === "passed" }] : [], | |
| 331 | 337 | ); |
| 530 | 530 | that does not exist yet may be refused; deploy that service first with | |
| 531 | 531 | `--only`. | |
| 532 | 532 | ||
| 533 | + | ## Deployments on g1t | |
| 534 | + | ||
| 535 | + | Every deploy shows on the repository's Deployments page, so its production | |
| 536 | + | card says which commit runs. | |
| 537 | + | ||
| 538 | + | - **From the workflow**, the deploy jobs name `environment: {name: | |
| 539 | + | production, url: https://g1t.sh}`, and g1t Actions records one production | |
| 540 | + | deployment per run. | |
| 541 | + | - **By hand**, `deploy` reports one itself: in progress once migrations are | |
| 542 | + | in, then success or failure. It needs a g1t token with `deployments:write` | |
| 543 | + | in `G1T_DEPLOY_TOKEN` or the file `.credentials/g1t-deploy-token`; without | |
| 544 | + | one, or from a dirty tree or a dry run, it sends nothing. It finds the | |
| 545 | + | repository from the git remote on g1t.sh. A report that fails is one line | |
| 546 | + | in the log and never fails the deploy. | |
| 547 | + | ||
| 548 | + | When CI cannot finish a deploy, for example a runner image that must be | |
| 549 | + | built (hosted runners have no Docker), deploy from a machine with Docker. | |
| 550 | + | The report records it, and production shows the commit that really runs. | |
| 551 | + | ||
| 533 | 552 | ## Rolling back | |
| 534 | 553 | ||
| 535 | 554 | - **One unit, at once:** `npx wrangler rollback` in its folder (or |
| 59 | 59 | writeDeployConfig, | |
| 60 | 60 | } from "./deploy/image.mjs"; | |
| 61 | 61 | import { decide, git, planJson, pool, table } from "./deploy/plan.mjs"; | |
| 62 | + | import { reportDeployment } from "./deploy/report.mjs"; | |
| 62 | 63 | import { ROOT, byStage, codeStages, findWranglerConfigs, npmCiArgs, npmWorkspace, pick, problems, resolvedStack } from "./deploy/stack.mjs"; | |
| 63 | 64 | ||
| 64 | 65 | const USAGE = "usage: node scripts/deploy.mjs plan|deploy|build|migrate|manifest|doctor|install|build-base|image [--all] [--only a,b] [--skip a,b] [--force] [--rollback] [--concurrency N] [--stage S] [--json]"; | |
| 409 | 410 | if (touched.some((u) => u.kind === "rust-worker")) ensureWorkerBuild(); | |
| 410 | 411 | const docker = touched.some((u) => u.image) ? await dockerAvailable() : false; | |
| 411 | 412 | const context = { head, subject: git.subject(), dirty, dryRun, docker, rebuildImage: opts.rebuildImage, rebuildBase: opts.rebuildBase }; | |
| 413 | + | // A deploy run by hand shows on the repository's Deployments page; a | |
| 414 | + | // dirty tree is not a commit anyone can look at, so it is left out. | |
| 415 | + | const settle = dryRun || dirty ? null : await reportDeployment({ head, subject: context.subject, units: touched.map((u) => u.id), log }); | |
| 412 | 416 | ||
| 413 | 417 | let failed = false; | |
| 414 | 418 | for (const { stage, units } of byStage(stack, touched)) { | |
| 422 | 426 | failed = shipped.some((r) => !r.ok); | |
| 423 | 427 | } | |
| 424 | 428 | summary(results); | |
| 429 | + | await settle?.(!failed); | |
| 425 | 430 | console.log(`\n${failed ? "Failed" : dryRun ? "Built" : "Deployed"} in ${seconds(Date.now() - started)}.`); | |
| 426 | 431 | return !failed; | |
| 427 | 432 | } |
| 577 | 577 | assert.equal(meant[0].deploy, true); | |
| 578 | 578 | assert.match(meant[0].reason, /rolling back/); | |
| 579 | 579 | }); | |
| 580 | + | ||
| 581 | + | test("a deploy by hand reports to the repository on g1t, and a workflow's does not", async () => { | |
| 582 | + | const { g1tRemote, inWorkflow, deployToken, reportDeployment } = await import("./report.mjs"); | |
| 583 | + | assert.deepEqual(g1tRemote(["git@github.com:flagon-io/g1t.git", "https://g1t.sh/flagon-io/g1t.git"]), { api: "https://api.g1t.sh", repo: "flagon-io/g1t" }); | |
| 584 | + | assert.equal(g1tRemote(["git@github.com:flagon-io/g1t.git"]), null); | |
| 585 | + | assert.equal(inWorkflow({ GITHUB_ACTIONS: "true" }), true); | |
| 586 | + | assert.equal(inWorkflow({}), false); | |
| 587 | + | assert.equal(deployToken({ G1T_DEPLOY_TOKEN: " g1t_x \n" }, "/nowhere"), "g1t_x"); | |
| 588 | + | assert.equal(deployToken({}, "/nowhere"), null); | |
| 589 | + | let called = false; | |
| 590 | + | const settle = await reportDeployment({ head: "abc", subject: "s", units: ["web"], log: () => {}, env: { GITHUB_ACTIONS: "true", G1T_DEPLOY_TOKEN: "t" }, fetchImpl: () => { called = true; } }); | |
| 591 | + | assert.equal(settle, null); | |
| 592 | + | assert.equal(called, false); | |
| 593 | + | }); |
| 1 | + | // Tells g1t about a deploy run by hand, so the repository's Deployments | |
| 2 | + | // page and its production card say what runs. In g1t Actions the job's | |
| 3 | + | // `environment:` does this already, so nothing is sent from there. | |
| 4 | + | // | |
| 5 | + | // Needs a g1t token with deployments:write: G1T_DEPLOY_TOKEN, or the file | |
| 6 | + | // .credentials/g1t-deploy-token. Without one, nothing is sent. Reporting | |
| 7 | + | // never fails a deploy: a problem is one line in the log. | |
| 8 | + | ||
| 9 | + | import { execFileSync } from "node:child_process"; | |
| 10 | + | import { existsSync, readFileSync } from "node:fs"; | |
| 11 | + | import { join } from "node:path"; | |
| 12 | + | ||
| 13 | + | import { ROOT } from "./stack.mjs"; | |
| 14 | + | ||
| 15 | + | /** Where production answers, as the Deployments page links it. */ | |
| 16 | + | const PRODUCTION_URL = process.env.G1T_DEPLOY_URL || "https://g1t.sh"; | |
| 17 | + | ||
| 18 | + | /** The token, or null when there is none to use. */ | |
| 19 | + | export function deployToken(env = process.env, root = ROOT) { | |
| 20 | + | if (env.G1T_DEPLOY_TOKEN) return env.G1T_DEPLOY_TOKEN.trim(); | |
| 21 | + | const file = join(root, ".credentials", "g1t-deploy-token"); | |
| 22 | + | return existsSync(file) ? readFileSync(file, "utf8").trim() || null : null; | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | /** Whether this runs inside a workflow, which reports for itself. */ | |
| 26 | + | export function inWorkflow(env = process.env) { | |
| 27 | + | return env.GITHUB_ACTIONS === "true" || env.G1T_ACTIONS === "true"; | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | /** `{ api, repo }` from a remote on g1t (e.g. https://g1t.sh/flagon-io/g1t.git), or null. */ | |
| 31 | + | export function g1tRemote(urls) { | |
| 32 | + | for (const url of urls) { | |
| 33 | + | const match = /^https:\/\/(g1t\.[a-z.]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/i.exec(url.trim()); | |
| 34 | + | if (match) return { api: `https://api.${match[1]}`, repo: `${match[2]}/${match[3]}` }; | |
| 35 | + | } | |
| 36 | + | return null; | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | function remoteUrls() { | |
| 40 | + | try { | |
| 41 | + | const names = execFileSync("git", ["remote"], { cwd: ROOT, encoding: "utf8" }).split("\n").filter(Boolean); | |
| 42 | + | return names.map((name) => execFileSync("git", ["remote", "get-url", name], { cwd: ROOT, encoding: "utf8" }).trim()); | |
| 43 | + | } catch { | |
| 44 | + | return []; | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /** | |
| 49 | + | * Starts a production deployment for `head` and returns a function that | |
| 50 | + | * settles it (true: success), or null when nothing is reported. | |
| 51 | + | */ | |
| 52 | + | export async function reportDeployment({ head, subject, units, log, fetchImpl = fetch, env = process.env }) { | |
| 53 | + | if (inWorkflow(env)) return null; | |
| 54 | + | const token = deployToken(env); | |
| 55 | + | const remote = g1tRemote(remoteUrls()); | |
| 56 | + | if (!token || !remote) return null; | |
| 57 | + | const headers = { authorization: `Bearer ${token}`, "content-type": "application/json", "user-agent": "g1t-deploy" }; | |
| 58 | + | const base = `${remote.api}/repos/${remote.repo}/deployments`; | |
| 59 | + | try { | |
| 60 | + | const made = await fetchImpl(base, { | |
| 61 | + | method: "POST", | |
| 62 | + | headers, | |
| 63 | + | body: JSON.stringify({ | |
| 64 | + | ref: head, | |
| 65 | + | sha: head, | |
| 66 | + | environment: "production", | |
| 67 | + | production_environment: true, | |
| 68 | + | description: `${subject} (${units.join(", ")}), deployed by hand`, | |
| 69 | + | }), | |
| 70 | + | }); | |
| 71 | + | if (!made.ok) { | |
| 72 | + | log(`(the deployment was not reported to g1t: ${made.status})`); | |
| 73 | + | return null; | |
| 74 | + | } | |
| 75 | + | const { id } = await made.json(); | |
| 76 | + | const status = async (state) => { | |
| 77 | + | const sent = await fetchImpl(`${base}/${id}/statuses`, { | |
| 78 | + | method: "POST", | |
| 79 | + | headers, | |
| 80 | + | body: JSON.stringify({ state, environment_url: PRODUCTION_URL, description: state === "in_progress" ? "Deploying" : undefined }), | |
| 81 | + | }); | |
| 82 | + | if (!sent.ok) log(`(the deployment's ${state} was not reported to g1t: ${sent.status})`); | |
| 83 | + | }; | |
| 84 | + | await status("in_progress"); | |
| 85 | + | log(`Reported to g1t as deployment ${id} of ${remote.repo}.`); | |
| 86 | + | return async (ok) => { | |
| 87 | + | try { | |
| 88 | + | await status(ok ? "success" : "failure"); | |
| 89 | + | } catch (error) { | |
| 90 | + | log(`(the deployment's result was not reported to g1t: ${error.message ?? error})`); | |
| 91 | + | } | |
| 92 | + | }; | |
| 93 | + | } catch (error) { | |
| 94 | + | log(`(the deployment was not reported to g1t: ${error.message ?? error})`); | |
| 95 | + | return null; | |
| 96 | + | } | |
| 97 | + | } |