import type { CompiledJob, CompiledStep, Segment } from "./compile"; import { runtimeExpansion } from "./segments"; import { RUNTIME_FUNCTIONS } from "./script-runtime"; export const MARKER_PREFIX = "::stig::"; 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 exports(env: Array<[string, Segment[]]>): string[] { return env.map(([name, segments]) => `export ${name}=${doubleQuoted(segments)}`); } function heredoc(path: string, content: string): string[] { let delimiter = `STIG_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 = `__stig_cache_key_${step.index}`; if (cache.mode === "save") return [`__stig_cache_save ${doubleQuoted(cache.key)} ${cache.version} ${paths}`]; return [ `${keyVar}=${doubleQuoted(cache.key)}`, `if __stig_cache_restore ${singleQuoted(step.id ?? "")} "$${keyVar}" ${cache.version} ${cache.restoreKeys .map(doubleQuoted) .join(" ")}; then __stig_cache_hit_${step.index}=1; else __stig_cache_hit_${step.index}=0; fi`, `__stig_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`)}`, "__stig_code=0"); return lines; } if (step.kind === "cache") { lines.push(...cacheLines(step), "__stig_code=0"); return lines; } if (step.kind === "upload-artifact" && step.artifact) { const archive = `"$__stig_tmp/artifacts/${step.artifact.name}.tgz"`; lines.push( `( ${directory}; tar czf ${archive} -- ${step.artifact.path} )`, "__stig_code=$?", `if [ $__stig_code -eq 0 ]; then __stig_mark artifact ${step.artifact.name} ${archive}; 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} "$__stig_tmp/steps/${step.index}"`, ")", "__stig_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 [ $__stig_failed -eq 0 ] && [ "\${__stig_cache_ran_${index}:-0}" = 1 ] && [ "$__stig_cache_hit_${index}" != 1 ]; then`, ` echo ${singleQuoted(`Post ${step.name}`)}`, ` __stig_cache_save "$__stig_cache_key_${index}" ${step.cache.version} ${step.cache.paths.map(singleQuoted).join(" ")}`, "fi", ); } for (const [name, segments] of job.outputs) { lines.push(`__stig_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 function buildJobScript(job: CompiledJob): string { const lines = [ "#!/usr/bin/env bash", `__stig_mark() { printf '\\n${MARKER_PREFIX}%s\\n' "$*"; }`, RUNTIME_FUNCTIONS, "export CI=true STIG_ACTIONS=true GITHUB_ACTIONS=true", ...exports(job.env), "__stig_failed=0", '__stig_dir="${STIG_WORKSPACE:-/workspace}"', '__stig_tmp="$__stig_dir/.stig"', 'rm -rf "$__stig_tmp" "$__stig_dir/src" && mkdir -p "$__stig_tmp/steps" "$__stig_tmp/artifacts"', 'export STIG_OUTPUT="$__stig_tmp/output" GITHUB_OUTPUT="$__stig_tmp/output" STIG_ENV="$__stig_tmp/env" GITHUB_ENV="$__stig_tmp/env" STIG_PATH="$__stig_tmp/path" GITHUB_PATH="$__stig_tmp/path"', "__stig_reset_files", ]; for (const step of job.steps) { if (step.kind === "run") lines.push(...heredoc(`"$__stig_tmp/steps/${step.index}"`, step.script)); } lines.push( '__stig_should_run() { if [ $__stig_failed -eq 0 ]; then [ "$1" = 1 ]; else [ "$2" = 1 ]; fi; }', "__stig_mark setup-start", 'echo "Checking out $STIG_REPOSITORY@$STIG_SHA"', 'git clone --quiet --no-checkout "$STIG_CLONE_URL" "$__stig_dir/src" && git -C "$__stig_dir/src" checkout --quiet --detach "$STIG_SHA"', "__stig_code=$?", "__stig_mark setup-end $__stig_code", "if [ $__stig_code -ne 0 ]; then __stig_mark job-end 1; exit 1; fi", 'cd "$__stig_dir/src" || exit 1', 'export GITHUB_WORKSPACE="$PWD" STIG_WORKSPACE_SRC="$PWD"', ); for (const step of job.steps) { const guard = `__stig_should_run ${step.runIfOk ? 1 : 0} ${step.runIfFailed ? 1 : 0}`; const id = singleQuoted(step.id ?? ""); lines.push( `if ${guard}${step.runtimeCondition ? ` && ${step.runtimeCondition}` : ""}; then`, ` __stig_mark step-start ${step.index}`, ...stepBody(step).map((line) => ` ${line}`), ` __stig_mark step-end ${step.index} $__stig_code`, ` __stig_after_step ${id} $__stig_code ${step.continueOnError ? 1 : 0}`, ` if [ $__stig_code -ne 0 ] && [ ${step.continueOnError ? 1 : 0} = 0 ]; then __stig_failed=1; fi`, "else", ` __stig_mark step-skip ${step.index}`, ...(step.id ? [` export STIG_STEPOUTCOME_${step.id}=skipped STIG_STEPCONCLUSION_${step.id}=skipped`] : []), "fi", ); } lines.push(...postLines(job), "__stig_mark job-end $__stig_failed", "exit $__stig_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]; }