diff --git a/.github/actions/deploy-vercel/index.mjs b/.github/actions/deploy-vercel/index.mjs index 54b15a168829..825a88ae8f1d 100644 --- a/.github/actions/deploy-vercel/index.mjs +++ b/.github/actions/deploy-vercel/index.mjs @@ -1,7 +1,7 @@ // @ts-check import { Client } from "./vercel.mjs"; -import { assert, find, get, getRequiredInput } from "./util.mjs"; +import { assert, getRequiredInput, info } from "./util.mjs"; // These inputs are defined in `action.yml`, and should be kept in sync const token = getRequiredInput("vercel_token"); @@ -11,25 +11,35 @@ const releaseCommit = getRequiredInput("release_commit"); const client = new Client(token); -console.log(`Fetching team \`${teamName}\``); -const team = await client.teams().then(find("name", teamName)); -assert(team, `failed to get team \`${teamName}\``); - -console.log(`Fetching project \`${projectName}\``); -const project = await client.projects(team.id).then(find("name", projectName)); -assert(project, `failed to get project \`${projectName}\``); - -console.log(`Fetching latest deployment`); -const deployment = await client.deployments(team.id, project.id).then(get(0)); -assert(deployment, `failed to get latest deployment`); - -console.log(`Fetching \`RELEASE_COMMIT\` env var`); -const env = await client.envs(team.id, project.id).then(find("key", "RELEASE_COMMIT")); -assert(env, `failed to get \`RELEASE_COMMIT\` env var`); - -console.log(`Setting \`RELEASE_COMMIT\` env to \`${releaseCommit}\``); -await client.setEnv(team.id, project.id, env.id, { key: "RELEASE_COMMIT", value: releaseCommit }); - -console.log(`Triggering redeploy`); -await client.redeploy(team.id, deployment.uid, "landing"); +info`Fetching team "${teamName}"`; +const availableTeams = await client.teams(); +assert(availableTeams, `failed to get team "${teamName}"`); +const team = availableTeams.find((team) => team.name === teamName); +assert(team, `failed to get team "${teamName}"`); + +info`Fetching project "${projectName}"`; +const projectsInTeam = await client.projects(team.id); +const project = projectsInTeam.find((project) => project.name === projectName); +assert(project, `failed to get project "${projectName}"`); + +info`Fetching latest production deployment`; +const productionDeployments = await client.deployments(team.id, project.id); +const latestProductionDeployment = productionDeployments[0]; +assert(latestProductionDeployment, `failed to get latest production deployment`); + +const RELEASE_COMMIT_KEY = "RELEASE_COMMIT"; + +info`Fetching "${RELEASE_COMMIT_KEY}" env var`; +const environment = await client.envs(team.id, project.id); +const releaseCommitEnv = environment.find((env) => env.key === RELEASE_COMMIT_KEY); +assert(releaseCommitEnv, `failed to get "${RELEASE_COMMIT_KEY}" env var`); + +info`Setting "${RELEASE_COMMIT_KEY}" env to "${releaseCommit}"`; +await client.setEnv(team.id, project.id, releaseCommitEnv.id, { + key: RELEASE_COMMIT_KEY, + value: releaseCommit, +}); + +info`Triggering redeploy`; +await client.redeploy(team.id, latestProductionDeployment.uid, "landing"); diff --git a/.github/actions/deploy-vercel/manual.mjs b/.github/actions/deploy-vercel/manual.mjs new file mode 100755 index 000000000000..74232ada93cd --- /dev/null +++ b/.github/actions/deploy-vercel/manual.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node + +// Manually run the deployment: +// +// node manual.mjs \ +// --token VERCEL_TOKEN \ +// --team rerun \ +// --project landing \ +// --commit RELEASE_COMMIT +// + +import { execSync } from "node:child_process"; +import { parseArgs } from "node:util"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { assert } from "./util.mjs"; + +const dirname = path.dirname(fileURLToPath(import.meta.url)); + +/** @type {typeof execSync} */ +const $ = (cmd, opts) => execSync(cmd, { stdio: "inherit", ...opts }); + +const { token, team, project, commit } = parseArgs({ + options: { + token: { type: "string" }, + team: { type: "string" }, + project: { type: "string" }, + commit: { type: "string" }, + }, + strict: true, + allowPositionals: false, +}).values; +assert(token, "missing `--token`"); +assert(team, "missing `--team`"); +assert(project, "missing `--project`"); +assert(commit, "missing `--commit`"); + +$("node index.mjs", { + cwd: dirname, + env: { + ...process.env, + INPUT_VERCEL_TOKEN: token, + INPUT_VERCEL_TEAM_NAME: team, + INPUT_VERCEL_PROJECT_NAME: project, + INPUT_RELEASE_COMMIT: commit, + }, +}); + diff --git a/.github/actions/deploy-vercel/util.mjs b/.github/actions/deploy-vercel/util.mjs index ec0549fffaf8..c838307ffce5 100644 --- a/.github/actions/deploy-vercel/util.mjs +++ b/.github/actions/deploy-vercel/util.mjs @@ -1,5 +1,22 @@ // @ts-check +/** + * Log a message with level `INFO` + * + * @param {TemplateStringsArray} strings + * @param {any[]} values + */ +export function info(strings, ...values) { + let out = ""; + for (let i = 0; i < strings.length; i++) { + out += strings[i]; + if (i < values.length) { + out += values[i].toString(); + } + } + console.info(out); +} + /** * Return a GitHub Actions input, returning `null` if it was not set. * @@ -7,7 +24,6 @@ * @returns {string | null} */ export function getInput(name) { - // @ts-expect-error: `process` is not defined without the right type definitions return process.env[`INPUT_${name.replace(/ /g, "_").toUpperCase()}`] ?? null; } @@ -29,37 +45,20 @@ export function getRequiredInput(name) { * Assert that `value` is truthy, throwing an error if it is not. * * @param {any} value - * @param {string} [message] + * @param {string | (() => string)} [message] * @returns {asserts value} */ export function assert(value, message) { if (!value) { - throw new Error(`assertion failed` + (message ? ` ${message}` : "")); + let error; + if (typeof message === "string") { + error = `assertion failed: ${message}`; + } else if (typeof message === "function") { + error = `assertion failed: ${message()}`; + } else { + error = `assertion failed`; + } + throw new Error(error); } } -/** - * Returns a function that attempts to find an object with - * `key` set to `value` in an array of objects with `key` properties. - * - * @template {string} Key - * @template {{ [p in Key]: string }} T - * @param {Key} key - * @param {string} value - * @returns {(a: T[]) => T|null} - */ -export function find(key, value) { - return (a) => a.find((v) => v[key] === value) ?? null; -} - -/** - * Returns a function that attempts to retrieve the value at `index` from an array. - * - * @template T - * @param {number} index - * @returns {(a: T[]) => T|null} - */ -export function get(index) { - return (a) => a[index] ?? null; -} - diff --git a/.github/actions/deploy-vercel/vercel.mjs b/.github/actions/deploy-vercel/vercel.mjs index be0557c040de..454711012343 100644 --- a/.github/actions/deploy-vercel/vercel.mjs +++ b/.github/actions/deploy-vercel/vercel.mjs @@ -1,4 +1,5 @@ // @ts-check +import { assert } from "./util.mjs"; /** * @typedef {Record} Params @@ -104,7 +105,9 @@ export class Client { * @returns {Promise} */ async teams() { - return await this.get("v2/teams").then((r) => r.teams); + const response = await this.get("v2/teams"); + assert("teams" in response, () => `failed to get teams: ${JSON.stringify(response)}`); + return response.teams; } /** @@ -115,7 +118,9 @@ export class Client { * @returns {Promise} */ async projects(teamId) { - return await this.get("v9/projects", { teamId }).then((r) => r.projects); + const response = await this.get("v9/projects", { teamId }); + assert("projects" in response, () => `failed to get projects: ${JSON.stringify(response)}`); + return response.projects; } /** @@ -133,9 +138,17 @@ export class Client { * @returns {Promise} */ async deployments(teamId, projectId, target = "production") { - return await this.get("v6/deployments", { teamId, projectId, target, sort: "created" }).then( - (r) => r.deployments + const response = await this.get("v6/deployments", { + teamId, + projectId, + target, + sort: "created", + }); + assert( + "deployments" in response, + () => `failed to get deployments: ${JSON.stringify(response)}` ); + return response.deployments; } /** @@ -146,7 +159,12 @@ export class Client { * @returns {Promise} */ async envs(teamId, projectId) { - return await this.get(`v9/projects/${projectId}/env`, { teamId }).then((r) => r.envs); + const response = await this.get(`v9/projects/${projectId}/env`, { teamId }); + assert( + "envs" in response, + () => `failed to get environment variables: ${JSON.stringify(response)}` + ); + return response.envs; } /**