Writing a GitHub Action with Pontil

Pontil is a toolkit for writing GitHub Actions in Gleam. It provides the primitives — input reading, logging, output commands, job summaries — but deliberately stays out of your concurrency story. You compose pontil into your action’s runtime; it doesn’t take control away from you.

This guide walks through building a JavaScript-target GitHub Action using pontil, based on patterns from starlist, a real-world action built with pontil.

Prerequisites

Project Setup

gleam.toml

Your action project should only target JavaScript, as GitHub Actions run on Node.

name = "my_feature"
version = "1.0.0"
target = "javascript"

[dependencies]
envoy = ">= 1.1.0 and < 2.0.0"
gleam_javascript = ">= 1.0.0 and < 2.0.0"
gleam_stdlib = ">= 0.44.0 and < 2.0.0"
pontil = ">= 1.0.0 and < 2.0.0"

[dev-dependencies]
pontil_build = ">= 1.0.0 and < 2.0.0"

[tools.pontil_build.bundle]
entry = "my_feature_action.gleam"

Key dependencies:

Alternative: If you prefer a more general-purpose bundler, esgleam can also be used.

action.yml

The action metadata file tells GitHub how to run your action:

name: My Action
description: Does something useful

inputs:
  token:
    description: GitHub token
    required: true
  config:
    description: Optional configuration
    required: false

runs:
  using: "node24"
  main: "dist/my_feature.cjs"

The main field points at the bundled output, not your Gleam source.

.gitignore

/build
!dist/

The dist/ directory containing your bundled action must be committed. GitHub Actions clones your repository and runs the bundle directly — there’s no build step on the runner.

Project Structure

A typical pontil action has this layout:

src/
  my_feature.gleam         # Library entry point
  my_feature_action.gleam  # Action entry point (what GitHub runs)
  my_feature_js.gleam      # JS CLI entry point
dist/
  my_feature.cjs           # Bundled output (committed)
action.yml                 # Action metadata
gleam.toml
manifest.toml

The separation between feature.gleam (library), my_feature_js.gleam (CLI) and my_feature_action.gleam (action) is intentional. The CLI entry point lets you test your logic locally without the GitHub Actions environment. The action entry point wires everything through pontil’s logging and output commands.

The Bundler

pontil_build produces a single-file CommonJS bundle that Node can run directly. Unlike esgleam, there’s no build script to write — configuration lives in gleam.toml:

[tools.pontil_build.bundle]
entry = "my_feature_action.gleam"
# outdir = "dist"          # default
# minify = true            # default
# autoinstall = true       # default

Run it with gleam run -m pontil_build after building your project.

To install esbuild separately (useful for CI caching):

gleam run -m pontil_build/install
Using esgleam instead

If you prefer esgleam, replace pontil_build with esgleam in your dev-dependencies:

[dev-dependencies]
esgleam = ">= 1.0.0 and < 2.0.0"

Then create a dev/build_action.gleam script:

import esgleam

pub fn main() {
  let assert Ok(_) =
    esgleam.new("./dist")
    |> esgleam.entry("my_feature_action.gleam")
    |> esgleam.kind(esgleam.Script)
    |> esgleam.format(esgleam.Cjs)
    |> esgleam.autoinstall(True)
    |> esgleam.platform(esgleam.Node)
    |> esgleam.bundle()
}

Run it with gleam run -m build_action after building your project.

The Action Entry Point

This is the core of your action — the module that GitHub’s runner executes.

Minimal Synchronous Action

If your action doesn’t need to make HTTP requests or do other async work, it can be straightforward:

import pontil

pub fn main() -> Nil {
  let name = pontil.get_input("name")

  case validate(name) {
    Ok(result) -> {
      pontil.info("Success: " <> result)
      let assert Ok(_) = pontil.set_output("result", result)
      Nil
    }
    Error(msg) -> pontil.set_failed(msg)
  }
}

fn validate(name: String) -> Result(String, String) {
  case name {
    "" -> Error("name input is required")
    n -> Ok("Hello, " <> n <> "!")
  }
}

Action with Async Work

Most real actions need to make HTTP requests (to the GitHub API, for example). On the JavaScript target, HTTP is inherently async — you’ll be working with Promise values.

The key pattern: keep your synchronous logic synchronous, and only use promises at the boundaries where you actually need async. Call sync functions from within your async pipeline rather than wrapping everything in promises.

import gleam/javascript/promise.{type Promise}
import gleam/result
import pontil

pub fn main() -> Nil {
  // Register handlers so unhandled promise rejections don't silently fail
  pontil.register_default_process_handlers()

  // Start the async pipeline
  promise.map(run(), fn(res) {
    case res {
      Ok(Nil) -> pontil.info("Done.")
      Error(msg) -> pontil.set_failed(msg)
    }
    Nil
  })

  // main() returns immediately — Node keeps running until promises settle
  Nil
}

fn run() -> Promise(Result(Nil, String)) {
  // 1. Read config (sync — just reads env vars)
  use config <- pontil.try_promise(read_config())

  // 2. Fetch data (async — HTTP request)
  use data <- promise.try_await(fetch_data(config))

  // 3. Process results (sync — pure computation)
  use output <- pontil.try_promise(process(data))

  // 4. Write output (sync)
  use _ <- pontil.try_promise(pontil.set_output("result", output))

  promise.resolve(Ok(Nil))
}

pontil.try_promise is glue between sync and async for use. It lets you call synchronous functions that return Result inside a promise.try_await chain without wrapping them in promise.resolve at every call site.

Process Handlers

Node will silently swallow unhandled promise rejections unless you register handlers. Pontil provides this out of the box:

// Use the default handlers (logs via pontil.error, fails via pontil.set_failed)
pontil.register_default_process_handlers()

If you need custom handling (e.g., cleanup before failing), use the flexible variant:

pontil.register_process_handlers(
  exception: my_exception_handler,
  promise: my_rejection_handler,
)

Call either at the top of your main() before starting any async work.

Using Pontil

Reading Inputs

Inputs declared in action.yml are exposed as INPUT_<NAME> environment variables (upper cased, spaces replaced with underscores). Pontil handles this mapping:

// Simple read — returns "" if not set
let name = pontil.get_input("my_input")

// With options — returns Error if required and missing
let token = pontil.get_input_opts(name: "token", opts: [InputRequired])

// Boolean inputs (YAML 1.2 core schema: true/True/TRUE/false/False/FALSE)
let verbose = pontil.get_boolean_input("verbose")

// Multiline inputs — splits on newlines, trims each line
let items = pontil.get_multiline_input("items")

Logging

pontil.info("Informational message")     // Plain stdout
pontil.debug("Debug details")            // Only visible with ACTIONS_STEP_DEBUG
pontil.warning("Something looks off")    // Warning annotation
pontil.error("Something broke")          // Error annotation
pontil.set_failed("Fatal: shutting down") // Sets exit code to 1 + error annotation

For annotations with file/line context:

pontil.error_annotation(
  msg: "Lint failure",
  props: [
    pontil.File("src/main.gleam"),
    pontil.StartLine(42),
    pontil.Title("unused variable"),
  ],
)

Output Groups

Groups create collapsible sections in the Actions log. The basic group function wraps a synchronous callback:

// Synchronous — group boundaries are accurate
pontil.group("Setup", fn() {
  pontil.info("Configuring...")
  configure()
})

For async work, use group_start and group_end manually so the group boundaries actually bracket the async operation:

pontil.group_start("Fetch data")
use data <- promise.try_await(fetch_data(config))
pontil.group_end()
// ...continue pipeline

Or use group_async:

pontil.group_async("Fetch data", fn() {
  use data <- promise.try_await(fetch_data(config))
  // ...continue pipeline
})

If you use pontil.group with a callback that returns a Promise, the group will close immediately when the callback returns — before the promise resolves. The log output will be misleading.

Secrets

Mask sensitive values so they don’t appear in logs:

pontil.set_secret(token)

Once registered, the runner replaces any occurrence of the value with *** in subsequent log output.

Outputs and State

// Set an output for downstream steps
let assert Ok(_) = pontil.set_output("result", "some-value")

// Save state for post-job execution
let assert Ok(_) = pontil.save_state("cache_key", key)

// Read state (in post action)
let key = pontil.get_state("cache_key")

Environment Variables and PATH

// Export a variable for this and future steps
let assert Ok(_) = pontil.export_variable("MY_VAR", "value")

// Add to PATH for this and future steps
let assert Ok(_) = pontil.add_path("/usr/local/custom/bin")

Platform Detection

Platform detection is provided by the pontil_platform package:

gleam add pontil_platform@1
import pontil/platform

// Boolean helpers
let is_linux = platform.is_linux()

// Structured info: OS, arch, runtime, and version
let info = platform.details()

pontil_platform supports both Erlang and JavaScript targets and detects the runtime (Node, Deno, Bun, Erlang) in addition to OS and architecture.

Job Summaries

Job summaries require the pontil_summary package:

gleam add pontil_summary@1

The pontil/summary module provides a builder API for writing job summaries:

import pontil/summary

// Build and append a summary
summary.new()
|> summary.h2("Build Results")
|> summary.raw("All checks passed.")
|> summary.table(
  summary.new_table()
  |> summary.header_row(["Check", "Status", "Duration"])
  |> summary.row(["Lint", "✅", "12s"])
  |> summary.row(["Test", "✅", "45s"])
  |> summary.row(["Build", "✅", "30s"])
)
|> summary.append()

The builder supports headings (h1h6), code blocks, lists, tables, collapsible details, images, links, block quotes, and separators. Tables have a sub-builder with header_row, row, and cells (for column and row span control).

append adds to the existing summary; overwrite replaces it; clear empties it.

Error Handling

Define a unified error type for your action and convert pontil errors at the boundary:

import pontil
import pontil/errors

pub type MyError {
  ConfigError(message: String)
  ApiError(message: String)
  FileError(message: String)
}

fn resolve_config() -> Result(Config, MyError) {
  case pontil.get_input_opts(name: "token", opts: [pontil.InputRequired]) {
    Ok(t) -> {
      pontil.set_secret(t)
      Ok(Config(token: t))
    }
    Error(e) ->
      Error(ConfigError("Missing token: " <> pontil.describe_error(e)))
  }
}

pontil.describe_error converts a PontilError into a human-readable string.

Build Pipeline

A Justfile (or Makefile, or shell script) ties the build together:

_default:
    just --list

# Build and bundle the action
@build:
    gleam format
    gleam build
    gleam run -m pontil_build

If your project uses code generation (cog, squall, etc.), add those steps before gleam build:

@build:
    gleam run -m cog
    gleam format
    gleam build
    gleam run -m pontil_build

The workflow is: generate → format → compile → bundle. The bundled output in dist/ gets committed so the action is ready to run when cloned.

Local Testing

The just action pattern from starlist is worth stealing. It creates a scratch directory, copies in the bundle, and runs it with node:

action: build
    #!/usr/bin/env bash
    set -euo pipefail
    : "${INPUT_TOKEN:?Set INPUT_TOKEN}"
    export INPUT_TOKEN

    SCRATCH="scratch.$$"
    mkdir -p "$SCRATCH"
    trap 'echo "Output in $SCRATCH"' EXIT

    cp -r dist "$SCRATCH"
    cd "$SCRATCH"
    node dist/my_feature.js

Set inputs as INPUT_<NAME> environment variables:

INPUT_TOKEN=$(gh auth token) INPUT_CONFIG="inline toml" just action
Search Document