From d3a2bc7f71a0caa673407eefb493ce3271fffc7f Mon Sep 17 00:00:00 2001 From: Kasra Bigdeli Date: Sat, 12 Sep 2026 23:22:13 -0700 Subject: [PATCH] Introduce simplified v2 deployment UX --- README.md | 202 +++++++++++++++++------------------------ action.yml | 16 ++-- dist/index.js | 106 ++++++++++++++++----- package-lock.json | 4 +- package.json | 2 +- src/archive.ts | 38 ++++++-- src/caprover.ts | 8 +- src/deploy.ts | 53 +++++++++-- src/index.ts | 2 +- src/inputs.ts | 32 ++++++- tests/archive.test.ts | 33 ++++++- tests/caprover.test.ts | 9 +- tests/deploy.test.ts | 53 +++++++---- tests/inputs.test.ts | 51 ++++++++++- 14 files changed, 409 insertions(+), 200 deletions(-) diff --git a/README.md b/README.md index f095d9c..883a921 100644 --- a/README.md +++ b/README.md @@ -1,137 +1,101 @@ -# Deploy from Github +# Deploy to CapRover -This Github Action uses CapRover's App Token strategy to deploy an app directly from Github. -An example workflow provided below, shows how we can automagically create a deploy.tar file as a required part of a build & deployment strategy. +Deploy checked-out source, a Docker image, or a prepared tar file to CapRover using an app token. -Using this Github Action requires the following three pieces of information to be entered into Github Secrets for your project repository: +## Quick start -- `app` secret is the name of your app, exactly as it's specified in Caprover. -- `token` secret is obtained fromt he "Deployment" tab of the app in Caprover. Click "Enable App Token" to generate a token. -- `server` secret can be organization-wide, per project, or per project override and in the format of https://captain.apps.your-domain.com. -Optional: -- `image` secret can be used to specify the specific image you want to deploy, this is particularly useful when you want to build on Github. -- `branch` secret can be used to specify the branch you want to deploy to CapRover. -- If `image` and `branch` are empty, this action expects a tar file located at the root of the project `./deploy.tar` to deploy +```yaml +- uses: actions/checkout@v6 +- uses: caprover/deploy-from-github@v2 + with: + server: https://captain.example.com + app: my-api + token: ${{ secrets.CAPROVER_APP_TOKEN }} +``` +This packages the files committed in the checked-out `HEAD` and submits the deployment to CapRover. Generate an app token from the app's **Deployment** tab in CapRover. -### Example 1 - deploy using image: -This method is preferred because you end up using Github servers to build your image and your own CapRover server just receives the built image. This is very useful specially if your server resources are limited. -Specify `CAPROVER_APP_TOKEN` and `CAPROVER_HOST` as secret in your repo. Also change `env` section in the action and you're good to go! +## Deploy a Docker image +Build and push the image with the standard Docker actions, then ask CapRover to deploy it: ```yaml -name: Deploy to staging - -env: - CONTEXT_DIR: './' - IMAGE_NAME: ${{ github.repository }}/staging - DOCKERFILE: Dockerfile.staging - CAPROVER_APP: myapp-staging - DOCKER_REGISTRY: ghcr.io - -on: - push: - branches: - - main - # you can specify path if you have a monorepo and you want to deploy if particular directory is changed, make sure to update `CONTEXT_DIR` too - # paths: - # - "backend-app/**" - -jobs: - build-and-publish: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v1 - - run: | - echo "IMAGE_NAME_WITH_REGISTRY=$DOCKER_REGISTRY/$IMAGE_NAME" >> $GITHUB_ENV - export IMAGE_NAME_WITH_REGISTRY=$DOCKER_REGISTRY/$IMAGE_NAME - echo "FULL_IMAGE_NAME=$IMAGE_NAME_WITH_REGISTRY:$GITHUB_SHA-gitsha" >> $GITHUB_ENV - echo "CAPROVER_GIT_COMMIT_SHA=$GITHUB_SHA" >> $GITHUB_ENV - - name: Log in to the Container registry - uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 - with: - registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and Push Release to DockerHub - shell: bash - run: | - set -e - - cd $CONTEXT_DIR - rm /tmp/build_args || echo OK - env >/tmp/build_args - echo "--build-arg \""$(cat /tmp/build_args | sed -z 's/\n/" --build-arg "/g')"IGNORE_VAR=IGNORE_VAR\"" >/tmp/build_args - BUILD_ARGS=$(cat /tmp/build_args) - COMMAND="docker build -t $FULL_IMAGE_NAME -t $IMAGE_NAME_WITH_REGISTRY:latest -f $DOCKERFILE $BUILD_ARGS --no-cache ." - /bin/bash -c "$COMMAND" - docker push $IMAGE_NAME_WITH_REGISTRY:latest - docker push $FULL_IMAGE_NAME - rm /tmp/build_args - - name: Deploy to CapRover - uses: caprover/deploy-from-github@d76580d79952f6841c453bb3ed37ef452b19752c - with: - server: ${{ secrets.CAPROVER_HOST }} - app: ${{ env.CAPROVER_APP }} - token: '${{ secrets.CAPROVER_APP_TOKEN }}' - image: '${{ env.FULL_IMAGE_NAME }}' - +- uses: actions/checkout@v6 + +- uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + +- uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ghcr.io/acme/my-api:${{ github.sha }} + +- uses: caprover/deploy-from-github@v2 + with: + server: https://captain.example.com + app: my-api + token: ${{ secrets.CAPROVER_APP_TOKEN }} + image: ghcr.io/acme/my-api:${{ github.sha }} ``` -### Example 2 - deploy using `./deploy.tar` +## Monorepo -The example workflow contains a few steps to process your source code into a deployed app in Caprover. The first step uses the a CI/CD version of Node Package Manager (NPM) to build the front-end from source code. The second step packages up your newly minted dist/ directory, the existing backend/ directory and captain-definition file into a deploy.tar file. In the last step the deploy.tar file is picked up by this Github Action and using the provided secrets, will send the file to the Caprover server where it will be deployed. +`working-directory` packages that directory's committed contents at the root of the deployment tar: ```yaml -name: Build App & Deploy - -on: - push: - branches: [ "main" ] - - pull_request: - branches: [ "main" ] - -jobs: - build-and-deploy: - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [18.x] - - steps: - - uses: actions/checkout@v3 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 - with: - node-version: ${{ matrix.node-version }} - cache: "npm" - - run: npm ci - - run: npm run build --if-present - - run: npm run test --if-present - - # Future plans in the works to create tarball from within the caprover/deploy-from-github action. - - uses: a7ul/tar-action@v1.1.0 - with: - command: c - cwd: "./" - files: | - backend/ - frontend/dist/ - captain-definition - outPath: deploy.tar - - - uses: caprover/deploy-from-github@main - with: - server: '${{ secrets.CAPROVER_SERVER }}' - app: '${{ secrets.APP_NAME }}' - token: '${{ secrets.APP_TOKEN }}' - branch: '${{ secrets.DEPLOY_BRANCH }}' # optional - image: '${{ secrets.DEPLOY_IMAGE }}' # optional +- uses: actions/checkout@v6 + +- uses: caprover/deploy-from-github@v2 + with: + server: https://captain.example.com + app: my-api + token: ${{ secrets.CAPROVER_APP_TOKEN }} + working-directory: apps/api +``` +## Deploy a prepared tar + +Use `tar-file` when an earlier step produces the exact deployment archive: + +```yaml +- uses: actions/checkout@v6 + +- uses: caprover/deploy-from-github@v2 + with: + server: https://captain.example.com + app: my-api + token: ${{ secrets.CAPROVER_APP_TOKEN }} + tar-file: ./dist/deploy.tar ``` -NOTE: Deployments take place within seconds after the workflow has been processed succesfully with any failed deployments sending an email alert to your email on file with Github. +## Inputs + +| Input | Required | Default | Description | +| ------------------- | -------- | ------- | ---------------------------------------------------------- | +| `server` | Yes | | CapRover URL, such as `https://captain.example.com` | +| `app` | Yes | | CapRover app name | +| `token` | Yes | | App token from the app's Deployment tab | +| `image` | No | | Existing Docker image for CapRover to deploy | +| `tar-file` | No | | Prepared tar file, resolved from the GitHub workspace | +| `working-directory` | No | `.` | Source directory whose committed contents should be packed | + +`image` and `tar-file` are mutually exclusive. `working-directory` applies to source deployments. + +## Private registries + +CapRover pulls an image from the registry during deployment. Configure the registry credentials in CapRover before deploying a private image. Logging the GitHub runner into the registry only grants access to the runner. + +## Migration from v1 + +- Replace `caprover/deploy-from-github@v1` with `caprover/deploy-from-github@v2` when you are ready to migrate. +- The `branch` input has been removed. Check out the desired commit before running the action; v2 always packages checked-out `HEAD`. +- The default mode now packages checked-out `HEAD` automatically. +- To deploy an existing `./deploy.tar`, provide `tar-file: ./deploy.tar` explicitly. +- Generated and uncommitted files are excluded from the default source deployment. Package them into a tar file and use `tar-file` when they are required. +App-token deployments run in detached mode. A successful action means CapRover accepted the deployment; final build and application health are handled by CapRover. diff --git a/action.yml b/action.yml index be25d96..0ac4605 100644 --- a/action.yml +++ b/action.yml @@ -1,6 +1,6 @@ -name: "Deploy Github repo to Caprover" -description: "Github Action for deploying your app to Caprover." -author: "Caprover Contributors" +name: "Deploy to CapRover" +description: "Deploy checked-out source, a Docker image, or a prepared tar to CapRover." +author: "CapRover Contributors" inputs: server: @@ -12,12 +12,16 @@ inputs: app: description: "App Name" required: true - branch: - description: "Branch to be deployed" - required: false image: description: "Docker image to be deployed" required: false + tar-file: + description: "Prepared deployment tar file, relative to the GitHub workspace" + required: false + working-directory: + description: "Directory within the checked-out repository to deploy" + required: false + default: "." runs: using: "node24" diff --git a/dist/index.js b/dist/index.js index d5ce770..be695f7 100644 --- a/dist/index.js +++ b/dist/index.js @@ -10060,12 +10060,12 @@ var require_form_data = __commonJS({ if (value.end != void 0 && value.end != Infinity && value.start != void 0) { callback(null, value.end + 1 - (value.start ? value.start : 0)); } else { - fs.stat(value.path, function(err, stat) { + fs.stat(value.path, function(err, stat3) { if (err) { callback(err); return; } - var fileSize = stat.size - (value.start ? value.start : 0); + var fileSize = stat3.size - (value.start ? value.start : 0); callback(null, fileSize); }); } @@ -10320,17 +10320,29 @@ var import_node_os = require("node:os"); var import_node_path = __toESM(require("node:path")); var import_node_util = require("node:util"); var execFileAsync = (0, import_node_util.promisify)(import_node_child_process.execFile); -async function createGitArchive(ref, cwd = process.env.GITHUB_WORKSPACE || process.cwd()) { +async function createGitArchive(workingDirectory, workspace = process.env.GITHUB_WORKSPACE || process.cwd()) { const directory = await (0, import_promises.mkdtemp)(import_node_path.default.join((0, import_node_os.tmpdir)(), "caprover-deploy-")); const archivePath = import_node_path.default.join(directory, "deploy.tar"); try { + const workspacePath = await (0, import_promises.realpath)(workspace); + const sourcePath = await (0, import_promises.realpath)( + import_node_path.default.resolve(workspacePath, workingDirectory) + ); + const sourceStat = await (0, import_promises.stat)(sourcePath); + const relativeSource = import_node_path.default.relative(workspacePath, sourcePath); + if (!sourceStat.isDirectory() || relativeSource === ".." || relativeSource.startsWith(`..${import_node_path.default.sep}`) || import_node_path.default.isAbsolute(relativeSource)) { + throw new Error( + "must resolve to a directory inside the GitHub workspace" + ); + } + const treeRef = relativeSource ? `HEAD:${relativeSource.split(import_node_path.default.sep).join("/")}` : "HEAD"; await execFileAsync( "git", - ["archive", "--format=tar", "--output", archivePath, ref], - { cwd } + ["archive", "--format=tar", "--output", archivePath, treeRef], + { cwd: workspacePath } ); - const { stdout } = await execFileAsync("git", ["rev-parse", ref], { - cwd + const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD"], { + cwd: workspacePath }); const gitHash = stdout.trim(); if (!/^[a-f0-9]{40}$/.test(gitHash)) { @@ -10344,7 +10356,9 @@ async function createGitArchive(ref, cwd = process.env.GITHUB_WORKSPACE || proce } catch (error) { await (0, import_promises.rm)(directory, { recursive: true, force: true }); const message = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to archive Git ref "${ref}": ${message}`); + throw new Error( + `Failed to archive input "working-directory" (${workingDirectory}): ${message}` + ); } } @@ -10366,13 +10380,13 @@ var CapRoverClient = class { form.append("gitHash", gitHash); await this.request(app, form, form.getHeaders()); } - async deployImage(app, imageName) { + async deployImage(app, imageName, gitHash) { const body = JSON.stringify({ captainDefinitionContent: JSON.stringify({ schemaVersion: 2, imageName }), - gitHash: "" + gitHash }); await this.request(app, body, { "content-type": "application/json", @@ -10470,15 +10484,29 @@ function setFailed(message) { // src/deploy.ts async function deploy(inputs) { const client = new CapRoverClient(inputs.server, inputs.token); + info("Deploying to CapRover"); + info(""); + info(`App: ${inputs.app}`); + info(`Server: ${inputs.server}`); if (inputs.image) { - info(`Deploying image ${inputs.image} to ${inputs.app}...`); - await client.deployImage(inputs.app, inputs.image); + const candidateGitHash = (process.env.GITHUB_SHA || "").trim(); + const gitHash = /^[a-f0-9]{40}$/i.test(candidateGitHash) ? candidateGitHash : ""; + info(`Image: ${inputs.image}`); + if (gitHash) info(`Commit: ${gitHash.slice(0, 7)}`); + info(""); + await client.deployImage(inputs.app, inputs.image, gitHash); return; } - if (inputs.branch) { - const archive = await createGitArchive(inputs.branch); + if (!inputs.tarFile) { + const archive = await createGitArchive(inputs.workingDirectory); try { - info(`Deploying Git ref ${inputs.branch} to ${inputs.app}...`); + info("Source: Git commit"); + info(`Commit: ${archive.gitHash.slice(0, 7)}`); + if (inputs.workingDirectory !== ".") { + info(`Directory: ${inputs.workingDirectory}`); + } + info(""); + info("\u2713 Source packaged"); await client.uploadArchive(inputs.app, archive.path, archive.gitHash); } finally { await archive.cleanup(); @@ -10486,13 +10514,25 @@ async function deploy(inputs) { return; } const workspace = process.env.GITHUB_WORKSPACE || process.cwd(); - const tarPath = import_node_path2.default.resolve(workspace, "deploy.tar"); + const workspacePath = await (0, import_promises2.realpath)(workspace); + const requestedTarPath = import_node_path2.default.resolve(workspacePath, inputs.tarFile); + let tarPath; try { - await (0, import_promises2.access)(tarPath); + tarPath = await (0, import_promises2.realpath)(requestedTarPath); + const relativeTarPath = import_node_path2.default.relative(workspacePath, tarPath); + if (relativeTarPath === ".." || relativeTarPath.startsWith(`..${import_node_path2.default.sep}`) || import_node_path2.default.isAbsolute(relativeTarPath)) { + throw new Error("path is outside the workspace"); + } + const tarStat = await (0, import_promises2.stat)(tarPath); + if (!tarStat.isFile()) throw new Error("path is not a file"); } catch { - throw new Error(`Deployment archive was not found: ${tarPath}`); + throw new Error( + `Input "tar-file" does not point to a file inside the GitHub workspace: ${requestedTarPath}` + ); } - info(`Deploying ${tarPath} to ${inputs.app}...`); + info(`Source: Prepared tar`); + info(`Tar file: ${inputs.tarFile}`); + info(""); await client.uploadArchive(inputs.app, tarPath, ""); } @@ -10507,12 +10547,32 @@ function readRequiredInput(name) { function getInputs() { const token = readRequiredInput("token"); setSecret(token); + const server = readRequiredInput("server"); + try { + const url = new URL(server); + if (url.protocol !== "http:" && url.protocol !== "https:") + throw new Error(); + } catch { + throw new Error('Input "server" must be a valid HTTP or HTTPS URL'); + } + const image = getInput("image").trim(); + const tarFile = getInput("tar-file").trim(); + const workingDirectory = getInput("working-directory").trim() || "."; + if (image && tarFile) { + throw new Error('Inputs "image" and "tar-file" cannot be used together'); + } + if ((image || tarFile) && workingDirectory !== ".") { + throw new Error( + 'Input "working-directory" can only be used for source deployment' + ); + } return { - server: readRequiredInput("server"), + server, app: readRequiredInput("app"), token, - branch: getInput("branch").trim(), - image: getInput("image").trim() + image, + tarFile, + workingDirectory }; } @@ -10520,7 +10580,7 @@ function getInputs() { async function run() { try { await deploy(getInputs()); - info("Deployment accepted by CapRover"); + info("\u2713 Deployment accepted by CapRover"); } catch (error) { setFailed(error instanceof Error ? error.message : String(error)); } diff --git a/package-lock.json b/package-lock.json index 87bccb7..0392784 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "deploy-from-github", - "version": "1.0.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "deploy-from-github", - "version": "1.0.0", + "version": "2.0.0", "license": "MIT", "dependencies": { "form-data": "^4.0.4" diff --git a/package.json b/package.json index 7eac71c..f0fa5d7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "deploy-from-github", - "version": "1.0.0", + "version": "2.0.0", "private": true, "description": "Deploy applications to CapRover from GitHub Actions", "license": "MIT", diff --git a/src/archive.ts b/src/archive.ts index e50d6f0..3243fda 100644 --- a/src/archive.ts +++ b/src/archive.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, realpath, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { promisify } from "node:util"; @@ -13,20 +13,40 @@ export interface TemporaryArchive { } export async function createGitArchive( - ref: string, - cwd = process.env.GITHUB_WORKSPACE || process.cwd(), + workingDirectory: string, + workspace = process.env.GITHUB_WORKSPACE || process.cwd(), ): Promise { const directory = await mkdtemp(path.join(tmpdir(), "caprover-deploy-")); const archivePath = path.join(directory, "deploy.tar"); try { + const workspacePath = await realpath(workspace); + const sourcePath = await realpath( + path.resolve(workspacePath, workingDirectory), + ); + const sourceStat = await stat(sourcePath); + const relativeSource = path.relative(workspacePath, sourcePath); + if ( + !sourceStat.isDirectory() || + relativeSource === ".." || + relativeSource.startsWith(`..${path.sep}`) || + path.isAbsolute(relativeSource) + ) { + throw new Error( + "must resolve to a directory inside the GitHub workspace", + ); + } + + const treeRef = relativeSource + ? `HEAD:${relativeSource.split(path.sep).join("/")}` + : "HEAD"; await execFileAsync( "git", - ["archive", "--format=tar", "--output", archivePath, ref], - { cwd }, + ["archive", "--format=tar", "--output", archivePath, treeRef], + { cwd: workspacePath }, ); - const { stdout } = await execFileAsync("git", ["rev-parse", ref], { - cwd, + const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD"], { + cwd: workspacePath, }); const gitHash = stdout.trim(); if (!/^[a-f0-9]{40}$/.test(gitHash)) { @@ -41,6 +61,8 @@ export async function createGitArchive( } catch (error) { await rm(directory, { recursive: true, force: true }); const message = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to archive Git ref \"${ref}\": ${message}`); + throw new Error( + `Failed to archive input "working-directory" (${workingDirectory}): ${message}`, + ); } } diff --git a/src/caprover.ts b/src/caprover.ts index 0cba788..573c0d0 100644 --- a/src/caprover.ts +++ b/src/caprover.ts @@ -32,13 +32,17 @@ export class CapRoverClient { await this.request(app, form, form.getHeaders()); } - async deployImage(app: string, imageName: string): Promise { + async deployImage( + app: string, + imageName: string, + gitHash: string, + ): Promise { const body = JSON.stringify({ captainDefinitionContent: JSON.stringify({ schemaVersion: 2, imageName, }), - gitHash: "", + gitHash, }); await this.request(app, body, { "content-type": "application/json", diff --git a/src/deploy.ts b/src/deploy.ts index 84d6797..deb5fe0 100644 --- a/src/deploy.ts +++ b/src/deploy.ts @@ -1,4 +1,4 @@ -import { access } from "node:fs/promises"; +import { realpath, stat } from "node:fs/promises"; import path from "node:path"; import { createGitArchive } from "./archive.js"; import { CapRoverClient } from "./caprover.js"; @@ -8,16 +8,33 @@ import { Inputs } from "./inputs.js"; export async function deploy(inputs: Inputs): Promise { const client = new CapRoverClient(inputs.server, inputs.token); + info("Deploying to CapRover"); + info(""); + info(`App: ${inputs.app}`); + info(`Server: ${inputs.server}`); + if (inputs.image) { - info(`Deploying image ${inputs.image} to ${inputs.app}...`); - await client.deployImage(inputs.app, inputs.image); + const candidateGitHash = (process.env.GITHUB_SHA || "").trim(); + const gitHash = /^[a-f0-9]{40}$/i.test(candidateGitHash) + ? candidateGitHash + : ""; + info(`Image: ${inputs.image}`); + if (gitHash) info(`Commit: ${gitHash.slice(0, 7)}`); + info(""); + await client.deployImage(inputs.app, inputs.image, gitHash); return; } - if (inputs.branch) { - const archive = await createGitArchive(inputs.branch); + if (!inputs.tarFile) { + const archive = await createGitArchive(inputs.workingDirectory); try { - info(`Deploying Git ref ${inputs.branch} to ${inputs.app}...`); + info("Source: Git commit"); + info(`Commit: ${archive.gitHash.slice(0, 7)}`); + if (inputs.workingDirectory !== ".") { + info(`Directory: ${inputs.workingDirectory}`); + } + info(""); + info("✓ Source packaged"); await client.uploadArchive(inputs.app, archive.path, archive.gitHash); } finally { await archive.cleanup(); @@ -26,12 +43,28 @@ export async function deploy(inputs: Inputs): Promise { } const workspace = process.env.GITHUB_WORKSPACE || process.cwd(); - const tarPath = path.resolve(workspace, "deploy.tar"); + const workspacePath = await realpath(workspace); + const requestedTarPath = path.resolve(workspacePath, inputs.tarFile); + let tarPath: string; try { - await access(tarPath); + tarPath = await realpath(requestedTarPath); + const relativeTarPath = path.relative(workspacePath, tarPath); + if ( + relativeTarPath === ".." || + relativeTarPath.startsWith(`..${path.sep}`) || + path.isAbsolute(relativeTarPath) + ) { + throw new Error("path is outside the workspace"); + } + const tarStat = await stat(tarPath); + if (!tarStat.isFile()) throw new Error("path is not a file"); } catch { - throw new Error(`Deployment archive was not found: ${tarPath}`); + throw new Error( + `Input "tar-file" does not point to a file inside the GitHub workspace: ${requestedTarPath}`, + ); } - info(`Deploying ${tarPath} to ${inputs.app}...`); + info(`Source: Prepared tar`); + info(`Tar file: ${inputs.tarFile}`); + info(""); await client.uploadArchive(inputs.app, tarPath, ""); } diff --git a/src/index.ts b/src/index.ts index 88cc288..8ca8963 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import { getInputs } from "./inputs.js"; export async function run(): Promise { try { await deploy(getInputs()); - info("Deployment accepted by CapRover"); + info("✓ Deployment accepted by CapRover"); } catch (error) { setFailed(error instanceof Error ? error.message : String(error)); } diff --git a/src/inputs.ts b/src/inputs.ts index c658f2d..7381647 100644 --- a/src/inputs.ts +++ b/src/inputs.ts @@ -4,8 +4,9 @@ export interface Inputs { server: string; app: string; token: string; - branch: string; image: string; + tarFile: string; + workingDirectory: string; } function readRequiredInput(name: "server" | "app" | "token"): string { @@ -20,11 +21,34 @@ export function getInputs(): Inputs { const token = readRequiredInput("token"); setSecret(token); + const server = readRequiredInput("server"); + try { + const url = new URL(server); + if (url.protocol !== "http:" && url.protocol !== "https:") + throw new Error(); + } catch { + throw new Error('Input "server" must be a valid HTTP or HTTPS URL'); + } + + const image = getInput("image").trim(); + const tarFile = getInput("tar-file").trim(); + const workingDirectory = getInput("working-directory").trim() || "."; + + if (image && tarFile) { + throw new Error('Inputs "image" and "tar-file" cannot be used together'); + } + if ((image || tarFile) && workingDirectory !== ".") { + throw new Error( + 'Input "working-directory" can only be used for source deployment', + ); + } + return { - server: readRequiredInput("server"), + server, app: readRequiredInput("app"), token, - branch: getInput("branch").trim(), - image: getInput("image").trim(), + image, + tarFile, + workingDirectory, }; } diff --git a/tests/archive.test.ts b/tests/archive.test.ts index 7a56b13..db86f01 100644 --- a/tests/archive.test.ts +++ b/tests/archive.test.ts @@ -45,7 +45,7 @@ describe("createGitArchive", () => { cwd: repository, }); - const archive = await createGitArchive("HEAD", repository); + const archive = await createGitArchive(".", repository); expect(archive.gitHash).toMatch(/^[a-f0-9]{40}$/); await expect(access(archive.path)).resolves.toBeUndefined(); @@ -56,12 +56,41 @@ describe("createGitArchive", () => { await expect(access(archive.path)).rejects.toThrow(); }); + it("archives a working directory with its contents at the tar root", async () => { + const repository = await mkdtemp(path.join(tmpdir(), "monorepo-")); + directories.push(repository); + await execFileAsync("git", ["init"], { cwd: repository }); + await execFileAsync("git", ["config", "user.email", "test@example.com"], { + cwd: repository, + }); + await execFileAsync("git", ["config", "user.name", "Test"], { + cwd: repository, + }); + await mkdir(path.join(repository, "apps", "my api"), { recursive: true }); + await writeFile(path.join(repository, "root.txt"), "root"); + await writeFile( + path.join(repository, "apps", "my api", "captain-definition"), + "{}", + ); + await execFileAsync("git", ["add", "."], { cwd: repository }); + await execFileAsync("git", ["commit", "-m", "fixture"], { + cwd: repository, + }); + + const archive = await createGitArchive("apps/my api", repository); + const { stdout } = await execFileAsync("tar", ["-tf", archive.path]); + expect(stdout).toContain("captain-definition"); + expect(stdout).not.toContain("apps/my api"); + expect(stdout).not.toContain("root.txt"); + await archive.cleanup(); + }); + it("cleans its temporary directory when archiving fails", async () => { const repository = await mkdtemp(path.join(tmpdir(), "bad-repo-")); directories.push(repository); await execFileAsync("git", ["init"], { cwd: repository }); await expect(createGitArchive("missing", repository)).rejects.toThrow( - 'Failed to archive Git ref "missing"', + 'Failed to archive input "working-directory" (missing)', ); expect( await readFile(path.join(repository, ".git", "HEAD"), "utf8"), diff --git a/tests/caprover.test.ts b/tests/caprover.test.ts index dea75c6..9cd08fe 100644 --- a/tests/caprover.test.ts +++ b/tests/caprover.test.ts @@ -86,6 +86,7 @@ describe("CapRoverClient", () => { await new CapRoverClient(server.url, "token").deployImage( "my-api", "ghcr.io/acme/api:sha", + "a".repeat(40), ); const request = await server.request; const payload = JSON.parse(request.body.toString()); @@ -93,7 +94,7 @@ describe("CapRoverClient", () => { schemaVersion: 2, imageName: "ghcr.io/acme/api:sha", }); - expect(payload.gitHash).toBe(""); + expect(payload.gitHash).toBe("a".repeat(40)); }); it("propagates a useful CapRover API error", async () => { @@ -102,7 +103,11 @@ describe("CapRoverClient", () => { description: "App token is invalid", }); await expect( - new CapRoverClient(server.url, "bad-token").deployImage("app", "image"), + new CapRoverClient(server.url, "bad-token").deployImage( + "app", + "image", + "", + ), ).rejects.toThrow( "CapRover rejected the deployment (status 1106): App token is invalid", ); diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index fa576cd..3abc39c 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -19,8 +19,9 @@ const base: Inputs = { server: "https://captain.example.com", app: "my-api", token: "token", - branch: "", image: "", + tarFile: "", + workingDirectory: ".", }; const directories: string[] = []; @@ -42,21 +43,28 @@ function client() { }; } -describe("deploy v1 behavior", () => { - it("gives image precedence over branch", async () => { - await deploy({ ...base, image: "image:sha", branch: "main" }); - expect(client().deployImage).toHaveBeenCalledWith("my-api", "image:sha"); +describe("deploy v2 behavior", () => { + it("deploys an image with commit metadata", async () => { + process.env.GITHUB_SHA = "a".repeat(40); + await deploy({ ...base, image: "image:sha" }); + expect(client().deployImage).toHaveBeenCalledWith( + "my-api", + "image:sha", + "a".repeat(40), + ); expect(createGitArchive).not.toHaveBeenCalled(); + delete process.env.GITHUB_SHA; }); - it("archives and deploys the selected branch, then cleans up", async () => { + it("archives and deploys checked-out HEAD, then cleans up", async () => { const cleanup = vi.fn(); vi.mocked(createGitArchive).mockResolvedValue({ path: "/tmp/deploy.tar", gitHash: "a".repeat(40), cleanup, }); - await deploy({ ...base, branch: "main" }); + await deploy(base); + expect(createGitArchive).toHaveBeenCalledWith("."); expect(client().uploadArchive).toHaveBeenCalledWith( "my-api", "/tmp/deploy.tar", @@ -78,33 +86,44 @@ describe("deploy v1 behavior", () => { deployImage: vi.fn(), } as never; }); - await expect(deploy({ ...base, branch: "main" })).rejects.toThrow( - "upload failed", - ); + await expect(deploy(base)).rejects.toThrow("upload failed"); expect(cleanup).toHaveBeenCalledOnce(); }); - it("uploads the implicit workspace deploy.tar", async () => { + it("uploads an explicit tar relative to the workspace", async () => { const workspace = await mkdtemp( path.join(tmpdir(), "workspace with spaces-"), ); directories.push(workspace); - const tarPath = path.join(workspace, "deploy.tar"); + const tarPath = path.join(workspace, "dist", "deploy file.tar"); + await import("node:fs/promises").then(({ mkdir }) => + mkdir(path.dirname(tarPath), { recursive: true }), + ); await writeFile(tarPath, "fixture"); process.env.GITHUB_WORKSPACE = workspace; - await deploy(base); + await deploy({ ...base, tarFile: "dist/deploy file.tar" }); expect(client().uploadArchive).toHaveBeenCalledWith("my-api", tarPath, ""); delete process.env.GITHUB_WORKSPACE; }); - it("fails clearly when implicit deploy.tar is missing", async () => { + it("fails clearly when the explicit tar is missing", async () => { const workspace = await mkdtemp(path.join(tmpdir(), "empty-workspace-")); directories.push(workspace); process.env.GITHUB_WORKSPACE = workspace; - await expect(deploy(base)).rejects.toThrow( - `Deployment archive was not found: ${path.join(workspace, "deploy.tar")}`, + await expect(deploy({ ...base, tarFile: "missing.tar" })).rejects.toThrow( + `Input "tar-file" does not point to a file inside the GitHub workspace: ${path.join(workspace, "missing.tar")}`, ); - await expect(access(path.join(workspace, "deploy.tar"))).rejects.toThrow(); + await expect(access(path.join(workspace, "missing.tar"))).rejects.toThrow(); delete process.env.GITHUB_WORKSPACE; }); + + it("passes working-directory to source packaging", async () => { + vi.mocked(createGitArchive).mockResolvedValue({ + path: "/tmp/deploy.tar", + gitHash: "b".repeat(40), + cleanup: vi.fn(), + }); + await deploy({ ...base, workingDirectory: "apps/api" }); + expect(createGitArchive).toHaveBeenCalledWith("apps/api"); + }); }); diff --git a/tests/inputs.test.ts b/tests/inputs.test.ts index 3a14372..f36fc9e 100644 --- a/tests/inputs.test.ts +++ b/tests/inputs.test.ts @@ -17,8 +17,9 @@ describe("getInputs", () => { server: " https://captain.example.com/ ", app: " my-api ", token: " secret-token ", - branch: " main ", image: "", + "tar-file": "", + "working-directory": " apps/api ", }; return values[name] || ""; }); @@ -29,8 +30,9 @@ describe("getInputs", () => { server: "https://captain.example.com/", app: "my-api", token: "secret-token", - branch: "main", image: "", + tarFile: "", + workingDirectory: "apps/api", }); expect(github.setSecret).toHaveBeenCalledWith("secret-token"); }); @@ -38,8 +40,51 @@ describe("getInputs", () => { it.each(["server", "app", "token"] as const)( "identifies a missing %s input", (missing) => { - getInput.mockImplementation((name) => (name === missing ? " " : "value")); + getInput.mockImplementation((name) => { + if (name === missing) return " "; + if (name === "server") return "https://captain.example.com"; + if (["app", "token"].includes(name)) return "value"; + return name === "working-directory" ? "." : ""; + }); expect(() => getInputs()).toThrow(`Input \"${missing}\" is required`); }, ); + + it('rejects "image" with "tar-file"', () => { + getInput.mockImplementation((name) => { + if (name === "image") return "image:sha"; + if (name === "tar-file") return "deploy.tar"; + if (name === "working-directory") return "."; + return name === "server" ? "https://captain.example.com" : "value"; + }); + expect(() => getInputs()).toThrow( + 'Inputs "image" and "tar-file" cannot be used together', + ); + }); + + it.each(["image", "tar-file"])( + 'rejects "working-directory" with %s mode', + (mode) => { + getInput.mockImplementation((name) => { + if (name === mode) return "value"; + if (name === "working-directory") return "apps/api"; + if (name === "server") return "https://captain.example.com"; + return ["app", "token"].includes(name) ? "value" : ""; + }); + expect(() => getInputs()).toThrow( + 'Input "working-directory" can only be used for source deployment', + ); + }, + ); + + it('identifies an invalid "server" URL', () => { + getInput.mockImplementation((name) => { + if (name === "server") return "captain.example.com"; + if (["app", "token"].includes(name)) return "value"; + return name === "working-directory" ? "." : ""; + }); + expect(() => getInputs()).toThrow( + 'Input "server" must be a valid HTTP or HTTPS URL', + ); + }); });