Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: generate vercel sdk and fix vercel-promote #1497

Merged
merged 1 commit into from
Sep 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion clis/vercel-scripts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
},
"dependencies": {
"@fern-fern/fern-docs-sdk": "0.0.5",
"@fern-fern/vercel": "0.0.4607",
"@fern-fern/vercel": "0.0.4650",
"ts-essentials": "^10.0.1"
}
}
63 changes: 16 additions & 47 deletions clis/vercel-scripts/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@ import { VercelClient } from "@fern-fern/vercel";
import { writeFileSync } from "fs";
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import { promoteCommand } from "./commands/promote.js";
import { revalidateAllCommand } from "./commands/revalidate-all.js";
import { cwd } from "./cwd.js";
import { cleanDeploymentId } from "./utils/clean-id.js";
import { VercelDeployer } from "./utils/deployer.js";
import { DocsRevalidator } from "./utils/revalidator.js";
import { FernDocsRevalidator } from "./utils/revalidator.js";
import { safeCommand } from "./utils/safeCommand.js";

function isValidEnvironment(environment: string): environment is "preview" | "production" {
return environment === "preview" || environment === "production";
Expand Down Expand Up @@ -49,13 +53,12 @@ void yargs(hideBin(process.argv))
choices: ["preview" as const, "production" as const],
})
.option("skip-deploy", { type: "boolean", description: "Skip the deploy step" })
.option("force", { type: "boolean", description: "Always deploy, even if the project is up-to-date" })
.option("output", {
type: "string",
description: "The output file to write the preview URLs to",
default: "deployment-url.txt",
}),
async ({ project, environment, token, teamName, teamId, output, skipDeploy, force }) => {
async ({ project, environment, token, teamName, teamId, output, skipDeploy }) => {
if (!isValidEnvironment(environment)) {
throw new Error(`Invalid environment: ${environment}`);
}
Expand All @@ -71,13 +74,10 @@ void yargs(hideBin(process.argv))
cwd: cwd(),
});

const result = await cli.buildAndDeployToVercel(project, { skipDeploy, force });
const result = await cli.buildAndDeployToVercel(project, { skipDeploy });

if (result) {
// eslint-disable-next-line no-console
console.log("Deployed to:", result.deploymentUrl);

writeFileSync(output, result.deploymentUrl);
writeFileSync(output, result.url);
}

process.exit(0);
Expand All @@ -91,46 +91,15 @@ void yargs(hideBin(process.argv))
type: "boolean",
description: "Revalidate the deployment (if it's fern docs)",
}),
async ({ deploymentUrl, token, teamId, revalidateAll }) => {
const deployment = await new VercelClient({ token }).deployments.getDeployment(
deploymentUrl.replace("https://", ""),
{ teamId, withGitRepoInfo: "false" },
);

if (deployment.target !== "production") {
// eslint-disable-next-line no-console
console.error("Deployment is not a production deployment");
process.exit(1);
} else if (deployment.readySubstate !== "STAGED") {
// eslint-disable-next-line no-console
console.error("Deployment is not staged");
process.exit(1);
} else if (!deployment.project) {
// eslint-disable-next-line no-console
console.error("Deployment does not have a project");
process.exit(1);
}

if (revalidateAll) {
const revalidator = new DocsRevalidator({ token, project: deployment.project.name, teamId });

await revalidator.revalidateAll();
}

process.exit(0);
},
async ({ deploymentUrl, token, teamId, revalidateAll }) =>
safeCommand(() => promoteCommand({ deploymentIdOrUrl: deploymentUrl, token, teamId, revalidateAll })),
)
.command(
"revalidate-all <deploymentUrl>",
"Revalidate all docs for a deployment",
(argv) => argv.positional("deploymentUrl", { type: "string", demandOption: true }),
async ({ deploymentUrl, token, teamId }) => {
const revalidator = new DocsRevalidator({ token, project: deploymentUrl, teamId });

await revalidator.revalidateAll();

process.exit(0);
},
async ({ deploymentUrl, token, teamId }) =>
safeCommand(() => revalidateAllCommand({ token, teamId, deploymentIdOrUrl: deploymentUrl })),
)
.command(
"preview.txt <deploymentUrl>",
Expand All @@ -143,15 +112,15 @@ void yargs(hideBin(process.argv))
}),
async ({ deploymentUrl, token, teamId, output }) => {
const deployment = await new VercelClient({ token }).deployments.getDeployment(
deploymentUrl.replace("https://", ""),
cleanDeploymentId(deploymentUrl),
{ teamId, withGitRepoInfo: "false" },
);

if (!deployment.project) {
throw new Error("Deployment does not have a project");
}

const revalidator = new DocsRevalidator({ token, project: deployment.project.name, teamId });
const revalidator = new FernDocsRevalidator({ token, project: deployment.project.name, teamId });

const urls = await revalidator.getPreviewUrls(deploymentUrl);

Expand All @@ -171,15 +140,15 @@ void yargs(hideBin(process.argv))
}),
async ({ deploymentUrl, token, teamId, output }) => {
const deployment = await new VercelClient({ token }).deployments.getDeployment(
deploymentUrl.replace("https://", ""),
cleanDeploymentId(deploymentUrl),
{ teamId, withGitRepoInfo: "false" },
);

if (!deployment.project) {
throw new Error("Deployment does not have a project");
}

const revalidator = new DocsRevalidator({ token, project: deployment.project.name, teamId });
const revalidator = new FernDocsRevalidator({ token, project: deployment.project.name, teamId });

const urls = await revalidator.getDomains();

Expand Down
26 changes: 26 additions & 0 deletions clis/vercel-scripts/src/commands/promote.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { VercelClient } from "@fern-fern/vercel";
import { cleanDeploymentId } from "../utils/clean-id.js";
import { requestPromote } from "../utils/promoter.js";
import { revalidateAllCommand } from "./revalidate-all.js";

interface PromoteArgs {
deploymentIdOrUrl: string;
token: string;
teamId: string;
revalidateAll?: boolean;
}

export async function promoteCommand({ deploymentIdOrUrl, token, teamId, revalidateAll }: PromoteArgs): Promise<void> {
const vercel = new VercelClient({ token });

const deployment = await vercel.deployments.getDeployment(cleanDeploymentId(deploymentIdOrUrl), {
teamId,
withGitRepoInfo: "false",
});

await requestPromote(token, deployment);

if (revalidateAll) {
await revalidateAllCommand({ token, teamId, deployment });
}
}
34 changes: 34 additions & 0 deletions clis/vercel-scripts/src/commands/revalidate-all.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { VercelClient } from "@fern-fern/vercel";
import { GetDeploymentResponse } from "@fern-fern/vercel/api/index.js";
import { cleanDeploymentId } from "../utils/clean-id.js";
import { FernDocsRevalidator } from "../utils/revalidator.js";

interface RevalidateAllArgs {
token: string;
teamId: string;
deployment?: GetDeploymentResponse;
deploymentIdOrUrl?: string;
}

export async function revalidateAllCommand({
token,
teamId,
deployment,
deploymentIdOrUrl,
}: RevalidateAllArgs): Promise<void> {
if (!deployment) {
if (!deploymentIdOrUrl) {
throw new Error("Either deployment or deploymentIdOrUrl must be provided");
}

const vercel = new VercelClient({ token });
deployment = await vercel.deployments.getDeployment(cleanDeploymentId(deploymentIdOrUrl));
}

if (!deployment.project) {
throw new Error("Deployment does not have a project");
}

const revalidator = new FernDocsRevalidator({ token, project: deployment.project.id, teamId });
await revalidator.revalidateAll();
}
7 changes: 7 additions & 0 deletions clis/vercel-scripts/src/utils/clean-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export function cleanDeploymentId(deploymentIdOrUrl: string): string {
const toReplace = deploymentIdOrUrl.replace("https://", "");
if (toReplace.length === 0) {
throw new Error(`Invalid deployment ID or URL: ${deploymentIdOrUrl}`);
}
return toReplace;
}
75 changes: 34 additions & 41 deletions clis/vercel-scripts/src/utils/deployer.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { VercelClient } from "@fern-fern/vercel";
import { Vercel, VercelClient } from "@fern-fern/vercel";
import { readFileSync } from "fs";
import { join } from "path";
import { UnreachableCaseError } from "ts-essentials";
import { exec } from "./exec.js";
import { cleanDeploymentId } from "./clean-id.js";
import { exec, logCommand } from "./exec.js";
import { requestPromote } from "./promoter.js";

export class VercelDeployer {
private token: string;
private teamName: string;
private teamId: string;
private environment: "preview" | "production";
private cwd: string;
private vercel: VercelClient;
public vercel: VercelClient;
constructor({
token,
teamName,
Expand Down Expand Up @@ -72,38 +74,49 @@ export class VercelDeployer {
});
}

private deploy(project: { id: string; name: string }): string {
private async deploy(project: { id: string; name: string }): Promise<Vercel.GetDeploymentResponse> {
let command = `pnpx vercel deploy --yes --prebuilt --token=${this.token} --archive=tgz`;
if (this.environment === "production") {
command += " --prod --skip-domain";
}
return exec(`[${this.environmentName}] Deploy bundle for ${project.name} to Vercel`, command, {
const deploymentUrl = exec(`[${this.environmentName}] Deploy bundle for ${project.name} to Vercel`, command, {
stdio: "pipe",
env: this.env(project.id),
cwd: this.cwd,
}).trim();

if (!deploymentUrl) {
throw new Error("Deployment failed: no deployment URL returned");
}

const deployment = await this.vercel.deployments.getDeployment(cleanDeploymentId(deploymentUrl));

logCommand(`[${this.environmentName}] Deployment URL: ${deployment.url}`);

if ("inspectorUrl" in deployment) {
logCommand(`[${this.environmentName}] Inspector URL: ${deployment.inspectorUrl}`);
}

// eslint-disable-next-line no-console
console.log("Deployment Source:", deployment.source);

return deployment;
}

public promote(deploymentUrl: string): void {
private async promote(deployment: Vercel.GetDeploymentResponse): Promise<void> {
if (this.environment === "production") {
exec(
`[${this.environmentName}] Promote ${deploymentUrl}`,
`pnpx vercel promote ${deploymentUrl} --token=${this.token}`,
{ cwd: this.cwd },
);
const isDev2 = this.loadEnvFile().includes("registry-dev2.buildwithfern.com");
if (!isDev2) {
return;
}
await requestPromote(this.token, deployment);
}
}

public async buildAndDeployToVercel(
project: string,
{ skipDeploy = false }: { skipDeploy?: boolean } = {},
): Promise<
| {
deploymentUrl: string;
canPromote: boolean;
}
| undefined
> {
): Promise<Vercel.GetDeploymentResponse | undefined> {
const prj = await this.vercel.projects.getProject(project, { teamId: this.teamId });

this.pull(prj);
Expand All @@ -114,31 +127,11 @@ export class VercelDeployer {
return;
}

const deploymentUrl = this.deploy(prj);

if (!deploymentUrl) {
throw new Error("Deployment failed: no deployment URL returned");
}

let canPromote = this.environment === "production";
const deployment = await this.deploy(prj);

if (canPromote) {
/**
* If the deployment is to the dev2 registry, we should automatically promote it
* and not allow manual promotion.
*/
const isDev2 = this.loadEnvFile().includes("registry-dev2.buildwithfern.com");

if (isDev2) {
this.promote(deploymentUrl);
canPromote = false;
}
}
await this.promote(deployment);

return {
deploymentUrl,
canPromote,
};
return deployment;
}

private loadEnvFile(): string {
Expand Down
47 changes: 47 additions & 0 deletions clis/vercel-scripts/src/utils/promoter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Vercel } from "@fern-fern/vercel";
import { logCommand } from "./exec.js";

export async function requestPromote(token: string, deployment: Vercel.GetDeploymentResponse): Promise<void> {
logCommand(`[Production] Promote ${deployment.url}`);

if (deployment.target !== "production") {
throw new Error("Deployment is not a production deployment");
}

if (deployment.readyState !== "READY") {
throw new Error("Deployment is not ready");
}

if (deployment.readySubstate === "PROMOTED") {
// eslint-disable-next-line no-console
console.log(`Deployment ${deployment.name} is already promoted`);
return;
}

if (deployment.readySubstate !== "STAGED") {
throw new Error("Deployment is not staged for promotion");
}

if (!deployment.project) {
throw new Error("Deployment has no project");
}

/**
* The vercel promote cli command does not accept tokens as an argument, so we have to use the API directly
*
* Note: the fern-generated SDK doesn't work for this, so we have to use fetch directly
*/
// await vercel.projects.requestPromote(deployment.project.id, deployment.id, { teamId });
await fetch(`https://api.vercel.com/v10/projects/${deployment.project.id}/promote/${deployment.id}`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
// required
body: JSON.stringify({}),
});

// eslint-disable-next-line no-console
console.log(`Successfully requested promote of ${deployment.name} to ${deployment.project.name}`);
}
4 changes: 3 additions & 1 deletion clis/vercel-scripts/src/utils/revalidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { logCommand } from "./exec.js";

const BANNED_DOMAINS = ["vercel.app", "buildwithfern.com", "ferndocs.com"];

export class DocsRevalidator {
export class FernDocsRevalidator {
private vercel: VercelClient;
private project: string;
private teamId: string;
Expand Down Expand Up @@ -53,6 +53,8 @@ export class DocsRevalidator {
}

async revalidateAll(): Promise<void> {
logCommand("Revalidating all docs");

const summary: Record<string, { success: number; failed: number }> = {};

for await (const domain of this.getProductionDomains()) {
Expand Down
Loading
Loading