diff --git a/README.md b/README.md index 9ce566e..922db34 100644 --- a/README.md +++ b/README.md @@ -1,138 +1,103 @@ -# 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 -App tokens and deployment data are sent to the configured server. Use HTTPS unless the server is reached through a trusted private network. +- 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. +App tokens and deployment data are sent to the configured server. Use HTTPS unless the server is reached through a trusted private network. -### 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 a03bcc1..f26e925 100644 --- a/dist/index.js +++ b/dist/index.js @@ -8881,11 +8881,11 @@ var require_mime_types = __commonJS({ } return exts[0]; } - function lookup(path3) { - if (!path3 || typeof path3 !== "string") { + function lookup(path4) { + if (!path4 || typeof path4 !== "string") { return false; } - var extension2 = extname("x." + path3).toLowerCase().substr(1); + var extension2 = extname("x." + path4).toLowerCase().substr(1); if (!extension2) { return false; } @@ -9990,7 +9990,7 @@ var require_form_data = __commonJS({ "use strict"; var CombinedStream = require_combined_stream(); var util = require("util"); - var path3 = require("path"); + var path4 = require("path"); var http2 = require("http"); var https2 = require("https"); var parseUrl = require("url").parse; @@ -10064,12 +10064,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); }); } @@ -10121,11 +10121,11 @@ var require_form_data = __commonJS({ FormData2.prototype._getContentDisposition = function(value, options) { var filename; if (typeof options.filepath === "string") { - filename = path3.normalize(options.filepath).replace(/\\/g, "/"); + filename = path4.normalize(options.filepath).replace(/\\/g, "/"); } else if (options.filename || value && (value.name || value.path)) { - filename = path3.basename(options.filename || value && (value.name || value.path)); + filename = path4.basename(options.filename || value && (value.name || value.path)); } else if (value && value.readable && hasOwn(value, "httpVersion")) { - filename = path3.basename(value.client._httpMessage.path || ""); + filename = path4.basename(value.client._httpMessage.path || ""); } if (filename) { return 'filename="' + escapeHeaderParam(filename) + '"'; @@ -10324,22 +10324,38 @@ 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 getHeadCommit(workspace = process.env.GITHUB_WORKSPACE || process.cwd()) { + const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD"], { + cwd: workspace + }); + const gitHash = stdout.trim(); + if (!/^[a-f0-9]{40}$/.test(gitHash)) { + throw new Error(`git rev-parse returned an invalid commit: ${gitHash}`); + } + return gitHash; +} +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 gitHash = stdout.trim(); - if (!/^[a-f0-9]{40}$/.test(gitHash)) { - throw new Error(`git rev-parse returned an invalid commit: ${gitHash}`); - } + const gitHash = await getHeadCommit(workspacePath); return { path: archivePath, gitHash, @@ -10348,7 +10364,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}` + ); } } @@ -10372,13 +10390,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", @@ -10490,15 +10508,34 @@ 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); + let gitHash = ""; + try { + gitHash = await getHeadCommit(); + } catch { + const candidateGitHash = (process.env.GITHUB_SHA || "").trim(); + if (/^[a-f0-9]{40}$/i.test(candidateGitHash)) gitHash = 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(); @@ -10506,17 +10543,30 @@ 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, ""); } // src/inputs.ts +var import_node_path3 = __toESM(require("node:path")); function readRequiredInput(name) { const value = getInput(name).trim(); if (!value) { @@ -10527,12 +10577,35 @@ 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 normalizedWorkingDirectory = import_node_path3.default.normalize( + getInput("working-directory").trim() || "." + ); + const workingDirectory = normalizedWorkingDirectory === `.${import_node_path3.default.sep}` ? "." : normalizedWorkingDirectory; + 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 }; } @@ -10540,7 +10613,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 f0c7fa3..13b65c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "deploy-from-github", - "version": "1.2.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "deploy-from-github", - "version": "1.2.0", + "version": "2.0.0", "license": "MIT", "dependencies": { "form-data": "^4.0.4" diff --git a/package.json b/package.json index 633cbd2..3a872ac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "deploy-from-github", - "version": "1.2.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..96bc9cf 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"; @@ -12,26 +12,53 @@ export interface TemporaryArchive { cleanup: () => Promise; } +export async function getHeadCommit( + workspace = process.env.GITHUB_WORKSPACE || process.cwd(), +): Promise { + const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD"], { + cwd: workspace, + }); + const gitHash = stdout.trim(); + if (!/^[a-f0-9]{40}$/.test(gitHash)) { + throw new Error(`git rev-parse returned an invalid commit: ${gitHash}`); + } + return gitHash; +} + 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 gitHash = stdout.trim(); - if (!/^[a-f0-9]{40}$/.test(gitHash)) { - throw new Error(`git rev-parse returned an invalid commit: ${gitHash}`); - } + const gitHash = await getHeadCommit(workspacePath); return { path: archivePath, @@ -41,6 +68,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 bc2a7ec..db64495 100644 --- a/src/caprover.ts +++ b/src/caprover.ts @@ -33,13 +33,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..41aab6c 100644 --- a/src/deploy.ts +++ b/src/deploy.ts @@ -1,6 +1,6 @@ -import { access } from "node:fs/promises"; +import { realpath, stat } from "node:fs/promises"; import path from "node:path"; -import { createGitArchive } from "./archive.js"; +import { createGitArchive, getHeadCommit } from "./archive.js"; import { CapRoverClient } from "./caprover.js"; import { info } from "./github.js"; import { Inputs } from "./inputs.js"; @@ -8,16 +8,36 @@ 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); + let gitHash = ""; + try { + gitHash = await getHeadCommit(); + } catch { + const candidateGitHash = (process.env.GITHUB_SHA || "").trim(); + if (/^[a-f0-9]{40}$/i.test(candidateGitHash)) gitHash = 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 +46,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 c265b45..75a8bc7 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..40b0765 100644 --- a/src/inputs.ts +++ b/src/inputs.ts @@ -1,11 +1,13 @@ import { getInput, setSecret } from "./github.js"; +import path from "node:path"; 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 +22,40 @@ 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 normalizedWorkingDirectory = path.normalize( + getInput("working-directory").trim() || ".", + ); + const workingDirectory = + normalizedWorkingDirectory === `.${path.sep}` + ? "." + : normalizedWorkingDirectory; + + 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 5201475..aeda072 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", ); @@ -131,7 +136,7 @@ describe("CapRoverClient", () => { new CapRoverClient( `http://127.0.0.1:${address.port}`, "token", - ).deployImage("app", "image"), + ).deployImage("app", "image", ""), ).rejects.toThrow(/CapRover response (was aborted|failed)/); }); }); diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index 854beab..4649f7f 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -2,13 +2,16 @@ import { access, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createGitArchive } from "../src/archive.js"; +import { createGitArchive, getHeadCommit } from "../src/archive.js"; import { CapRoverClient } from "../src/caprover.js"; import { deploy } from "../src/deploy.js"; import { Inputs } from "../src/inputs.js"; vi.mock("../src/github.js", () => ({ info: vi.fn() })); -vi.mock("../src/archive.js", () => ({ createGitArchive: vi.fn() })); +vi.mock("../src/archive.js", () => ({ + createGitArchive: vi.fn(), + getHeadCommit: vi.fn(), +})); vi.mock("../src/caprover.js", () => ({ CapRoverClient: vi.fn(function () { return { uploadArchive: vi.fn(), deployImage: vi.fn() }; @@ -19,8 +22,9 @@ const base: Inputs = { server: "https://captain.example.com", app: "my-api", token: "token", - branch: "", image: "", + tarFile: "", + workingDirectory: ".", }; const directories: string[] = []; @@ -34,7 +38,10 @@ afterEach(async () => { ); }); -beforeEach(() => vi.clearAllMocks()); +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getHeadCommit).mockRejectedValue(new Error("No checkout")); +}); function client() { return vi.mocked(CapRoverClient).mock.results[0].value as { @@ -43,21 +50,38 @@ 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 () => { + vi.stubEnv("GITHUB_SHA", "a".repeat(40)); + vi.mocked(getHeadCommit).mockResolvedValue("b".repeat(40)); + await deploy({ ...base, image: "image:sha" }); + expect(client().deployImage).toHaveBeenCalledWith( + "my-api", + "image:sha", + "b".repeat(40), + ); expect(createGitArchive).not.toHaveBeenCalled(); }); - it("archives and deploys the selected branch, then cleans up", async () => { + it("uses GITHUB_SHA for image metadata when source is not checked out", async () => { + vi.stubEnv("GITHUB_SHA", "a".repeat(40)); + await deploy({ ...base, image: "image:sha" }); + expect(client().deployImage).toHaveBeenCalledWith( + "my-api", + "image:sha", + "a".repeat(40), + ); + }); + + 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", @@ -79,31 +103,42 @@ 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"); vi.stubEnv("GITHUB_WORKSPACE", workspace); - await deploy(base); + await deploy({ ...base, tarFile: "dist/deploy file.tar" }); expect(client().uploadArchive).toHaveBeenCalledWith("my-api", tarPath, ""); }); - 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); vi.stubEnv("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(); + }); + + 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..0141d34 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,64 @@ 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.each(["image", "tar-file"])( + 'accepts "./" as the workspace root with %s mode', + (mode) => { + getInput.mockImplementation((name) => { + if (name === mode) return "value"; + if (name === "working-directory") return "./"; + if (name === "server") return "https://captain.example.com"; + return ["app", "token"].includes(name) ? "value" : ""; + }); + expect(getInputs().workingDirectory).toBe("."); + }, + ); + + 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', + ); + }); });