import type { CompiledJob, CompiledStep, Segment } from "./compile"; import type { CompiledAction, CompiledDocker } from "./actions/compile-action"; import { inputVariable } from "./actions/manifest"; 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 actionDirectory(action: CompiledAction): string { const base = action.local ? '"$__gitfor_dir/src"' : `"$__gitfor_tmp/_actions/"${singleQuoted((action.archive?.key ?? "").replace(/[^A-Za-z0-9._-]/g, "_"))}`; return action.path ? `${base}/${singleQuoted(action.path)}` : base; } function actionArguments(step: CompiledStep, action: CompiledAction): string { const text = (value: string): Segment => ({ kind: "text", value }); const fixed: Array<[string, string]> = [ ["GITHUB_ACTION", step.id ?? `__${(action.repository ?? "run").replace(/[^A-Za-z0-9]/g, "_")}`], ["GITHUB_ACTION_REPOSITORY", action.repository ?? ""], ["GITHUB_ACTION_REF", action.ref ?? ""], ]; return [ ...fixed.map(([name, value]) => doubleQuoted([text(`${name}=${value}`)])), ...action.inputs.map(([name, segments]) => doubleQuoted([text(`${inputVariable(name)}=`), ...segments])), ].join(" "); } /** Image arguments for `__gitfor_docker_run`: kind, digest or build tag, build context, Dockerfile, label. */ function dockerImage(step: CompiledStep, action: CompiledAction, docker: CompiledDocker): string { if (docker.image.kind === "registry") { return ["pull", docker.image.digest, "''", "''", singleQuoted(docker.image.ref)].join(" "); } const tag = `${(action.archive?.sha256 ?? "local").slice(0, 32)}-${step.index}`; return ["build", tag, actionDirectory(action), singleQuoted(docker.image.path), singleQuoted(action.label)].join(" "); } /** * One phase of a Docker action: the image runs with the workspace, runner temp and file-command files mounted * where GitHub mounts them. Values travel as `NAME=value` arguments to env(1), like JavaScript actions, so the * container sees exactly the step's env, the action's env and its inputs. */ function dockerPhase(step: CompiledStep, action: CompiledAction, docker: CompiledDocker, entry: string, main: boolean) { const timeout = step.timeoutMinutes ? Math.ceil(step.timeoutMinutes * 60) : 0; const text = (value: string): Segment => ({ kind: "text", value }); const args = main ? docker.args.map(doubleQuoted) : []; const pairs = [...step.env, ...docker.env].map(([name, segments]) => doubleQuoted([text(`${name}=`), ...segments])); return [ "(", ...exports(step.env).map((line) => ` ${line}`), ` __gitfor_docker_run ${step.index} ${dockerImage(step, action, docker)} ${timeout} ${singleQuoted(entry)} ${args.length} ${args.join(" ")} ${actionArguments(step, action)} ${pairs.join(" ")}`, ")", ]; } /** One phase of an action step, in a subshell carrying the step's `env:`. */ function actionPhase(step: CompiledStep, action: CompiledAction, entry: string, main = true): string[] { if (action.docker) return dockerPhase(step, action, action.docker, entry, main); const timeout = step.timeoutMinutes ? Math.ceil(step.timeoutMinutes * 60) : 0; return [ "(", ...exports(step.env).map((line) => ` ${line}`), ` __gitfor_action_run ${step.index} ${actionDirectory(action)} ${singleQuoted(entry)} ${timeout} ${actionArguments(step, action)}`, ")", ]; } /** Downloads (and verifies) each remote action once, during job setup. */ function actionFetchLines(job: CompiledJob): string[] { const seen = new Set(); const lines: string[] = []; for (const step of job.steps) { const action = step.action; if (!action?.archive || seen.has(action.archive.key)) continue; // A registry-image Docker action runs the image; its repository files are never needed on the runner. if (action.docker?.image.kind === "registry") continue; seen.add(action.archive.key); const label = `${action.label} (SHA:${action.sha ?? ""})`; const root = `"$__gitfor_tmp/_actions/"${singleQuoted(action.archive.key.replace(/[^A-Za-z0-9._-]/g, "_"))}`; lines.push( `if [ $__gitfor_code -eq 0 ]; then __gitfor_action_fetch ${singleQuoted(action.archive.key)} ${action.archive.sha256} ${root} ${singleQuoted(label)} || __gitfor_code=1; fi`, ); } return lines; } /** `pre:` entry points run after setup, in step order, like GitHub's "Pre" steps. */ function actionPreLines(job: CompiledJob): string[] { const lines: string[] = []; for (const step of job.steps) { const action = step.action; if (!action?.pre || !action.preRun) continue; lines.push( `echo ${singleQuoted(`Pre ${step.name}`)}`, ...actionPhase(step, action, action.pre, false), "if [ $? -ne 0 ]; then __gitfor_failed=1; fi", ); } return lines; } 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 === "action" && step.action) { lines.push( ...actionPhase(step, step.action, step.action.main), "__gitfor_code=$?", `__gitfor_action_ran_${step.index}=1`, ); 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()) { const action = step.action; if (action?.post) { lines.push( `if [ "\${__gitfor_action_ran_${step.index}:-0}" = 1 ] && __gitfor_should_run ${action.postIfOk ? 1 : 0} ${action.postIfFailed ? 1 : 0}; then`, ` echo ${singleQuoted(`Post ${step.name}`)}`, ...actionPhase(step, action, action.post, false).map((line) => ` ${line}`), " if [ $? -ne 0 ]; then __gitfor_failed=1; fi", "fi", ); continue; } 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; } /** * Fetches only the job's commit (depth `GITFOR_FETCH_DEPTH`, default 1, like actions/checkout) instead of * cloning all history: clones are billed Artifacts operations and dominate setup time on large repos. A * branch push also gets its `origin/` ref. `GITFOR_FETCH_DEPTH: 0` in the workflow env restores a * full fetch; a failed fetch by SHA falls back to one too. */ const CHECKOUT_LINES = [ '__gitfor_src="$__gitfor_dir/src"', '__gitfor_refspec="$GITFOR_SHA"', 'case "$GITFOR_REF" in refs/heads/*) __gitfor_refspec="+$GITFOR_SHA:refs/remotes/origin/${GITFOR_REF#refs/heads/}" ;; esac', 'git init --quiet "$__gitfor_src" && git -C "$__gitfor_src" remote add origin "$GITFOR_CLONE_URL" && {', ' if [ "${GITFOR_FETCH_DEPTH:-1}" != 0 ]; then', ' git -C "$__gitfor_src" fetch --quiet --no-tags --depth "${GITFOR_FETCH_DEPTH:-1}" origin "$__gitfor_refspec" || git -C "$__gitfor_src" fetch --quiet origin', " else", ' git -C "$__gitfor_src" fetch --quiet origin', " fi", '} && git -C "$__gitfor_src" checkout --quiet --detach "$GITFOR_SHA"', ]; /** GitHub-compatible runner variables; RUNNER_TOOL_CACHE persists across jobs on a self-hosted runner that sets it. */ const RUNNER_ENV_LINES = [ 'export GITHUB_REPOSITORY="$GITFOR_REPOSITORY" GITHUB_SHA="$GITFOR_SHA" GITHUB_REF="$GITFOR_REF" GITHUB_RUN_ID="$GITFOR_RUN_ID" GITHUB_RUN_NUMBER="$GITFOR_RUN_NUMBER" GITHUB_RUN_ATTEMPT=1', 'export RUNNER_OS=Linux RUNNER_ARCH=X64 RUNNER_TEMP="$__gitfor_tmp/runner-temp" RUNNER_TOOL_CACHE="${RUNNER_TOOL_CACHE:-$__gitfor_tmp/toolcache}"', 'export GITHUB_STEP_SUMMARY="$__gitfor_tmp/step-summary" GITHUB_EVENT_PATH="$__gitfor_tmp/event.json"', ]; /** 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"', ...RUNNER_ENV_LINES, ...(job.githubEnv ?? []).map(([name, value]) => `export ${name}=${singleQuoted(value)}`), "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"', 'mkdir -p "$RUNNER_TEMP" "$RUNNER_TOOL_CACHE" && : > "$GITHUB_STEP_SUMMARY"', ...heredoc('"$GITHUB_EVENT_PATH"', job.eventJson ?? "{}"), '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"', ...CHECKOUT_LINES, "__gitfor_code=$?", ...actionFetchLines(job), "__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"', ); lines.push(...actionPreLines(job)); 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]; }