Best Practices for GitHub Actions

Writing reusable actions is a lot of work whether using pontil with Gleam or the GitHub Actions toolkit in JavaScript. What follows are some patterns and recommendations based on the actions that I’ve built.

State Your Identity

GitHub tells you which checkout of your commit is running in the log, but it’s still a good idea to state what version your action thinks is running. Add an info log with this information early in the execution.

import pontil

fn log_action_identity() -> Nil {
  let version = "1.2.0"
  let name = "my-org/my-action"

  let repo =
    pontil.env_get_nonempty("GITHUB_ACTION_REPOSITORY")
    |> option.unwrap(name)

  pontil.info(repo <> " " <> version)
}

This will produce something like:

▶ Run my-org/my-action@decafbad…
my-org/my-action 1.2.0

When something goes wrong in production, you’ll know exactly what’s deployed without digging through git history or action version pins.

Use Debug Logging Liberally

Debug messages are skipped by default; they only appear when a workflow is re-run with debug logging enabled. There’s a minimal performance cost and no log noise in normal operation, so there’s no reason to be stingy.

pontil.debug("Parsing " <> int.to_string(list.length(items)) <> " items")
pontil.debug("Config resolved: " <> string.inspect(config))
pontil.debug("API response status: " <> int.to_string(status))

Add debug logging at:

When there’s an accidentally quadratic parsing loop, it becomes much more obvious where to fix the issue if you have sufficient debug logging.

Mask Secrets Before Any Logging

Call pontil.set_secret as early as possible — before any code path that might log the value, even in debug output:

fn read_config() -> Result(Config, String) {
  use token <- result.try(
    pontil.get_input_opts(name: "token", opts: [pontil.InputRequired])
    |> result.map_error(pontil.describe_error),
  )
  pontil.set_secret(token)

  // Safe to log from here on — the token will be masked
  pontil.debug("Token length: " <> int.to_string(string.length(token)))
  Ok(Config(token:))
}

Fail Fast with Clear Messages

When something is unrecoverable, fail immediately with a message that tells the user what to fix. Don’t let the action continue in a degraded state that produces confusing failures later:

fn validate_config(config: Config) -> Result(Config, String) {
  case config.mode {
    "strict" | "lenient" -> Ok(config)
    other ->
      Error(
        "'mode' must be \"strict\" or \"lenient\", got \""
        <> other
        <> "\"",
      )
  }
}

For required inputs, use pontil.get_input_opts(name, [pontil.InputRequired]), as it already fails with a clear message. Reserve custom validation for constraints that input options can’t express: value formats, valid ranges, mutually exclusive flags, or cross-field dependencies.

Keep the Bundle Fresh in CI

Add a check to your CI workflow that verifies dist/ matches the current source. Stale bundles are a common source of “it works locally but not in CI” confusion:

- name: Check dist/ is up to date
  run: |
    gleam build
    gleam run -m pontil_build

    if (( "$(git diff --text dist/ | wc -l)" > 0 )); then
      echo "dist/ is out of date. Build locally and commit."
      git diff --text dist/
      exit 1
    fi
Search Document