import type { CompiledJob, CompiledStep, Segment } from "./compile"; import { runtimeExpansion } from "./segments"; import { RUNTIME_FUNCTIONS } from "./script-runtime"; export const MARKER_PREFIX = "::gitfor::"; const SHELL_COMMANDS: Record = { bash: "bash --noprofile --norc -eo pipefail", sh: "sh -e", python: "python3", python3: "python3", node: "node", bun: "bun", }; const KILL_GRACE_SECONDS = 15; function doubleQuoted(segments: Segment[]): string { const body = segments .map((segment) => segment.kind === "text" ? segment.value.replace(/(["\\$`])/g, "\\$1") : runtimeExpansion(segment), ) .join(""); return `"${body}"`; } function singleQuoted(value: string): string { return `'${value.replace(/'/g, "'\\''")}'`; } function artifactPath(path: string): string { if (path === "~") return '"$HOME"'; if (path.startsWith("~/")) return `"$HOME"/${singleQuoted(path.slice(2))}`; return singleQuoted(path); } function exports(env: Array<[string, Segment[]]>): string[] { return env.map(([name, segments]) => `export ${name}=${doubleQuoted(segments)}`); } function heredoc(path: string, content: string): string[] { let delimiter = `GITFOR_EOF_${crypto.randomUUID().replace(/-/g, "")}`; while (content.includes(delimiter)) delimiter += "_"; return [`cat > ${path} <<'${delimiter}'`, content, delimiter]; } function cacheLines(step: CompiledStep): string[] { const cache = step.cache; if (!cache) return []; const paths = cache.paths.map(singleQuoted).join(" "); const keyVar = `__gitfor_cache_key_${step.index}`; if (cache.mode === "save") return [`__gitfor_cache_save ${doubleQuoted(cache.key)} ${cache.version} ${paths}`]; return [ `${keyVar}=${doubleQuoted(cache.key)}`, `if __gitfor_cache_restore ${singleQuoted(step.id ?? "")} "$${keyVar}" ${cache.version} ${cache.restoreKeys .map(doubleQuoted) .join(" ")}; then __gitfor_cache_hit_${step.index}=1; else __gitfor_cache_hit_${step.index}=0; fi`, `__gitfor_cache_ran_${step.index}=1`, ]; } function stepBody(step: CompiledStep): string[] { const lines: string[] = []; const directory = step.workingDirectory ? `cd ${singleQuoted(step.workingDirectory)} || exit 1` : ":"; if (step.kind === "noop") { lines.push(`echo ${singleQuoted(`${step.name}: provided by the runner`)}`, "__gitfor_code=0"); return lines; } if (step.kind === "cache") { lines.push(...cacheLines(step), "__gitfor_code=0"); return lines; } if (step.kind === "upload-artifact" && step.artifact) { const archive = `"$__gitfor_tmp/artifacts/${step.artifact.name}.tgz"`; const paths = step.artifact.path .split("\n") .map((line) => line.trim()) .filter(Boolean) .map(artifactPath) .join(" "); lines.push( `( ${directory}; shopt -s globstar; __gitfor_paths=(); for __gitfor_glob in ${paths}; do IFS= __gitfor_match=( $__gitfor_glob ); __gitfor_paths+=("\${__gitfor_match[@]}"); done; tar czf ${archive} -- "\${__gitfor_paths[@]}" )`, "__gitfor_code=$?", `if [ $__gitfor_code -eq 0 ]; then __gitfor_mark artifact ${singleQuoted(step.artifact.name)}; fi`, ); return lines; } const command = SHELL_COMMANDS[step.shell] ?? SHELL_COMMANDS.bash; const timeout = step.timeoutMinutes ? `timeout --kill-after=${KILL_GRACE_SECONDS} ${Math.ceil(step.timeoutMinutes * 60)} ` : ""; lines.push("(", ` ${directory}`, ...exports(step.env).map((line) => ` ${line}`)); lines.push(` exec ${timeout}${command} "$__gitfor_tmp/steps/${step.index}"`, ")", "__gitfor_code=$?"); return lines; } /** `actions/cache` saves after the job, like GitHub's post step, and only when the job succeeded without an exact hit. */ function postLines(job: CompiledJob): string[] { const lines: string[] = []; for (const step of [...job.steps].reverse()) { if (step.kind !== "cache" || step.cache?.mode !== "both") continue; const index = step.index; lines.push( `if [ $__gitfor_failed -eq 0 ] && [ "\${__gitfor_cache_ran_${index}:-0}" = 1 ] && [ "$__gitfor_cache_hit_${index}" != 1 ]; then`, ` echo ${singleQuoted(`Post ${step.name}`)}`, ` __gitfor_cache_save "$__gitfor_cache_key_${index}" ${step.cache.version} ${step.cache.paths.map(singleQuoted).join(" ")}`, "fi", ); } for (const [name, segments] of job.outputs) { lines.push(`__gitfor_mark output ${name} "$(printf '%s' ${doubleQuoted(segments)} | base64 | tr -d '\\n')"`); } return lines; } /** Generates the self-contained bash program that runs a job and reports progress through log markers. */ export const MARKER_NONCE_FILE = ".gitfor-marker-nonce"; /** * The marker nonce is read from a file the runner writes next to the script and deleted at once. It lives in an * unexported variable, so step processes (started with `exec`) never see it in their environment or the script. */ export function buildJobScript(job: CompiledJob): string { const lines = [ "#!/usr/bin/env bash", `__gitfor_nonce=$(cat "\${GITFOR_WORKSPACE:-/workspace}/${MARKER_NONCE_FILE}" 2>/dev/null); rm -f "\${GITFOR_WORKSPACE:-/workspace}/${MARKER_NONCE_FILE}"`, `__gitfor_mark() { printf '\\n${MARKER_PREFIX}%s %s\\n' "$__gitfor_nonce" "$*"; }`, RUNTIME_FUNCTIONS, "export CI=true GITFOR_ACTIONS=true GITHUB_ACTIONS=true", ...exports(job.env), "__gitfor_failed=0", '__gitfor_dir="${GITFOR_WORKSPACE:-/workspace}"', '__gitfor_tmp="$__gitfor_dir/.gitfor"', "readonly __gitfor_nonce __gitfor_dir __gitfor_tmp GITFOR_API_URL GITFOR_JOB_TOKEN GITFOR_CLONE_URL", 'rm -rf "$__gitfor_tmp" "$__gitfor_dir/src" && mkdir -p "$__gitfor_tmp/steps" "$__gitfor_tmp/artifacts"', 'export GITFOR_OUTPUT="$__gitfor_tmp/output" GITHUB_OUTPUT="$__gitfor_tmp/output" GITFOR_ENV="$__gitfor_tmp/env" GITHUB_ENV="$__gitfor_tmp/env" GITFOR_PATH="$__gitfor_tmp/path" GITHUB_PATH="$__gitfor_tmp/path"', "__gitfor_reset_files", ]; for (const step of job.steps) { if (step.kind === "run") lines.push(...heredoc(`"$__gitfor_tmp/steps/${step.index}"`, step.script)); } lines.push( '__gitfor_should_run() { if [ $__gitfor_failed -eq 0 ]; then [ "$1" = 1 ]; else [ "$2" = 1 ]; fi; }', "__gitfor_mark setup-start", 'echo "Checking out $GITFOR_REPOSITORY@$GITFOR_SHA"', 'git clone --quiet --no-checkout "$GITFOR_CLONE_URL" "$__gitfor_dir/src" && git -C "$__gitfor_dir/src" checkout --quiet --detach "$GITFOR_SHA"', "__gitfor_code=$?", "__gitfor_mark setup-end $__gitfor_code", "if [ $__gitfor_code -ne 0 ]; then __gitfor_mark job-end 1; exit 1; fi", 'cd "$__gitfor_dir/src" || exit 1', 'export GITHUB_WORKSPACE="$PWD" GITFOR_WORKSPACE_SRC="$PWD"', ); for (const step of job.steps) { const guard = `__gitfor_should_run ${step.runIfOk ? 1 : 0} ${step.runIfFailed ? 1 : 0}`; const id = singleQuoted(step.id ?? ""); lines.push( `if ${guard}${step.runtimeCondition ? ` && ${step.runtimeCondition}` : ""}; then`, ` __gitfor_mark step-start ${step.index}`, ...stepBody(step).map((line) => ` ${line}`), ` __gitfor_mark step-end ${step.index} $__gitfor_code`, ` __gitfor_after_step ${id} $__gitfor_code ${step.continueOnError ? 1 : 0}`, ` if [ $__gitfor_code -ne 0 ] && [ ${step.continueOnError ? 1 : 0} = 0 ]; then __gitfor_failed=1; fi`, "else", ` __gitfor_mark step-skip ${step.index}`, ...(step.id ? [` export GITFOR_STEPOUTCOME_${step.id}=skipped GITFOR_STEPCONCLUSION_${step.id}=skipped`] : []), "fi", ); } lines.push(...postLines(job), "__gitfor_mark job-end $__gitfor_failed", "exit $__gitfor_failed", ""); return lines.join("\n"); } export function jobTimeoutCommand(job: CompiledJob, scriptPath: string): string[] { return ["timeout", `--kill-after=${KILL_GRACE_SECONDS}`, String(job.timeoutMinutes * 60), "bash", scriptPath]; }