Skip to content

Commit 24ab01e

Browse files
committed
feat: specify current_working_directory where to run your scripts from
The biggest reason for wanting this feature is if you want to separate your deployment scripts from your main application code. This way, you can use a package manager for dependencies and such. It's kind of like fully encapsulating your deployment scripts.
1 parent 8c5744c commit 24ab01e

9 files changed

Lines changed: 273 additions & 1 deletion

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,8 @@ You are responsible for writing the scripts that perform each deployment step. T
187187

188188
Below are instructions for each required step. The `steps/` directory in this repository contains example scripts for deploying this codebase, but you should write your own scripts for your project. Use the examples only for reference.
189189

190+
> Tip: Use the `current_working_directory` option to run all commands from a subdirectory (e.g., `current_working_directory: "./deployment"`). This keeps deployment scripts and dependencies separate from your application code.
191+
190192
### 1. Get latest release
191193

192194
Write a script that determines the current/latest release version of your project.

action.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ inputs:
4242
pull_request_comment_template:
4343
description: 'Template string for the pull request comment. Used if pull_request_comment_template_file is not provided.'
4444
default: ''
45+
current_working_directory:
46+
description: 'The working directory to run all user scripts from. If not provided, defaults to the git repository root directory.'
47+
default: ''
4548

4649
outputs:
4750
new_release_version:
@@ -105,6 +108,7 @@ runs:
105108
--simulated_merge_type "${{ inputs.simulated_merge_type }}" \
106109
--branch_filters "${{ inputs.branch_filters }}" \
107110
--commit_limit "${{ inputs.commit_limit }}" \
111+
--current_working_directory "${{ inputs.current_working_directory }}" \
108112
--pull_request_comment_template_file "${{ inputs.pull_request_comment_template_file || env.DECAF_TEMPLATE_FILE }}"
109113
shell: bash
110114

cli.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const processCommandLineArgs = (cmdArgs: string[]) => {
1515
"commit_limit",
1616
"pull_request_comment_template_file",
1717
"pull_request_comment_template",
18+
"current_working_directory",
1819
],
1920
// collect: allows repeatable flags (e.g., --deploy staging --deploy prod) -> returns array
2021
collect: [
@@ -37,6 +38,7 @@ export const processCommandLineArgs = (cmdArgs: string[]) => {
3738
commit_limit: "",
3839
pull_request_comment_template_file: "",
3940
pull_request_comment_template: "",
41+
current_working_directory: "",
4042
},
4143
})
4244

@@ -55,4 +57,5 @@ export const processCommandLineArgs = (cmdArgs: string[]) => {
5557
Deno.env.set("INPUT_COMMIT_LIMIT", args.commit_limit)
5658
Deno.env.set("INPUT_PULL_REQUEST_COMMENT_TEMPLATE_FILE", args.pull_request_comment_template_file)
5759
Deno.env.set("INPUT_PULL_REQUEST_COMMENT_TEMPLATE", args.pull_request_comment_template)
60+
Deno.env.set("INPUT_CURRENT_WORKING_DIRECTORY", args.current_working_directory)
5861
}

deno.jsonc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"@david/service-store": "jsr:@david/service-store@^0.3.0",
44
"@david/dax": "jsr:@david/dax@^0.44.0",
55
"@std/cli": "jsr:@std/cli@^1.0.20",
6+
"@std/path": "jsr:@std/path@^1.1.4",
67
"@std/semver": "jsr:@std/semver@^1.0.4",
78
"@std/testing": "jsr:@std/testing@^1.0.10",
89
"@std/assert": "jsr:@std/assert@^1.0.12",

deno.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ export async function main() {
8585
try {
8686
const runResult = await run({
8787
convenienceStep: new ConvenienceStepImpl(environment, git, logger),
88-
stepRunner: new StepRunnerImpl(environment, exec, logger, git.getDirectory()),
88+
stepRunner: new StepRunnerImpl(environment, exec, logger, environment.getUserScriptCurrentWorkingDirectory(git.getDirectory())),
8989
prepareEnvironmentForTestMode: new PrepareTestModeEnvStepImpl(githubApi, environment, new SimulateMergeImpl(git), git),
9090
getCommitsSinceLatestReleaseStep: new GetCommitsSinceLatestReleaseStepImpl(git),
9191
log: logger,

lib/e2e/e2e-stubs.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,9 @@ export class EnvironmentStub implements Environment {
531531
getPullRequestCommentTemplate(): Promise<string | undefined> {
532532
return Promise.resolve(undefined)
533533
}
534+
getUserScriptCurrentWorkingDirectory(gitDirectory: string): string {
535+
return gitDirectory
536+
}
534537
getUserConfigurationOptions(): { failOnDeployVerification: boolean; makePullRequestComment: boolean } {
535538
return {
536539
failOnDeployVerification: this.args.failOnDeployVerification ?? false,

lib/environment.test.ts

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,3 +808,209 @@ Deno.test("getPullRequestCommentTemplate - should handle empty file", async () =
808808
Deno.env.delete("INPUT_PULL_REQUEST_COMMENT_TEMPLATE_FILE")
809809
}
810810
})
811+
812+
// Tests for getUserScriptCurrentWorkingDirectory()
813+
814+
Deno.test("getUserScriptCurrentWorkingDirectory - should return git directory when input is not set", () => {
815+
Deno.env.delete("INPUT_CURRENT_WORKING_DIRECTORY")
816+
817+
const gitDirectory = Deno.cwd()
818+
819+
const result = environment.getUserScriptCurrentWorkingDirectory(gitDirectory)
820+
821+
assertEquals(result, gitDirectory)
822+
})
823+
824+
Deno.test("getUserScriptCurrentWorkingDirectory - should return git directory when input is empty string", () => {
825+
Deno.env.set("INPUT_CURRENT_WORKING_DIRECTORY", "")
826+
const gitDirectory = Deno.cwd()
827+
828+
const result = environment.getUserScriptCurrentWorkingDirectory(gitDirectory)
829+
830+
assertEquals(result, gitDirectory)
831+
})
832+
833+
Deno.test("getUserScriptCurrentWorkingDirectory - should return git directory when input is whitespace only", () => {
834+
Deno.env.set("INPUT_CURRENT_WORKING_DIRECTORY", " ")
835+
const gitDirectory = Deno.cwd()
836+
837+
const result = environment.getUserScriptCurrentWorkingDirectory(gitDirectory)
838+
839+
assertEquals(result, gitDirectory)
840+
})
841+
842+
Deno.test("getUserScriptCurrentWorkingDirectory - should throw error when absolute path is provided", async () => {
843+
const tempDir = await Deno.makeTempDir({ prefix: "decaf-cwd-test-" })
844+
845+
try {
846+
Deno.env.set("INPUT_CURRENT_WORKING_DIRECTORY", tempDir)
847+
const gitDirectory = Deno.cwd()
848+
849+
let errorThrown = false
850+
let errorMessage = ""
851+
try {
852+
environment.getUserScriptCurrentWorkingDirectory(gitDirectory)
853+
} catch (error) {
854+
errorThrown = true
855+
errorMessage = (error as Error).message
856+
}
857+
858+
assertEquals(errorThrown, true)
859+
assertEquals(errorMessage.includes("must be a relative path"), true)
860+
assertEquals(errorMessage.includes("not an absolute path"), true)
861+
} finally {
862+
await Deno.remove(tempDir)
863+
Deno.env.delete("INPUT_CURRENT_WORKING_DIRECTORY")
864+
}
865+
})
866+
867+
Deno.test("getUserScriptCurrentWorkingDirectory - should resolve relative path when valid relative directory is provided", async () => {
868+
const tempDir = await Deno.makeTempDir({ prefix: "decaf-cwd-test-" })
869+
870+
try {
871+
// Create a subdirectory
872+
const subDir = `${tempDir}/subdir`
873+
await Deno.mkdir(subDir)
874+
875+
// Use tempDir as the git directory
876+
const gitDirectory = tempDir
877+
878+
Deno.env.set("INPUT_CURRENT_WORKING_DIRECTORY", "subdir")
879+
880+
const result = environment.getUserScriptCurrentWorkingDirectory(gitDirectory)
881+
882+
// Should resolve to absolute path (with symlinks resolved)
883+
assertEquals(result, Deno.realPathSync(subDir))
884+
} finally {
885+
await Deno.remove(tempDir, { recursive: true })
886+
Deno.env.delete("INPUT_CURRENT_WORKING_DIRECTORY")
887+
}
888+
})
889+
890+
Deno.test("getUserScriptCurrentWorkingDirectory - should resolve relative path with .. navigation", async () => {
891+
const tempDir = await Deno.makeTempDir({ prefix: "decaf-cwd-test-" })
892+
893+
try {
894+
// Create nested directories: tempDir/a/b
895+
const dirA = `${tempDir}/a`
896+
const dirB = `${dirA}/b`
897+
await Deno.mkdir(dirA)
898+
await Deno.mkdir(dirB)
899+
900+
// Use dirB as the git directory
901+
const gitDirectory = dirB
902+
903+
// Set relative path that goes up one level
904+
Deno.env.set("INPUT_CURRENT_WORKING_DIRECTORY", "..")
905+
906+
const result = environment.getUserScriptCurrentWorkingDirectory(gitDirectory)
907+
908+
// Should resolve to dirA (with symlinks resolved)
909+
assertEquals(result, Deno.realPathSync(dirA))
910+
} finally {
911+
await Deno.remove(tempDir, { recursive: true })
912+
Deno.env.delete("INPUT_CURRENT_WORKING_DIRECTORY")
913+
}
914+
})
915+
916+
Deno.test("getUserScriptCurrentWorkingDirectory - should throw error when directory does not exist", () => {
917+
const nonExistentDir = "this-directory-does-not-exist-" + Date.now()
918+
const gitDirectory = Deno.cwd()
919+
920+
Deno.env.set("INPUT_CURRENT_WORKING_DIRECTORY", nonExistentDir)
921+
922+
let errorThrown = false
923+
let errorMessage = ""
924+
try {
925+
environment.getUserScriptCurrentWorkingDirectory(gitDirectory)
926+
} catch (error) {
927+
errorThrown = true
928+
errorMessage = (error as Error).message
929+
}
930+
931+
assertEquals(errorThrown, true)
932+
assertEquals(errorMessage.includes("does not exist"), true)
933+
assertEquals(errorMessage.includes(nonExistentDir), true)
934+
935+
Deno.env.delete("INPUT_CURRENT_WORKING_DIRECTORY")
936+
})
937+
938+
Deno.test("getUserScriptCurrentWorkingDirectory - should throw error when path is a file not a directory", async () => {
939+
const tempDir = await Deno.makeTempDir({ prefix: "decaf-cwd-test-" })
940+
const tempFile = `${tempDir}/test-file.txt`
941+
await Deno.writeTextFile(tempFile, "test content")
942+
943+
try {
944+
// Use relative path to the file
945+
Deno.env.set("INPUT_CURRENT_WORKING_DIRECTORY", "test-file.txt")
946+
const gitDirectory = tempDir
947+
948+
let errorThrown = false
949+
let errorMessage = ""
950+
try {
951+
environment.getUserScriptCurrentWorkingDirectory(gitDirectory)
952+
} catch (error) {
953+
errorThrown = true
954+
errorMessage = (error as Error).message
955+
}
956+
957+
assertEquals(errorThrown, true)
958+
assertEquals(errorMessage.includes("is not a directory"), true)
959+
assertEquals(errorMessage.includes("test-file.txt"), true)
960+
} finally {
961+
await Deno.remove(tempDir, { recursive: true })
962+
Deno.env.delete("INPUT_CURRENT_WORKING_DIRECTORY")
963+
}
964+
})
965+
966+
Deno.test("getUserScriptCurrentWorkingDirectory - should handle path with trailing slash", async () => {
967+
const tempDir = await Deno.makeTempDir({ prefix: "decaf-cwd-test-" })
968+
const subDir = `${tempDir}/subdir`
969+
await Deno.mkdir(subDir)
970+
971+
try {
972+
// Use relative path with trailing slash
973+
Deno.env.set("INPUT_CURRENT_WORKING_DIRECTORY", "subdir/")
974+
const gitDirectory = tempDir
975+
976+
const result = environment.getUserScriptCurrentWorkingDirectory(gitDirectory)
977+
978+
// Should still work and return the directory (normalized and with symlinks resolved)
979+
assertEquals(result, Deno.realPathSync(subDir))
980+
} finally {
981+
await Deno.remove(tempDir, { recursive: true })
982+
Deno.env.delete("INPUT_CURRENT_WORKING_DIRECTORY")
983+
}
984+
})
985+
986+
Deno.test("getUserScriptCurrentWorkingDirectory - should handle current directory specified as .", async () => {
987+
const gitDirectory = Deno.cwd()
988+
989+
Deno.env.set("INPUT_CURRENT_WORKING_DIRECTORY", ".")
990+
991+
const result = environment.getUserScriptCurrentWorkingDirectory(gitDirectory)
992+
993+
assertEquals(result, gitDirectory)
994+
995+
Deno.env.delete("INPUT_CURRENT_WORKING_DIRECTORY")
996+
})
997+
998+
Deno.test("getUserScriptCurrentWorkingDirectory - should trim whitespace from input", async () => {
999+
const tempDir = await Deno.makeTempDir({ prefix: "decaf-cwd-test-" })
1000+
const subDir = `${tempDir}/subdir`
1001+
await Deno.mkdir(subDir)
1002+
1003+
try {
1004+
// Use relative path with whitespace
1005+
Deno.env.set("INPUT_CURRENT_WORKING_DIRECTORY", ` subdir `)
1006+
const gitDirectory = tempDir
1007+
1008+
const result = environment.getUserScriptCurrentWorkingDirectory(gitDirectory)
1009+
1010+
// Should trim whitespace and resolve symlinks
1011+
assertEquals(result, Deno.realPathSync(subDir))
1012+
} finally {
1013+
await Deno.remove(tempDir, { recursive: true })
1014+
Deno.env.delete("INPUT_CURRENT_WORKING_DIRECTORY")
1015+
}
1016+
})

lib/environment.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as log from "./log.ts"
33
import { AnyStepName } from "./steps/types/any-step.ts"
44
import envCi from "env-ci"
55
import { GitHubApi } from "./github-api.ts"
6+
import { isAbsolute, resolve } from "@std/path"
67

78
export interface Environment {
89
getRepository(): { owner: string; repo: string }
@@ -16,6 +17,7 @@ export interface Environment {
1617
getCommitLimit(): number
1718
setOutput({ key, value }: { key: string; value: string }): Promise<void>
1819
getPullRequestCommentTemplate(): Promise<string | undefined>
20+
getUserScriptCurrentWorkingDirectory(gitDirectory: string): string
1921
// A catch-all method to get inputs that dont match the other methods.
2022
getUserConfigurationOptions(): { failOnDeployVerification: boolean; makePullRequestComment: boolean }
2123
}
@@ -276,6 +278,56 @@ export class EnvironmentImpl implements Environment {
276278
return undefined
277279
}
278280

281+
getUserScriptCurrentWorkingDirectory(gitDirectory: string): string {
282+
let cwd: string
283+
try {
284+
cwd = this.getInput("current_working_directory")
285+
} catch (_error) {
286+
// Input not set, return the git directory
287+
return gitDirectory
288+
}
289+
290+
// If empty or whitespace, use git directory
291+
cwd = cwd.trim()
292+
if (cwd === "") {
293+
return gitDirectory
294+
}
295+
296+
// Only allow relative paths, not absolute paths
297+
// This is important because decaf creates temporary/isolated directories in test mode,
298+
// so absolute paths would point to the wrong location
299+
if (isAbsolute(cwd)) {
300+
throw new Error(
301+
`The current_working_directory must be a relative path, not an absolute path. Got: "${cwd}". ` +
302+
`Use a relative path like "." or "scripts/" instead.`,
303+
)
304+
}
305+
306+
// Resolve the relative path against the git directory
307+
const resolvedPath = resolve(gitDirectory, cwd)
308+
309+
// Validate that the directory exists
310+
try {
311+
const stat = Deno.statSync(resolvedPath)
312+
if (!stat.isDirectory) {
313+
throw new Error(
314+
`The configured current_working_directory "${cwd}" (resolved to "${resolvedPath}") is not a directory`,
315+
)
316+
}
317+
log.debug(`Using configured working directory: ${resolvedPath}`)
318+
// Use realpath to resolve symlinks for consistent paths (e.g., /private/var -> /var on macOS)
319+
return Deno.realPathSync(resolvedPath)
320+
} catch (error) {
321+
if (error instanceof Deno.errors.NotFound) {
322+
throw new Error(
323+
`The configured current_working_directory "${cwd}" (resolved to "${resolvedPath}") does not exist`,
324+
)
325+
}
326+
// Re-throw other errors (like permission errors or the "not a directory" error we threw)
327+
throw error
328+
}
329+
}
330+
279331
getGitConfigInput(): { name: string; email: string } | undefined {
280332
const gitConfigInput = this.getInput("git_config")
281333
if (!gitConfigInput || gitConfigInput.trim() === "") {

0 commit comments

Comments
 (0)