# Hollywood > TypeScript scripts for GitHub Actions, without shell-in-YAML. Hollywood is an AI-native TypeScript scripting library for GitHub Actions. It moves imperative CI/CD logic out of YAML and into testable scripts while generating ordinary action.yml files, workflow YAML, and GitHub-compatible action entrypoints. Key features: typed inputs, typed outputs, execve-shaped command arguments, local script tests, generated GitHub Actions files, upstream GitHub Actions schema validation, MinIO/LocalStack-style service dogfood, and Lima-backed local action runs. # Getting Started # [Installation](#installation) Inside this repository, install dependencies and build the local CLI: ``` npm ci npm run build ``` In another repository, install Hollywood as a development dependency: ``` npm install --save-dev @dedalus-labs/hollywood ``` That installs a local `hollywood` binary at `node_modules/.bin/hollywood`. Run it with `npx hollywood ...`: ``` npx hollywood generate "gha/**/*.ts" --output . ``` If you prefer npm scripts, wire the local binary once: ``` { "scripts": { "actions:generate": "hollywood generate \"gha/**/*.ts\" --output .", "actions:check": "hollywood check" } } ``` Then run: ``` npm run actions:generate ``` Run an exported action locally: ``` npx hollywood run gha/s3-cache.ts --export s3Cache --with mode=restore ``` ## [Node requirements](#node-requirements) | Surface | Node requirement | | -------------------------- | ------------------------------- | | Installed package and CLI | Node 20 or newer | | Generated GitHub actions | GitHub's Node 24 action runtime | | Building Hollywood locally | Node 22.18+ or Node 24.11+ | `package.json` is the source of truth for the published package's `engines.node` value. `tsdown.config.ts` sets the runtime build target. `tsconfig.json` is for typechecking and should not be read as the package's runtime support contract. GitHub JavaScript actions need a bundled entrypoint. Hollywood generates the TypeScript entrypoint, but the bundling command is still explicit. Until Hollywood owns that build step, use the repository's chosen bundler to turn: ``` .github/actions//src/index.ts ``` into: ``` .github/actions//dist/index.js ``` The generated `action.yml` points at `dist/index.js` because that is the normal GitHub Actions JavaScript action contract. ## [Documentation site](#documentation-site) Serve these docs locally with MkDocs: ``` python3 -m venv .venv . .venv/bin/activate python -m pip install -r docs/requirements.txt python -m mkdocs serve -f mkdocs.yml ``` Build them with strict link validation: ``` python3 -m venv .venv . .venv/bin/activate python -m pip install -r docs/requirements.txt python -m mkdocs build --strict -f mkdocs.yml ``` # [Quick Start](#quick-start) ## [1. Write a script](#1-write-a-script) ``` import { action, booleanInput, pathInput, stringInput, stringOutput, } from "@dedalus-labs/hollywood"; export const publishImage = action({ name: "publish-container-image", description: "Build and publish a container image without embedding shell in workflow YAML.", inputs: { image: stringInput({ description: "Container image name, including registry." }), tag: stringInput({ description: "Container image tag." }), context: pathInput({ description: "Build context path.", default: "." }), dockerfile: pathInput({ description: "Dockerfile path.", default: "Dockerfile" }), provenance: booleanInput({ description: "Emit build provenance.", default: "false" }), }, outputs: { imageRef: stringOutput({ description: "Published image reference." }), }, run: async ({ exec, input }) => { const imageRef = `${input.image}:${input.tag}`; await exec("docker", [ "buildx", "build", "--file", input.dockerfile, "--tag", imageRef, "--push", "--provenance", input.provenance ? "true" : "false", input.context, ]); return { imageRef }; }, }); ``` ## [2. Test it locally](#2-test-it-locally) ``` import { nodeExec, nodeFs, nodeLog, runAction } from "@dedalus-labs/hollywood"; await runAction(publishImage, { with: { image: "ghcr.io/acme/api", tag: "sha-abc123", provenance: "false", }, exec: nodeExec, fs: nodeFs, log: nodeLog, runner: { uidGid: "1001:1001" }, }); ``` Use a fake executor for unit tests. Use `nodeExec` only when you intentionally want to run the command on the local machine. The CLI can run the same exported action: ``` npx hollywood run gha/containers/publish-image.ts \ --export publishImage \ --with image=ghcr.io/acme/api \ --with tag=sha-abc123 \ --with provenance=false ``` For Linux VM execution on macOS, add `--lima `: ``` npx hollywood run gha/containers/publish-image.ts \ --export publishImage \ --lima default \ --start-vm \ --with image=ghcr.io/acme/api \ --with tag=sha-abc123 \ --with provenance=false ``` ## [3. Generate action files](#3-generate-action-files) Point Hollywood at the source files that export actions or workflows. Quote glob patterns so your shell does not expand them first. ``` npx hollywood generate "gha/**/*.ts" --output . ``` The command writes: ``` created .github/actions/publish-container-image/action.yml created .github/actions/publish-container-image/src/index.ts ``` The same flow is available as a library API: ``` import { generateActionEntrypointFile, generateActionFile, writeGeneratedFiles, } from "@dedalus-labs/hollywood"; await writeGeneratedFiles( [ generateActionFile(publishImage, { sourcePath: "gha/containers/publish-image.ts", actionsDir: ".github/actions", }), generateActionEntrypointFile(publishImage, { sourcePath: "gha/containers/publish-image.ts", actionsDir: ".github/actions", exportName: "publishImage", }), ], { outputDir: process.cwd() }, ); ``` This writes: ``` .github/actions/publish-container-image/action.yml .github/actions/publish-container-image/src/index.ts ``` ## [4. Call it from workflow YAML](#4-call-it-from-workflow-yaml) ``` jobs: publish_image: runs-on: ubuntu-latest steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Publish container image uses: ./.github/actions/publish-container-image with: image: ghcr.io/acme/api tag: ${{ github.sha }} provenance: "false" ``` The workflow stays flat and GitHub-compatible. The real logic stays in TypeScript. # Usage # [Scripts](#scripts) A Hollywood script is a `ScriptAction`: a typed object with a name, description, inputs, outputs, and one `run` function. ## [Inputs](#inputs) Use the narrowest input type that describes the contract: ``` const mode = choiceInput({ description: "Cache operation.", options: ["restore", "save"] as const, }); const bucket = stringInput({ description: "S3 bucket name." }); const archivePath = pathInput({ description: "Temporary archive path." }); const buildAttempt = integerInput({ description: "CI build attempt number." }); const dryRun = booleanInput({ description: "Skip mutating commands.", default: "false" }); ``` The runtime parses GitHub string inputs into typed script values. Invalid input fails before `run` starts. ## [Runtime Validation](#runtime-validation) Hollywood gives your script typed inputs. Use Zod, Effect Schema, or the schema library your repository already trusts when you also need domain policy that TypeScript cannot prove: ``` import { action, choiceInput, pathInput, stringInput } from "@dedalus-labs/hollywood"; import { z } from "zod"; const promotionPolicy = z.object({ environment: z.enum(["staging", "production"]), imageRef: z.string().regex(/^ghcr\.io\/[a-z0-9-]+\/[a-z0-9._/-]+:[A-Za-z0-9_.-]+$/), manifestPath: z.string().refine((path) => path.startsWith("deploy/")), }); export const promoteManifest = action({ name: "promote-manifest", description: "Promote a generated manifest after policy validation.", inputs: { environment: choiceInput({ description: "Deployment environment.", options: ["staging", "production"] as const, }), imageRef: stringInput({ description: "Published image reference." }), manifestPath: pathInput({ description: "Manifest path under deploy/." }), }, outputs: {}, run: async ({ exec, input }) => { promotionPolicy.parse(input); await exec("git", ["add", input.manifestPath]); return {}; }, }); ``` ## [Commands](#commands) Use `exec(file, args)` for process execution: ``` await exec("aws", ["s3", "cp", archivePath, `s3://${bucket}/${key}`, "--only-show-errors"]); ``` Each array item is one argument. Hollywood does not ask a shell to split a string. ## [Expected nonzero exits](#expected-nonzero-exits) Some commands use exit codes as data. For example, S3 restore misses are not always fatal. Say that explicitly: ``` const copy = await exec("aws", ["s3", "cp", s3Uri, input.archivePath], { exitPolicy: "any" }); if (copy.exitCode !== 0) { log.info(`No cache found at ${s3Uri}`); return { cacheHit: "false" }; } ``` The default exit policy is `zero`, which throws on any nonzero exit. Hollywood does not silently degrade. ## [Parallel commands](#parallel-commands) `exec` is asynchronous because local execution uses `child_process.spawn` and GitHub execution uses `@actions/exec`. Use normal TypeScript promises when two commands are independent: ``` const [lint, test] = await Promise.all([exec("pnpm", ["lint"]), exec("pnpm", ["test"])]); ``` Keep scheduler policy in workflow YAML. Script-level `Promise.all` is for work inside one action process; workflow `strategy.max-parallel`, `needs`, and `concurrency` decide how GitHub schedules jobs. ## [Logs](#logs) Scripts receive a small logger: ``` await log.group("Publish container image", async () => { await exec("docker", ["buildx", "build", "--tag", input.imageRef, "--push", input.context]); }); log.warning("Cache upload failed"); ``` Local runs can write to stdout and stderr. GitHub runs route the same calls through `@actions/core`. # [Generated GitHub Actions](#generated-github-actions) Hollywood generates ordinary GitHub Actions files. There is no custom runtime inside the workflow YAML. ## [Action metadata](#action-metadata) ``` generateActionFile(publishImage, { sourcePath: ".github/actions/containers/publish-image/src/action.ts", actionsDir: ".github/actions", }); ``` This produces: ``` .github/actions/containers/publish-image/action.yml ``` When the source already lives under `.github/actions//src`, Hollywood keeps the generated files in that action directory. The file contains a normal JavaScript action contract: ``` name: publish-container-image description: Build and publish a container image without embedding shell in workflow YAML. runs: using: node24 main: dist/index.js ``` ## [Entrypoint](#entrypoint) ``` generateActionEntrypointFile(publishImage, { sourcePath: ".github/actions/containers/publish-image/src/action.ts", actionsDir: ".github/actions", exportName: "publishImage", }); ``` This produces: ``` import { runGitHubAction } from "@dedalus-labs/hollywood/action-runtime"; import { publishImage } from "./action.ts"; void runGitHubAction(publishImage); ``` `runGitHubAction` uses GitHub's official TypeScript packages. Inputs and outputs go through `@actions/core`. Commands go through `@actions/exec`. ## [Action composition](#action-composition) Use `call` inside a parent action when one public GitHub Action should compose smaller typed Hollywood actions. ``` export const release = action({ name: "release", description: "Compose a release contract.", inputs, outputs, run: async ({ call, input }) => { const artifacts = await call(resolveArtifacts, { version: input.version, }); const metadata = await call(readBuildMetadata, { uri: artifacts.buildMetadataUri, }); return assembleRelease(input, artifacts, metadata); }, }); ``` `call` does not create nested workflow steps. It invokes the child action in the same runtime with the same `exec`, `fs`, `log`, and `runner` services. ## [Workflow files](#workflow-files) Set `localActionPath` on actions you want to call from generated workflows. Then `uses(action, ...)` derives `./.github/actions/` and preserves the action's typed inputs. ``` import { generateWorkflowFile, job, uses, workflow } from "@dedalus-labs/hollywood"; import { defineMatrix, format, gh } from "@dedalus-labs/hollywood/expr"; const build = defineMatrix({ runner: ["ubuntu-latest"], } as const); generateWorkflowFile({ sourcePath: "gha/containers/release.ts", sourceRoot: "gha", workflowsDir: ".github/workflows", workflow: workflow({ name: "Container Release", on: { workflow_dispatch: {} }, concurrency: { group: format("{0}-{1}", gh.github.workflow, gh.github.ref), queue: "max", }, jobs: { publish_image: job({ "runs-on": build.runner, strategy: { matrix: build, "max-parallel": 2 }, steps: [ uses(publishImage, { name: "Publish container image", with: { image: "ghcr.io/acme/api", tag: gh.github.sha, provenance: "false", }, }), ], }), }, }), }); ``` The source path is flattened: ``` gha/containers/release.ts ``` becomes: ``` .github/workflows/containers-release.yml ``` GitHub gets the flat shape it requires. The source tree keeps the nested shape humans want. ## [Path-dependent CI jobs](#path-dependent-ci-jobs) Use `pathDependencies` when a workflow should stay scheduled but specific jobs should only run for relevant files. This is the safe shape for required checks: GitHub path filters can leave skipped workflows pending, while job guards keep the workflow result explicit. ``` import { generateWorkflowFile, job, pathDependencies, workflow, } from "@dedalus-labs/hollywood"; const changes = pathDependencies("changes", { terraform: [ "infra/terraform/**", ".github/actions/terraform/**", ], web: [ "apps/web/**", "packages/ui/**", "!apps/web/docs/**", ], }); generateWorkflowFile({ sourcePath: "gha/platform/static-validation.ts", sourceRoot: "gha", workflowsDir: ".github/workflows", workflow: workflow({ name: "Platform Static Validation", on: { pull_request: { paths: changes.workflowPaths }, }, jobs: { [changes.jobId]: changes.job(), infracost: job({ name: "Terraform cost", needs: changes.jobId, if: changes.terraform.changed, "runs-on": "ubuntu-24.04", steps: [{ uses: "./.github/actions/terraform/infracost" }], }), web_checks: job({ name: "Web checks", needs: changes.jobId, if: changes.web.changed, "runs-on": "ubuntu-24.04", steps: [{ run: "npm test" }], }), }, }), }); ``` `workflowPaths` contains positive patterns only. Negative patterns still apply inside the generated detector job, so a workflow can start conservatively without accidentally skipping another dependency. ## [Validation](#validation) Generated workflow YAML and action metadata pass through upstream GitHub Actions parsers before Hollywood writes files. Invalid generated content fails closed. ## [CLI](#cli) Point the CLI at source files that export Hollywood actions or workflows: ``` npx hollywood generate "gha/**/*.ts" --output . ``` Hollywood discovers exports by shape: | Export shape | Generated files | | ----------------------------------------- | ---------------------------------------------------- | | `action({ name: "s3-cache" })` | `.github/actions/s3-cache/action.yml` and entrypoint | | `workflow({ name: "Container Release" })` | `.github/workflows/.yml` | For example, this source tree: ``` gha/ actions/ s3-cache.ts workflows/ cache-example.ts ``` can generate: ``` .github/ actions/ s3-cache/ action.yml src/index.ts workflows/ workflows-cache-example.yml ``` The generated action still needs bundling to `dist/index.js` before GitHub can run it. The workflow YAML can be committed as-is. The CLI prints one line per generated file: ``` created .github/actions/publish-container-image/action.yml updated .github/actions/publish-container-image/src/index.ts created .github/workflows/containers-release.yml ``` # [Local Testing](#local-testing) Hollywood has three local testing layers. ## [Unit tests](#unit-tests) Use a fake executor when the script's command sequence is the contract: ``` const commands: Command[] = []; await runAction(publishImage, { with: { image: "ghcr.io/acme/api", tag: "sha-abc123", provenance: "false", }, exec: async (file, args, options) => { commands.push({ file, args, ...options }); return { exitCode: 0, stdout: "", stderr: "" }; }, fs: { readText: async () => "" }, log: memoryLog, runner: { uidGid: "1001:1001" }, }); ``` This is the fast path. It proves typed inputs, output shapes, command arguments, and explicit nonzero-exit handling. ## [Real local commands](#real-local-commands) Use `nodeExec`, `nodeFs`, and `nodeLog` when the script should run on the local machine. The CLI path is: ``` npx hollywood run gha/containers/publish-image.ts \ --export publishImage \ --with image=ghcr.io/acme/api \ --with tag=sha-abc123 \ --with provenance=false ``` The library path is: ``` await runAction(action, { with, exec: nodeExec, fs: nodeFs, log: nodeLog, runner: { uidGid: "1001:1001" }, }); ``` This is useful for scripts that call local tools such as `aws`, `tar`, `zstd`, `terraform`, or project-specific binaries. ## [Lima commands](#lima-commands) Use `--lima ` when the script should run commands inside a Linux VM. The full command mapping lives in the [Lima backend docs](https://oss.dedaluslabs.ai/hollywood/backends/lima/index.md). ``` npx hollywood run gha/go/s3-cache.ts \ --export s3Cache \ --lima kvm \ --start-vm \ --with mode=restore ``` Every script command is routed through `limactl shell` without turning the command into shell text. Add `--require-containerd` or `--require-kvm` when the script needs those VM capabilities before it starts. ## [Real local services](#real-local-services) Use MinIO or LocalStack when the script talks to cloud-shaped APIs. The current local S3 test is gated because it needs a local service: ``` HOLLYWOOD_RUN_LOCAL_S3=1 HOLLYWOOD_RUN_MINIO=1 \ npm test -- src/script.test.ts ``` This tests the ethos directly: run the script locally against a real service, then expose the same script to GitHub. # [Local Services](#local-services) Cloud workflows should be tested against local services before GitHub spends money discovering obvious mistakes. ## [MinIO](#minio) Use MinIO, an Amazon Simple Storage Service (S3)-compatible object store, for S3-specific tests when persistence and speed matter more than full Amazon Web Services (AWS) behavior. Use cases: - direct S3 cache restore/save scripts - object upload and download - archive layout checks - cache key behavior Not a fit: - Identity and Access Management (IAM) policy behavior - Security Token Service (STS) or OpenID Connect - AWS service interactions beyond S3 ## [LocalStack](#localstack) Use LocalStack, a local AWS emulator, when the script or workflow depends on AWS-shaped behavior across multiple services. Use cases: - Terraform tests that need S3, DynamoDB, IAM, or STS endpoints - service containers in GitHub Actions - scripts that call the normal AWS CLI or SDK against a local endpoint Hollywood should not hide which service is active. A script should receive the endpoint and credentials explicitly through typed inputs or environment. ## [VM providers](#vm-providers) Linux action runs on macOS should use Lima first. Lima gives us a real Linux virtual machine (VM), and Hollywood routes each script `exec(file, args)` call through `limactl shell`. See [Execution Backends](https://oss.dedaluslabs.ai/hollywood/backends/index.md) for the current backend matrix and planned directions. ``` npx hollywood run gha/cache/s3-cache.ts --export s3Cache --lima default --start-vm ``` Hollywood's current VM support is action-level, not whole-workflow emulation. There is no local artifact server, cache server, OIDC issuer, or private GitHub runner worker protocol in the package. ## [Rejection is a feature](#rejection-is-a-feature) Some jobs cannot run on a developer laptop. A container publish action might require Docker BuildKit, registry credentials, and a Linux-only toolchain. A Terraform apply action might require cloud credentials that should never exist on a random machine. Hollywood should reject that local run before starting when the declared contract is missing. A local green run that did not provide the runner contract would be worse than no local run. # Backends # [Execution Backends](#execution-backends) Execution backends decide where a Hollywood script command runs. Hollywood's script contract stays the same for every backend: - `exec(file, args)` receives an executable path and an argument array. - `cwd`, environment, and exit policy stay structured. - Missing capabilities reject the local run before the action starts. - GitHub still runs generated actions on real GitHub runners. ## [Backend Matrix](#backend-matrix) | Backend | Status | Use cases | | ----------------------------------------------------------------------------------- | --------- | -------------------------------------------------------------- | | Host process | Supported | Commands that should run on the current developer machine. | | [Lima](https://oss.dedaluslabs.ai/hollywood/backends/lima/index.md) | Supported | Linux command execution from macOS or another host. | | [Apple Container](https://oss.dedaluslabs.ai/hollywood/backends/container/index.md) | Planned | OCI images backed by lightweight macOS virtual machines. | | [Docker](https://oss.dedaluslabs.ai/hollywood/backends/docker/index.md) | Planned | Containerized tools where the Docker CLI is already available. | | [smolmachines](https://oss.dedaluslabs.ai/hollywood/backends/smolmachines/index.md) | Candidate | Portable virtual machine images with fast local startup. | | [Arch / pacman](https://oss.dedaluslabs.ai/hollywood/backends/pacman/index.md) | Candidate | Arch Linux package workflows and pacman-shaped recipes. | Supported means Hollywood has a public API or CLI flag for that backend today. Planned means the backend shape is useful, but the package does not expose it yet. Candidate means the page records an integration direction for discussion. ## [Why Backends Exist](#why-backends-exist) The backend boundary is intentionally small. A backend only needs to answer: 1. How do we run ` ...` without shell interpolation? 1. How do we pass structured working directory and environment values? 1. How do we prove required capabilities before `run` starts? 1. How do we report the runner user and group when file ownership matters? Everything else belongs in the script or the generated GitHub Actions workflow. Hollywood should not become a full GitHub Actions emulator. # [Lima](#lima) Lima is Hollywood's supported Linux virtual machine backend. Use it when a script should run Linux commands from a macOS laptop or from a host that should delegate execution into a named Lima virtual machine. ``` npx hollywood run gha/cache/s3-cache.ts \ --export s3Cache \ --lima default \ --start-vm \ --with mode=restore ``` ## [Command Shape](#command-shape) Hollywood routes each script command through `limactl shell` without rewriting the command into shell text. With `--lima default --start-vm`, the backend command is: ``` limactl shell --tty=false --start default -- ... ``` If a script command has a working directory, Hollywood preserves that as a structured Lima option: ``` limactl shell --tty=false --start --workdir default -- ... ``` If a script command has environment variables, Hollywood inserts an explicit `env` process inside the VM: ``` limactl shell --tty=false --start default -- env NAME=value ... ``` The important invariant is unchanged: the executable and arguments remain an argument array all the way into the backend. ## [Capability Checks](#capability-checks) `--start-vm` starts the named VM before running the action. Without it, a stopped VM rejects the local run before the script starts. `--require-containerd` checks that `containerd` is active and that `nerdctl` exists inside the VM. `--require-kvm` checks that `/dev/kvm` is readable and writable inside the VM. These checks are fail-closed. A local run that silently skips the requested backend capability would not prove the action contract. ## [Runner Context](#runner-context) Hollywood asks the Lima VM for `id -u` and `id -g` so scripts can use the guest runner user and group when they create or extract files. # [Apple Container](#apple-container) Apple Container is a planned backend, not a supported Hollywood runtime yet. The useful shape is clear: run a Linux container as the backend for `exec(file, args)` while preserving the same argument-array contract Hollywood uses for host and Lima runs. Apple's `container` tool runs Linux containers as lightweight virtual machines on Apple silicon Macs and consumes Open Container Initiative (OCI) images. Its official docs expose nested virtualization with `--virtualization`; that path requires supported Apple silicon and a Linux kernel with virtualization support. ## [Target Shape](#target-shape) A future backend should keep this shape: ``` container run ... ``` The backend should own image selection, mount policy, network policy, and virtualization capability checks. Scripts should still only see `exec(file, args)`. ## [Open Questions](#open-questions) - How should Hollywood map script working directories into container mounts? - Should environment variables be passed directly or through an explicit `env` process for parity with Lima? - How should a script request nested virtualization or KVM access without baking host-specific kernel paths into the action? - Which runner user and group should be reported for extracted cache archives? References: - [apple/container](https://github.com/apple/container) - [Apple Container how-to](https://github.com/apple/container/blob/main/docs/how-to.md) # [Docker](#docker) Docker is a planned execution backend. Scripts can already call Docker directly through `exec("docker", [...])` when the host has Docker installed. A Docker backend would be different: it would run each script command inside a declared container environment. ## [Target Shape](#target-shape) A future backend should preserve the command contract: ``` docker run ... ``` The backend would own image selection, volume mounts, working directory, environment, user mapping, and container cleanup. ## [Good Fit](#good-fit) - tools that are easier to install once in an image than on every laptop - Linux-only command-line tools - reproducible smoke tests for generated actions - local service tests that need a nearby container network ## [Non-Goals](#non-goals) Docker support should not turn Hollywood into a workflow emulator. It should only route `exec(file, args)` through a declared container environment. # [smolmachines](#smolmachines) smolmachines is a candidate execution backend. The interesting fit is portable virtual machine images for scripts that need a real Linux environment but should start quickly and travel with their runtime state. ## [Target Shape](#target-shape) A future backend should keep the same Hollywood command boundary: ``` smolvm machine run -- ... ``` Persistent machines would likely use `smolvm machine exec --name -- ...`. The exact Hollywood API needs design against the smolmachines CLI before this becomes a supported backend. ## [Good Fit](#good-fit) - hermetic build tools - stateful development machines used by CI-like scripts - local tests that need stronger isolation than a host process Reference: - [smol-machines/smolvm](https://github.com/smol-machines/smolvm) # [Arch / pacman](#arch-pacman) Arch Linux and `pacman` are candidate recipe targets, not an execution backend by themselves. The useful direction is a backend profile or recipe for scripts that need Arch package tooling such as `pacman`, `makepkg`, or repository metadata commands. That profile would still run through a real backend such as Lima, Docker, Apple Container, or smolmachines. ## [Target Shape](#target-shape) The script command remains ordinary Hollywood: ``` await exec("pacman", ["--sync", "--refresh"]); ``` The backend provides the Arch environment where that command exists. ## [Good Fit](#good-fit) - building Arch packages in CI - testing repository metadata updates - exercising package-manager-specific cache behavior - documenting package recipes without shell-in-YAML ## [Open Questions](#open-questions) - Should this live as a recipe rather than a backend page? - Which Arch base image or VM image should examples use? - Which commands are safe to demonstrate without mutating the developer host? # Recipes # [S3 Cache](#s3-cache) The Amazon Simple Storage Service (S3) cache recipe restores or saves an archive in object storage. It is the best first example because it can run locally against MinIO, an S3-compatible object store, and in GitHub against AWS S3 with the same script. The maintained example lives at `examples/s3-cache.ts`. ## [Script shape](#script-shape) ``` export const s3Cache = action({ name: "s3-cache", description: "Restore or save an archive from S3-compatible object storage.", inputs: { mode: choiceInput({ description: "Cache mode.", options: ["restore", "save"] as const, }), bucket: stringInput({ description: "S3 bucket name." }), prefix: stringInput({ description: "S3 key prefix." }), key: stringInput({ description: "Cache key." }), archivePath: pathInput({ description: "Temporary cache archive path." }), contentsPath: pathInput({ description: "Directory to restore or save." }), }, outputs: { cacheHit: stringOutput({ description: "Whether restore found an archive." }), }, run: async ({ exec, input, log }) => { const s3Uri = `s3://${input.bucket}/${input.prefix}/${input.key}.tar.gz`; if (input.mode === "restore") { const copy = await exec("aws", ["s3", "cp", s3Uri, input.archivePath], { exitPolicy: "any" }); if (copy.exitCode !== 0) { log.info(`No Go cache found at ${s3Uri}`); return { cacheHit: "false" }; } await exec("tar", ["-xzf", input.archivePath, "-C", input.contentsPath]); return { cacheHit: "true" }; } await exec("tar", ["-czf", input.archivePath, "-C", input.contentsPath, "."]); await exec("aws", ["s3", "cp", input.archivePath, s3Uri]); return { cacheHit: "true" }; }, }); ``` The restore miss is modeled explicitly with `exitPolicy: "any"`. Upload failure can be fatal or nonfatal depending on the cache contract. Pick one and encode it in the script. ## [Local MinIO test](#local-minio-test) ``` HOLLYWOOD_RUN_MINIO=1 \ npm test -- src/examples.test.ts \ -t "s3-cache example saves and restores through real local MinIO" ``` This starts a local MinIO process, creates a bucket with the AWS command line interface (CLI), saves an archive through the Hollywood action, restores it through the same action, and checks the restored file contents. ## [GitHub workflow step](#github-workflow-step) ``` - name: Restore Go cache uses: ./.github/actions/s3-cache with: mode: restore bucket: ci-artifacts prefix: go key: ${{ runner.os }}-${{ hashFiles('go.sum') }} archive-path: /tmp/go-cache.tar.gz contents-path: /tmp/go-cache ``` There is no shell in the workflow. The workflow passes values. The action owns the program. # [Publish Container Image](#publish-container-image) The container image recipe wraps a Docker buildx publish step. It is a common CI/CD action: build from a Dockerfile, tag with the current commit, push to a registry, and return the image reference for deployment jobs. The maintained example lives at `examples/publish-container-image.ts`. ``` import { action, booleanInput, pathInput, stringInput, stringOutput, } from "@dedalus-labs/hollywood"; export const publishImage = action({ name: "publish-container-image", description: "Build and publish a container image without embedding shell in workflow YAML.", inputs: { image: stringInput({ description: "Container image name, including registry." }), tag: stringInput({ description: "Container image tag." }), context: pathInput({ description: "Build context path.", default: "." }), dockerfile: pathInput({ description: "Dockerfile path.", default: "Dockerfile" }), provenance: booleanInput({ description: "Emit build provenance.", default: "false" }), }, outputs: { imageRef: stringOutput({ description: "Published image reference." }), }, run: async ({ exec, input, log }) => { const imageRef = `${input.image}:${input.tag}`; await log.group("Publish container image", async () => { await exec("docker", [ "buildx", "build", "--file", input.dockerfile, "--tag", imageRef, "--push", "--provenance", input.provenance ? "true" : "false", input.context, ]); }); return { imageRef }; }, }); ``` Generated workflow usage: ``` - name: Publish container image uses: ./.github/actions/publish-container-image with: image: ghcr.io/acme/api tag: ${{ github.sha }} context: . dockerfile: Dockerfile provenance: "false" ``` ## [Why this belongs in Hollywood](#why-this-belongs-in-hollywood) Container publishing looks small until it needs to be reliable. The moment you add multiple tags, build arguments, cache settings, provenance, and downstream outputs, a shell string inside YAML becomes hard to review. Hollywood keeps the contract typed: | Concern | Hollywood shape | | ------------------- | ------------------------------------------------ | | Required image name | `stringInput({ description: "..." })` | | Optional paths | `pathInput({ default: "." })` | | Boolean settings | `booleanInput({ default: "false" })` | | Command arguments | `exec("docker", ["buildx", "build", ...])` | | Downstream value | `imageRef: stringOutput({ description: "..." })` | ## [Run locally](#run-locally) ``` npx hollywood run examples/publish-container-image.ts \ --export publishImage \ --with image=ghcr.io/acme/api \ --with tag=sha-abc123 \ --with context=. \ --with dockerfile=Dockerfile \ --with provenance=false ``` Use a fake executor in unit tests when you want to assert the exact Docker command without pushing anything. # Reference # [API Surface](#api-surface) Hollywood's current application programming interface (API) surface is intentionally small. ## [Script authoring](#script-authoring) | API | Purpose | | -------------- | ----------------------------------------------------- | | `action` | Define a typed script action. | | `stringInput` | Read a required or defaulted string input. | | `integerInput` | Parse a string input into an integer. | | `booleanInput` | Parse a string input into a boolean. | | `choiceInput` | Restrict a string input to a closed set of values. | | `pathInput` | Mark an input as a filesystem path. | | `stringOutput` | Declare a string output. | | `call` | Invoke a child action with typed inputs inside `run`. | | `exec` | Run an executable plus argument array inside `run`. | | `expr` | Validate and wrap a GitHub Actions expression. | ## [Expressions](#expressions) | API | Purpose | | --------------------------------- | ---------------------------------------------------- | | `gh` | Namespace for typed `github.*` and `runner.*` refs. | | `github` | Typed references to common `github.*` context names. | | `runner` | Typed references to common `runner.*` context names. | | `format` | Build a validated `format(...)` expression. | | `contains` | Build a validated `contains(...)` expression. | | `hashFiles` | Build a validated `hashFiles(...)` expression. | | `eq` / `ne` | Build validated equality expressions. | | `and` / `or` | Compose validated boolean expressions. | | `selectString` | Select between typed string expression values. | | `valueOr` | Compose typed value expressions with OR. | | `not` | Negate a validated expression. | | `input` | Reference `inputs.`. | | `matrix` | Reference `matrix.`. | | `needsOutput` | Reference `needs..outputs.`. | | `needsResult` | Reference `needs..result`. | | `needsResultIs` / `needsResultIn` | Compare job results. | | `stepOutput` | Reference `steps..outputs.`. | | `defineMatrix` | Keep matrix values and typed matrix refs together. | | Status helpers | `always`, `cancelled`, `failure`, `success`. | Expression helpers are also exported from `@dedalus-labs/hollywood/expr` so workflow authoring can keep orchestration imports separate from script/action imports. ## [Runtime adapters](#runtime-adapters) | API | Purpose | | ----------------- | ---------------------------------------------------------------------------- | | `runAction` | Run a script with explicit filesystem, executor, logger, and runner context. | | `runGitHubAction` | Run a script through `@actions/core` and `@actions/exec`. | | `nodeExec` | Execute commands on the local machine. | | `nodeFs` | Read local files. | | `nodeLog` | Write local logs to stdout and stderr. | | `limaExec` | Route command execution through `limactl shell`. | | `limaRunner` | Read the guest runner uid/gid from a Lima VM. | ## [Action runtime import](#action-runtime-import) GitHub JavaScript actions should import the smaller action runtime surface: ``` import { action, runGitHubAction } from "@dedalus-labs/hollywood/action-runtime"; ``` This subpath exports script authoring primitives and the GitHub adapter without pulling workflow generation or YAML validation code into every bundled action. ## [Generation](#generation) | API | Purpose | | ------------------------------ | ---------------------------------------------------------- | | `generateActionMetadata` | Convert a script action into `action.yml` metadata. | | `generateActionFile` | Produce a generated `action.yml` file object. | | `generateActionEntrypointFile` | Produce a generated TypeScript entrypoint file object. | | `generateActionFiles` | Generate action metadata files with duplicate path checks. | | `generateUsesStep` | Convert typed script inputs into GitHub `with:` names. | | `uses` | Reference a generated local action from a workflow step. | | `generateWorkflowFile` | Produce a flattened workflow file object. | | `workflow` | Type a GitHub workflow definition without extra runtime. | | `job` | Type a GitHub workflow job without extra runtime. | | `pathDependencies` | Define typed path-gated jobs and their detector job. | | `matchPathDependency` | Test a path dependency pattern list locally. | | `writeGeneratedFiles` | Write generated files under an explicit output directory. | `GitHubWorkflow` types cover the orchestration fields Hollywood emits today: `permissions`, `concurrency`, job `needs`, matrix `strategy`, `services`, `env`, `if`, and mutually exclusive `run`/`uses` steps. `queue: max` is typed so it cannot be combined with `cancel-in-progress`. `pathDependencies` models the standard required-check-safe path gating shape: run the workflow, detect changed paths once, then guard downstream jobs with typed `needs..outputs. == 'true'` expressions. ## [CLI](#cli) | Command | Purpose | | -------------------- | ---------------------------------------------------------- | | `hollywood generate` | Discover exported actions and workflows from source files. | | `hollywood run` | Run one exported Hollywood action locally. | The command accepts explicit source files or glob patterns: ``` npx hollywood generate "gha/**/*.ts" --output . ``` Supported flags: | Flag | Default | Purpose | | ----------------- | ------------------- | ------------------------------------------ | | `--output` | `.` | Repository root where files are written. | | `--actions-dir` | `.github/actions` | Destination for generated local actions. | | `--workflows-dir` | `.github/workflows` | Destination for generated workflows. | | `--source-root` | `gha` | Prefix removed before workflow flattening. | The source root and generated output directories are CLI options, not hardcoded paths. Run an action on the host: ``` npx hollywood run gha/s3-cache.ts --export s3Cache --with mode=restore ``` Run the same action with command execution routed through Lima: ``` npx hollywood run gha/s3-cache.ts --export s3Cache --lima kvm --start-vm --with mode=restore ``` `--require-containerd` checks `containerd` and `nerdctl` before the action starts. `--require-kvm` checks readable and writable `/dev/kvm` before the action starts. The exact backend command shape is documented in [Lima](https://oss.dedaluslabs.ai/hollywood/backends/lima/index.md). ## [Validation](#validation) | API | Purpose | | ---------------------------------- | ------------------------------------------------------ | | `validateActionMetadataContent` | Return parser diagnostics for an action metadata file. | | `validateWorkflowContent` | Return parser diagnostics for a workflow file. | | `assertValidActionMetadataContent` | Throw if action metadata is invalid. | | `assertValidWorkflowContent` | Throw if workflow YAML is invalid. | ## [Environment probing](#environment-probing) | API | Purpose | | ---------------------- | --------------------------------------------------- | | `probeLimaEnvironment` | Check whether the named Lima environment is usable. | # [Generated Files](#generated-files) Hollywood writes generated files only through `writeGeneratedFiles`. ``` await writeGeneratedFiles(files, { outputDir: process.cwd() }); ``` `outputDir` is the filesystem root for the write. A generated path that escapes that directory is rejected. Every generated text file starts with an `@generated by Hollywood` comment. Callers may pass an explicit timestamp, but CLI generation omits timestamps so re-running it is idempotent. Checked-in JavaScript action bundles also get a generated banner from `.github/actions/build.mjs`; package bundles get one from `npm run build`. ## [File states](#file-states) Each write reports one of three states: | State | Meaning | | ----------- | ---------------------------------------- | | `created` | The file did not exist and was written. | | `updated` | The file existed and content changed. | | `unchanged` | The file existed with identical content. | These states make generation script output predictable. A check mode can fail if any file would be `created` or `updated`. ## [Action file](#action-file) ``` .github/actions//action.yml ``` This file is GitHub Actions metadata. Hollywood validates it before writing. If the source path already lives under `.github/actions//src`, Hollywood keeps the generated files in that action directory. Other action sources use `action.name` as their generated directory. ## [Entrypoint file](#entrypoint-file) ``` .github/actions//src/index.ts ``` This file adapts the script to GitHub's official action toolkit: ``` void runGitHubAction(action); ``` The entrypoint still needs bundling into: ``` .github/actions//dist/index.js ``` ## [Workflow file](#workflow-file) ``` .github/workflows/.yml ``` The source tree can be nested: ``` gha/containers/release.ts ``` The GitHub workflow output is flat: ``` .github/workflows/containers-release.yml ``` That satisfies GitHub's directory shape without forcing humans to keep every workflow source file in one directory. # [Publishing Boundary](#publishing-boundary) The published package should contain runtime JavaScript, TypeScript declarations, package metadata, and the README. It should not contain examples, tests, Vitest config, or TypeScript source. ## [Dependency boundary](#dependency-boundary) Hollywood keeps the direct runtime dependency list small: | Dependency | Why it exists | | -------------------------- | ------------------------------------------------- | | `@actions/core` | Official GitHub Actions input/output and logging. | | `@actions/exec` | Official GitHub Actions process execution. | | `@actions/expressions` | GitHub expression parsing and validation. | | `@actions/workflow-parser` | GitHub workflow schema parsing and validation. | | `esbuild` | Local TypeScript source loading for the CLI. | | `yaml` | Rendering generated action and workflow files. | Keep new runtime dependencies rare. Every dependency expands the install graph that users have to trust when they run CI/CD automation from npm. ## [Desired package boundary](#desired-package-boundary) ``` { "bin": { "hollywood": "./dist/cli.js" }, "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }, "./action-runtime": { "types": "./dist/action-runtime.d.ts", "import": "./dist/action-runtime.js" }, "./expr": { "types": "./dist/expr.d.ts", "import": "./dist/expr.js" } }, "types": "./dist/index.d.ts", "files": ["dist", "README.md", "package.json"] } ``` With that boundary: | Path | Published? | Reason | | ------------------ | ---------- | --------------------------------------- | | `dist/index.js` | yes | Runtime entrypoint. | | `dist/cli.js` | yes | Bundled `hollywood` command. | | `dist/index.d.ts` | yes | Public types. | | `dist/expr.js` | yes | Expression helper subpath. | | `README.md` | yes | Package landing page. | | `LICENSE` | yes | License file included by npm. | | `examples/*` | no | Repository examples, not runtime files. | | `src/*.test.ts` | no | Tests are not runtime files. | | `vitest.config.ts` | no | Local test configuration. | `npm pack --dry-run` is the source of truth for what would publish. The package uses `prepack` to build `dist/` before the tarball is assembled. ## [Release flow](#release-flow) Hollywood releases from `main` through [Release Please](https://github.com/googleapis/release-please). Normal PRs merge into `main` first. On each push, Release Please reads the Conventional Commit history and opens or updates one release PR with the next version, changelog, and package metadata. Merging that release PR into `main` is the release switch. Release Please then creates the GitHub release and tag. The npm workflow runs from the published GitHub release, checks out the release tag, reruns lint/typecheck/tests/build, and publishes the package with npm provenance. The current prerelease channel publishes with the `alpha` npm dist-tag. Release Please owns `package.json`, `CHANGELOG.md`, and `.release-please-manifest.json` during normal releases.