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

Deploy option to enable continuous deployment #1745

Open
wants to merge 50 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
adaa867
checkpoint - kicking off cloud build from cli sorta working
tophtucker Oct 4, 2024
fa5e762
correct ellipsis
tophtucker Oct 8, 2024
6e614ca
continuousDeployment flag persisted in deploy.json
tophtucker Oct 11, 2024
a7efd4c
poll for repo access
tophtucker Oct 11, 2024
1077838
cleanup. remove "Do you wanna link GitHub" prompt, since you have to
tophtucker Oct 11, 2024
44b1e5f
various post-demo cleanup; maybeLinkGitHub even if we’re not enabling…
tophtucker Oct 15, 2024
b6c2a3f
better handling of the case where github is not connected at all; set…
tophtucker Oct 15, 2024
bb790ef
throw Error → throw new Error
tophtucker Oct 15, 2024
93203ca
Merge branch 'main' into toph/onramp
tophtucker Oct 15, 2024
8128173
fix existing tests
tophtucker Oct 15, 2024
56e4d14
start on docs
tophtucker Oct 22, 2024
6f7c852
Merge branch 'main' into toph/onramp
tophtucker Oct 25, 2024
9e234a2
use new singular endpoint to see if repo is authed
tophtucker Oct 25, 2024
c9effe0
clean up logic, more dry
tophtucker Oct 26, 2024
8c75f52
rm getProjectEnvironment calls
tophtucker Oct 29, 2024
75cbf50
link to internal interstitial screen instead of directly to github
tophtucker Oct 30, 2024
e80786b
slightly more human-readable error message
tophtucker Oct 30, 2024
ea676b1
dont change continuousDeployment setting as a side effect of github l…
tophtucker Oct 30, 2024
06b52f7
flatten structure of maybeLinkGitHub with early returns
tophtucker Oct 31, 2024
a7ba250
rename maybeLinkGitHub: boolean to validateGitHubLink: void, more err…
tophtucker Oct 31, 2024
5830afc
support SSH github remotes
tophtucker Nov 1, 2024
048fd88
move validateGitHubLink call to a better higher clearer more consolid…
tophtucker Nov 1, 2024
7c719ae
tweak cd prompt to clarify that you need a github repo
tophtucker Nov 1, 2024
a6ac45e
Merge branch 'main' into toph/onramp
tophtucker Nov 1, 2024
8407f88
minimize diff
tophtucker Nov 1, 2024
476ebec
minimize diff, again
tophtucker Nov 1, 2024
bc60124
first tests of validateGitHubLink
tophtucker Nov 2, 2024
14ce19b
whoohoo end-to-end test of kicking off cloud build
tophtucker Nov 2, 2024
27128bb
ALL TESTS PASSING incl coverage threshold, thanks to testing cloud bu…
tophtucker Nov 2, 2024
3a5de29
Merge branch 'main' into toph/onramp
tophtucker Nov 2, 2024
82f7b0d
move mockIsolatedDirectory to own file
tophtucker Nov 2, 2024
105f0ed
Merge branch 'main' into toph/onramp
tophtucker Nov 2, 2024
e511cc7
testing if git is installed on ubuntu
tophtucker Nov 2, 2024
bb9c311
testing deterministic default branch name
tophtucker Nov 2, 2024
ff60b80
more debugging...
tophtucker Nov 2, 2024
f1cd74d
setting name and email config for git, seeing if that helps
tophtucker Nov 2, 2024
e188a39
fix command separator from ; to && for windows; prettier for ubuntu
tophtucker Nov 2, 2024
fae07e1
use fs instead of touch for cross-platform (windows) compatibility; f…
tophtucker Nov 2, 2024
41f9682
force: true on rm dir for windows complaining ENOTEMPTY
tophtucker Nov 2, 2024
dd50d70
adopting rimraf in lieu of node fs rm for what i hope is better windo…
Nov 2, 2024
3f0bbb8
oops i wasnt actually closing the file i made. good call, windows. ro…
Nov 2, 2024
fede926
ah i had another case of touch
Nov 2, 2024
172bf50
new test for when repo doesnt match
Nov 2, 2024
87f707d
add test for polling for repo auth; deploy.ts test coverage is now ba…
Nov 2, 2024
159d708
WHEW i think everything should be passing now, removing debug console…
Nov 2, 2024
1a5c9f9
lmao last few commits are attributed to my test user bc the exec git …
tophtucker Nov 2, 2024
4a8e4e7
now that were not setting the git config options globally we have to …
Nov 2, 2024
a23c35c
set initial branch when initializing repo instead of setting the defa…
Nov 2, 2024
36e2153
Merge branch 'main' into toph/onramp
Fil Nov 8, 2024
3e5e669
Fil/onramp review (#1805)
Fil Nov 12, 2024
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
21 changes: 13 additions & 8 deletions docs/deploying.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,21 @@ npm run deploy -- --help

</div>

## Automated deploys
## Continuous deployment

After deploying an app manually at least once, Observable can handle subsequent deploys for you automatically. You can automate deploys both [on commit](https://observablehq.com/documentation/data-apps/github) (whenever you push a new commit to your project’s default branch) and [on schedule](https://observablehq.com/documentation/data-apps/schedules) (such as daily or weekly).
### Cloud builds

Automatic deploys — also called _continuous deployment_ or _CD_ — ensure that your data is always up to date, and that any changes you make to your app are immediately reflected in the deployed version.
Connect your app to Observable to handle deploys automatically. You can automate deploys both [on commit](https://observablehq.com/documentation/data-apps/github) (whenever you push a new commit to your project’s default branch) and [on schedule](https://observablehq.com/documentation/data-apps/schedules) (such as daily or weekly).

Continuous deployment (for short, _CD_) ensures that your data is always up to date, and that any changes you make to your app are immediately reflected in the deployed version.

On your app settings page on Observable, open the **Build settings** tab to set up a link to a GitHub repository hosting your project’s files. Observable will then listen for changes in the repo and deploy the app automatically.

The settings page also allows you to trigger a manual deploy on Observable Cloud, add secrets (for data loaders to use private APIs and passwords), view logs, configure sharing, _etc._ For details, see the [Building & deploying](https://observablehq.com/documentation/data-apps/deploys) documentation.
The settings page also allows you to trigger a manual deploy, add secrets for data loaders to use private APIs and passwords, view logs, configure sharing, _etc._ For details, see the [Building & deploying](https://observablehq.com/documentation/data-apps/deploys) documentation.

## GitHub Actions
### GitHub Actions

As an alternative to building on Observable Cloud, you can use [GitHub Actions](https://github.com/features/actions) and have GitHub build a new version of your app and deploy it to Observable. In your git repository, create and commit a file at `.github/workflows/deploy.yml`. Here is a starting example:
Alternatively, you can use [GitHub Actions](https://github.com/features/actions) to have GitHub build a new version of your app and deploy it to Observable. In your git repository, create and commit a file at `.github/workflows/deploy.yml`. Here is a starting example:

```yaml
name: Deploy
Expand Down Expand Up @@ -88,7 +90,7 @@ To create an API key:

1. Open the [API Key settings](https://observablehq.com/select-workspace?next=api-keys-settings) for your Observable workspace.
2. Click **New API Key**.
3. Check the **Deploy new versions of projects** checkbox. <!-- TODO apps -->
3. Check the **Deploy new versions of data apps** checkbox.
4. Give your key a description, such as “Deploy via GitHub Actions”.
5. Click **Create API Key**.

Expand Down Expand Up @@ -137,6 +139,8 @@ This uses one cache per calendar day (in the `America/Los_Angeles` time zone). I

<div class="note">You’ll need to edit the paths above if you’ve configured a source root other than <code>src</code>.</div>

<div class="tip">Caching is limited for now to manual builds and GitHub Actions. In the future, it will be available as a configuration option for Observable Cloud builds.</div>

## Deploy configuration

The deploy command creates a file at <code>.observablehq/deploy.json</code> under the source root (typically <code>src</code>) with information on where to deploy the app. This file allows you to re-deploy an app without having to repeat where you want the app to live on Observable.
Expand All @@ -147,7 +151,8 @@ The contents of the deploy config file look like this:
{
"projectId": "0123456789abcdef",
"projectSlug": "hello-framework",
"workspaceLogin": "acme"
"workspaceLogin": "acme",
"continuousDeployment": true
}
```

Expand Down
213 changes: 187 additions & 26 deletions src/deploy.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import {exec} from "node:child_process";
import {createHash} from "node:crypto";
import type {Stats} from "node:fs";
import {existsSync} from "node:fs";
import {readFile, stat} from "node:fs/promises";
import {join} from "node:path/posix";
import {promisify} from "node:util";
import slugify from "@sindresorhus/slugify";
import wrapAnsi from "wrap-ansi";
import type {BuildEffects, BuildManifest, BuildOptions} from "./build.js";
Expand All @@ -15,7 +18,7 @@ import {visitFiles} from "./files.js";
import type {Logger} from "./logger.js";
import type {AuthEffects} from "./observableApiAuth.js";
import {defaultEffects as defaultAuthEffects, formatUser, loginInner, validWorkspaces} from "./observableApiAuth.js";
import {ObservableApiClient} from "./observableApiClient.js";
import {ObservableApiClient, getObservableUiOrigin} from "./observableApiClient.js";
import type {
DeployManifestFile,
GetCurrentUserResponse,
Expand All @@ -33,6 +36,26 @@ const DEPLOY_POLL_MAX_MS = 1000 * 60 * 5;
const DEPLOY_POLL_INTERVAL_MS = 1000 * 5;
const BUILD_AGE_WARNING_MS = 1000 * 60 * 5;

const OBSERVABLE_UI_ORIGIN = getObservableUiOrigin();

function settingsUrl(deployTarget: DeployTargetInfo) {
if (deployTarget.create) throw new Error("Incorrect deploy target state");
return `${OBSERVABLE_UI_ORIGIN}projects/@${deployTarget.workspace.login}/${deployTarget.project.slug}`;
}

/**
* Returns the ownerName and repoName of the first GitHub remote (HTTPS or SSH)
* on the current repository. Supports both https and ssh URLs:
* - https://github.com/observablehq/framework.git
* - [email protected]:observablehq/framework.git
*/
async function getGitHubRemote(): Promise<{ownerName: string; repoName: string} | undefined> {
const firstRemote = (await promisify(exec)("git remote -v")).stdout.match(
/^\S+\s(https:\/\/github.com\/|[email protected]:)(?<ownerName>[^/]+)\/(?<repoName>[^/]*?)(\.git)?\s/m
);
return firstRemote?.groups as {ownerName: string; repoName: string} | undefined;
}

export interface DeployOptions {
config: Config;
deployConfigPath: string | undefined;
Expand Down Expand Up @@ -84,7 +107,7 @@ type DeployTargetInfo =
| {create: true; workspace: {id: string; login: string}; projectSlug: string; title: string; accessLevel: string}
| {create: false; workspace: {id: string; login: string}; project: GetProjectResponse};

/** Deploy a project to ObservableHQ */
/** Deploy a project to Observable */
export async function deploy(deployOptions: DeployOptions, effects = defaultEffects): Promise<void> {
Telemetry.record({event: "deploy", step: "start", force: deployOptions.force});
effects.clack.intro(`${inverse(" observable deploy ")} ${faint(`v${process.env.npm_package_version}`)}`);
Expand Down Expand Up @@ -180,24 +203,145 @@ class Deployer {
const {deployId} = this.deployOptions;
if (!deployId) throw new Error("invalid deploy options");
await this.checkDeployCreated(deployId);
await this.uploadFiles(deployId, await this.getBuildFilePaths());
await this.markDeployUploaded(deployId);
return await this.pollForProcessingCompletion(deployId);
}

const buildFilePaths = await this.getBuildFilePaths();
private async cloudBuild(deployTarget: DeployTargetInfo) {
if (deployTarget.create) throw new Error("Incorrect deploy target state");
const {deployPollInterval: pollInterval = DEPLOY_POLL_INTERVAL_MS} = this.deployOptions;
await this.apiClient.postProjectBuild(deployTarget.project.id);
const spinner = this.effects.clack.spinner();
spinner.start("Requesting deploy");
const pollExpiration = Date.now() + DEPLOY_POLL_MAX_MS;
while (true) {
if (Date.now() > pollExpiration) {
spinner.stop("Requesting deploy timed out.");
throw new CliError("Requesting deploy failed");
}
const {latestCreatedDeployId} = await this.apiClient.getProject({
workspaceLogin: deployTarget.workspace.login,
projectSlug: deployTarget.project.slug
});
if (latestCreatedDeployId !== deployTarget.project.latestCreatedDeployId) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

chatting with fil: there's no guarantee that the new deploy ID is the one you just kicked off; maybe your colleague click the deploy button around the same time. currently, the postProjectBuild method can't return the new deploy ID because it just dispatches a message to the job-manager, which at some point will get around to making a new deploy. two options:

  • create deploy id earlier, in api, and pass to job manager (which feels like it might mess with our messaging protocol? i don't know all the reasons it was designed like it is today)
  • pass some unique string to the api, which will pass it to the job manager, which will eventually respond with it, which would let us identify that the deploy was our own

but fil thinks this is probably not a blocking issue with this PR

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not planning to do anything about this for now

spinner.stop(
`Deploy started. Watch logs: ${link(`${settingsUrl(deployTarget)}/deploys/${latestCreatedDeployId}`)}`
);
// latestCreatedDeployId is initially null for a new project, but once
// it changes to a string it can never change back; since we know it has
// changed, we assert here that it’s not null
return latestCreatedDeployId!;
}
await new Promise((resolve) => setTimeout(resolve, pollInterval));
}
}

await this.uploadFiles(deployId, buildFilePaths);
await this.markDeployUploaded(deployId);
const deployInfo = await this.pollForProcessingCompletion(deployId);
// Throws error if local and remote GitHub repos don’t match or are invalid.
// Ignores this.deployOptions.config.root as we only support cloud builds from
// the root directory.
private async validateGitHubLink(deployTarget: DeployTargetInfo): Promise<void> {
if (deployTarget.create) throw new Error("Incorrect deploy target state");
if (!deployTarget.project.build_environment_id) throw new CliError("No build environment configured.");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we could add a link to the build settings page maybe?

something like https://observablehq.com/projects/@{user}/{data-app-slug}/settings

if (!existsSync(".git")) throw new CliError("Not at root of a git repository.");
const remote = await getGitHubRemote();
if (!remote) throw new CliError("No GitHub remote found.");
const branch = (await promisify(exec)("git rev-parse --abbrev-ref HEAD")).stdout.trim();
if (!branch) throw new Error("Branch not found.");

// If a source repository has already been configured, check that it’s
// accessible and matches the linked repository and branch. TODO: validate
// local/remote refs match, "Your branch is up to date", and "nothing to
// commit, working tree clean".
const {source} = deployTarget.project;
if (source) {
const linkedRepo = await this.apiClient.getGitHubRepository(remote);
if (linkedRepo) {
if (source.provider_id !== linkedRepo.provider_id) {
throw new CliError(
`Configured repository does not match local repository; check build settings on ${link(
`${settingsUrl(deployTarget)}/settings`
)}`
);
}
if (source.branch !== branch) {
// TODO: If source.branch is empty, it'll use the default repository
// branch (usually main or master), which we don't know from our current
// getGitHubRepository response, and thus can't check here.
throw new CliError(
`Configured branch ${source.branch} does not match local branch ${branch}; check build settings on ${link(
`${settingsUrl(deployTarget)}/settings`
)}`
);
}
}

return deployInfo;
if (!(await this.apiClient.getGitHubRepository({providerId: source.provider_id}))) {
// TODO: This could poll for auth too, but is a distinct case because it
// means the repo was linked at one point and then something went wrong
throw new CliError(
`Cannot access configured repository; check build settings on ${link(
`${settingsUrl(deployTarget)}/settings`
)}`
);
}

// Configured repo is OK; proceed
return;
}

// If the source has not been configured, first check that the remote repo
// is linked in CD settings. If not, prompt the user to auth & link.
let linkedRepo = await this.apiClient.getGitHubRepository(remote);
if (!linkedRepo) {
if (!this.effects.isTty)
throw new CliError(
"Cannot access repository for continuous deployment and cannot request access in non-interactive mode"
);

// Repo is not authorized; link to auth page and poll for auth
const authUrl = new URL("/auth-github", OBSERVABLE_UI_ORIGIN);
authUrl.searchParams.set("owner", remote.ownerName);
authUrl.searchParams.set("repo", remote.repoName);
this.effects.clack.log.info(
`Authorize Observable to access the ${bold(remote.repoName)} repository: ${link(authUrl)}`
);

const spinner = this.effects.clack.spinner();
spinner.start("Waiting for authorization");
const {deployPollInterval: pollInterval = DEPLOY_POLL_INTERVAL_MS} = this.deployOptions;
const pollExpiration = Date.now() + DEPLOY_POLL_MAX_MS;
do {
await new Promise((resolve) => setTimeout(resolve, pollInterval));
if (Date.now() > pollExpiration) {
spinner.stop("Authorization timed out.");
throw new CliError("Repository authorization failed");
}
} while (!(linkedRepo = await this.apiClient.getGitHubRepository(remote)));
spinner.stop("Repository authorized.");
}

// Save the linked repo as the configured source.
const {provider, provider_id, url} = linkedRepo;
await this.apiClient
.postProjectEnvironment(deployTarget.project.id, {source: {provider, provider_id, url, branch}})
.catch((error) => {
throw new CliError("Setting source repository for continuous deployment failed", {cause: error});
});
}

private async startNewDeploy(): Promise<GetDeployResponse> {
const deployConfig = await this.getUpdatedDeployConfig();
const deployTarget = await this.getDeployTarget(deployConfig);
const buildFilePaths = await this.getBuildFilePaths();
Comment on lines -194 to -196
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fil notes that this could be parallelized: getBuildFilePaths doesn't depend on the previous two; it's filesystem i/o, as opposed to talking to the api. might speed up local deploys a bit

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not gonna worry about this for now; also, buildFilePaths is only relevant for the local deploy path, so it's not as clean to parallelize anymore

const deployId = await this.createNewDeploy(deployTarget);

await this.uploadFiles(deployId, buildFilePaths);
await this.markDeployUploaded(deployId);
const {deployConfig, deployTarget} = await this.getDeployTarget(await this.getUpdatedDeployConfig());
let deployId: string;
if (deployConfig.continuousDeployment) {
await this.validateGitHubLink(deployTarget);
deployId = await this.cloudBuild(deployTarget);
} else {
const buildFilePaths = await this.getBuildFilePaths();
deployId = await this.createNewDeploy(deployTarget);
await this.uploadFiles(deployId, buildFilePaths);
await this.markDeployUploaded(deployId);
}
return await this.pollForProcessingCompletion(deployId);
tophtucker marked this conversation as resolved.
Show resolved Hide resolved
}

Expand All @@ -210,11 +354,7 @@ class Deployer {
}
return deployInfo;
} catch (error) {
if (isHttpError(error)) {
throw new CliError(`Deploy ${deployId} not found.`, {
cause: error
});
}
if (isHttpError(error)) throw new CliError(`Deploy ${deployId} not found.`, {cause: error});
throw error;
}
}
Expand Down Expand Up @@ -274,7 +414,9 @@ class Deployer {
}

// Get the deploy target, prompting the user as needed.
private async getDeployTarget(deployConfig: DeployConfig): Promise<DeployTargetInfo> {
private async getDeployTarget(
deployConfig: DeployConfig
): Promise<{deployTarget: DeployTargetInfo; deployConfig: DeployConfig}> {
let deployTarget: DeployTargetInfo;
if (deployConfig.workspaceLogin && deployConfig.projectSlug) {
try {
Expand Down Expand Up @@ -384,18 +526,37 @@ class Deployer {
}
}

let {continuousDeployment} = deployConfig;
if (continuousDeployment === null) {
const enable = await this.effects.clack.confirm({
message: wrapAnsi(
`Do you want to enable continuous deployment? ${faint(
"Given a GitHub repository, this builds in the cloud and redeploys whenever you push to the current branch."
)}`,
this.effects.outputColumns
),
active: "Yes, enable and build in cloud",
inactive: "No, build locally"
});
if (this.effects.clack.isCancel(enable)) throw new CliError("User canceled deploy", {print: false, exitCode: 0});
continuousDeployment = enable;
}

deployConfig = {
projectId: deployTarget.project.id,
projectSlug: deployTarget.project.slug,
workspaceLogin: deployTarget.workspace.login,
continuousDeployment
};

await this.effects.setDeployConfig(
this.deployOptions.config.root,
this.deployOptions.deployConfigPath,
{
projectId: deployTarget.project.id,
projectSlug: deployTarget.project.slug,
workspaceLogin: deployTarget.workspace.login
},
deployConfig,
this.effects
);

return deployTarget;
return {deployConfig, deployTarget};
}

// Create the new deploy on the server.
Expand Down
Loading