Skip to content

Generated GitHub Actions

Hollywood generates ordinary GitHub Actions files. There is no custom runtime inside the workflow YAML.

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/<name>/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

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. Child output streams unchanged unless the command requests { output: "capture" }. Captured output remains available to the action without flooding the job log. Hollywood adds a compact command group, elapsed status, and failure annotation without replaying the child output or printing a runtime stack trace.

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, runner, and summary services.

Command log color

runGitHubAction colors command status lines in auto mode in GitHub Actions and interactive terminals. NO_COLOR, NODE_DISABLE_COLORS, FORCE_COLOR=0, and FORCE_COLOR=false disable automatic color. Other FORCE_COLOR values enable it. Set logColor: "never" when another log collector renders terminal color codes literally, or logColor: "always" when testing colored output.

await runGitHubAction(integrationTest, { logColor: "never" });

GitHub step summaries do not use terminal color codes. They are rendered as escaped HTML.

Step summaries

Use summary.table for GitHub step summaries. Titles and labels are escaped plain text. Values must be explicitly formatted with summaryText or summaryCode. There is no raw HTML or Markdown cell format.

import { action, summaryCode, summaryText } from "@dedalus-labs/hollywood/action-runtime";

export const integrationTest = action({
    name: "integration-test",
    description: "Run a live integration test.",
    inputs,
    outputs: {},
    run: async ({ input, summary }) => {
        await summary.table("Integration test", [
            { label: "Environment", value: summaryCode(input.environment) },
            { label: "API base", value: summaryCode(input.apiBase) },
            { label: "Result", value: summaryText("PASS") },
        ]);
        return {};
    },
});

Workflow files

Set localActionPath on actions you want to call from generated workflows. Then uses(action, ...) derives ./.github/actions/<path> 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",
                        },
                    }),
                ],
            }),
        },
    }, { filename: "container-release.yml" }),
});

The source path is flattened:

gha/containers/release.ts

becomes:

.github/workflows/containers-release.yml

Workflow commands

Use command for a workflow step that starts one process. Provide the executable and argument vector as separate values.

import { command, job } from "@dedalus-labs/hollywood";

job({
    "runs-on": "ubuntu-latest",
    steps: [
        {
            name: "Test",
            run: command({
                file: "npm",
                args: ["test", "--", "src/release.test.ts"],
            }),
        },
    ],
});

Hollywood quotes literal arguments and selects bash. The selected runner must provide bash. Hollywood does not select another shell when bash is missing.

Pass a complete GitHub expression as one argument. Hollywood moves the expression into a generated environment variable and quotes the variable expansion. This prevents expression data from becoming shell syntax.

import { command, github } from "@dedalus-labs/hollywood";

command({
    file: "printf",
    args: ["actor=%s\\n", github.actor],
});

Do not combine a literal and an expression in one argument. Use format to produce one complete expression when the child process needs a combined value.

Use a typed local action for multiple commands, branching, loops, file access, or output parsing. Call exec once for each process. This path uses @actions/exec and does not generate shell control flow.

Unsafe shell escape hatch

Use unsafeShell only when a workflow step requires shell syntax that Hollywood cannot represent with command or a typed local action. Document the missing first-class operation in a source comment when it is not apparent from the script.

import { unsafeShell } from "@dedalus-labs/hollywood";

// Hollywood does not provide a structured pipeline step.
unsafeShell("printf '%s\\n' ok | tee result.txt");

unsafeShell does not quote, parse, or validate the script. The selected shell controls its behavior. Treat all interpolated values as untrusted and pass GitHub expressions through step environment variables instead of script text.

Pass { filename: "container-release.yml" } to workflow when the output name must be independent of the source layout. Hollywood rejects directory paths, non-portable names, unsupported extensions, and case-insensitive collisions before writing any generated file.

GitHub gets the flat shape it requires. The source tree keeps the nested shape humans want.

Validation

Generated workflow YAML and action metadata pass through upstream GitHub Actions parsers before Hollywood writes files. Invalid generated content fails closed.

CLI

Let the CLI discover source files that export Hollywood actions or workflows:

npx hollywood generate

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/<flattened-source-path>.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

Bundle generated actions before GitHub runs them:

npx hollywood build

Commit dist/index.js with the generated action, or build an ignored bundle in an earlier workflow step before calling the local action. 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