diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,29 @@
+Copyright (C) 2024 Bellroy Pty Ltd
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+1. Redistributions of source code must retain the above copyright
+   notice, this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright
+   notice, this list of conditions and the following disclaimer in the
+   documentation and/or other materials provided with the
+   distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+   contributors may be used to endorse or promote products derived
+   from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,97 @@
+# Github Actions
+
+[![Haskell-CI](https://github.com/bellroy/github-actions/actions/workflows/haskell-ci.yml/badge.svg)](https://github.com/bellroy/github-actions/actions/workflows/haskell-ci.yml)
+
+This library provides types and instances for serializing and deserializing
+GitHub Actions YAML, so that workflows can be built and maintained in Haskell.
+
+As specified here:
+https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions
+
+## Usage Examples
+
+### 1. Exporting a Workflow to YAML
+
+You can create a workflow in Haskell and export it to YAML format:
+
+```haskell
+{-# LANGUAGE OverloadedStrings #-}
+
+import qualified Data.Yaml as Yaml
+import qualified Data.Map as Map
+import qualified Data.Set as Set
+import Data.List.NonEmpty (NonEmpty(..))
+import Language.Github.Actions.Workflow
+import qualified Language.Github.Actions.Job as Job
+import qualified Language.Github.Actions.Job.Id as JobId
+import qualified Language.Github.Actions.Step as Step
+import qualified Language.Github.Actions.Workflow.Trigger as Trigger
+
+-- Create a simple CI workflow
+myWorkflow :: Workflow
+myWorkflow = new
+  { workflowName = Just "CI"
+  , on = Set.singleton (Trigger.PushTrigger Trigger.pushTriggerDefaults)
+  , jobs = Map.singleton (JobId.JobId "build") buildJob
+  }
+
+buildJob :: Job.Job
+buildJob = Job.new
+  { Job.jobName = Just "Build and Test"
+  , Job.runsOn = Just "ubuntu-latest"
+  , Job.steps = Just $ checkoutStep :| [buildStep, testStep]
+  }
+
+checkoutStep :: Step.Step
+checkoutStep = Step.new
+  { Step.name = Just "Checkout repository"
+  , Step.uses = Just "actions/checkout@v4"
+  }
+
+buildStep :: Step.Step
+buildStep = Step.new
+  { Step.name = Just "Build project"
+  , Step.run = Just "npm install && npm run build"
+  }
+
+testStep :: Step.Step
+testStep = Step.new
+  { Step.name = Just "Run tests"
+  , Step.run = Just "npm test"
+  }
+
+-- Export to YAML
+exportWorkflow :: IO ()
+exportWorkflow = Yaml.encodeFile "workflow.yml" myWorkflow
+```
+
+### 2. Importing a YAML file into a Workflow representation
+
+You can load an existing GitHub Actions YAML file into a Haskell `Workflow` type:
+
+```haskell
+{-# LANGUAGE TypeApplications #-}
+
+import qualified Data.Yaml as Yaml
+import Language.Github.Actions.Workflow (Workflow)
+
+-- Import from YAML file
+importWorkflow :: FilePath -> IO (Either String Workflow)
+importWorkflow yamlFilePath = do
+  result <- Yaml.decodeFileEither @Workflow yamlFilePath
+  case result of
+    Left parseException ->
+      return $ Left $ Yaml.prettyPrintParseException parseException
+    Right workflow ->
+      return $ Right workflow
+
+-- Example usage
+main :: IO ()
+main = do
+  result <- importWorkflow ".github/workflows/ci.yml"
+  case result of
+    Left errorMsg -> putStrLn $ "Failed to parse workflow: " ++ errorMsg
+    Right workflow -> do
+      putStrLn "Successfully parsed workflow!"
+      print workflow
+```
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,3 @@
+import Distribution.Simple
+
+main = defaultMain
diff --git a/github-actions.cabal b/github-actions.cabal
new file mode 100644
--- /dev/null
+++ b/github-actions.cabal
@@ -0,0 +1,93 @@
+cabal-version:      2.2
+name:               github-actions
+version:            0.1.0.0
+synopsis:           Github Actions
+description:
+  This library provides types and instances for serializing and deserializing
+  Github Actions YAML, so that flows can be built and maintained in Haskell.
+
+homepage:           http://github.com/bellroy/github-actions
+bug-reports:        http://github.com/bellroy/github-actions/issues
+license:            BSD-3-Clause
+license-file:       LICENSE
+author:             Bellroy Tech Team <haskell@bellroy.com>
+maintainer:         Bellroy Tech Team <haskell@bellroy.com>
+copyright:          Copyright (C) 2025 Bellroy Pty Ltd
+category:           Language
+build-type:         Simple
+extra-source-files:
+  README.md
+  test/golden/configuration-main.golden.yml
+  test/golden/configuration-main.hs.txt
+  test/golden/configuration-main.yml
+
+tested-with:        GHC ==9.6.6 || ==9.8.2 || ==9.10.1
+
+common opts
+  default-language: Haskell2010
+  ghc-options:
+    -Wall -Wcompat -Widentities -Wincomplete-record-updates
+    -Wincomplete-uni-patterns -Werror=incomplete-patterns
+    -Wredundant-constraints -Wpartial-fields -Wtabs
+    -Wmissing-local-signatures -fhelpful-errors
+    -fprint-expanded-synonyms -fwarn-unused-do-bind
+
+common deps
+  build-depends:
+    , aeson               ^>=2.2.3.0
+    , base                >=4.14      && <4.22
+    , containers          ==0.6.7     || ==0.6.8 || ==0.7   || ==0.8
+    , hedgehog            ^>=1.5
+    , hoist-error         ^>=0.3
+    , string-interpolate  ^>=0.3.3
+    , text                ==1.2.4.1   || ==2.0.2 || ==2.1.1
+    , vector              ^>=0.13.0.0
+
+library
+  import:          opts, deps
+  hs-source-dirs:  src
+
+  -- cabal-fmt: expand src/
+  exposed-modules:
+    Language.Github.Actions.Concurrency
+    Language.Github.Actions.Defaults
+    Language.Github.Actions.Internal
+    Language.Github.Actions.Job
+    Language.Github.Actions.Job.Container
+    Language.Github.Actions.Job.Environment
+    Language.Github.Actions.Job.Id
+    Language.Github.Actions.Job.Strategy
+    Language.Github.Actions.Permissions
+    Language.Github.Actions.Service
+    Language.Github.Actions.Service.Id
+    Language.Github.Actions.Shell
+    Language.Github.Actions.Step
+    Language.Github.Actions.Step.Id
+    Language.Github.Actions.Step.With
+    Language.Github.Actions.Workflow
+    Language.Github.Actions.Workflow.Trigger
+
+test-suite spec
+  import:             deps, opts
+  type:               exitcode-stdio-1.0
+  hs-source-dirs:     test
+  main-is:            Main.hs
+  ghc-options:        -threaded
+  other-modules:      Language.Github.Actions.WorkflowTest
+  build-tool-depends: tasty-discover:tasty-discover ^>=4.2.2
+  build-depends:
+    , bytestring          ==0.11.5.3  || ==0.12.1.0
+    , filepath            ^>=1.4      || ^>=1.5
+    , github-actions
+    , pretty-show         ^>=1.10
+    , tasty               ^>=1.5
+    , tasty-discover      ^>=5.0.0
+    , tasty-golden        ^>=2.3.5
+    , tasty-golden-extra  ^>=0.1.0
+    , tasty-hedgehog      ^>=1.4.0.0
+    , tasty-hunit         ^>=0.10.0.3
+    , yaml                ^>=0.11.11
+
+source-repository head
+  type:     git
+  location: https://github.com/bellroy/github-actions.git
diff --git a/src/Language/Github/Actions/Concurrency.hs b/src/Language/Github/Actions/Concurrency.hs
new file mode 100644
--- /dev/null
+++ b/src/Language/Github/Actions/Concurrency.hs
@@ -0,0 +1,81 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE DerivingStrategies #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+
+-- |
+-- Module      : Language.Github.Actions.Concurrency
+-- Description : GitHub Actions concurrency settings
+-- Copyright   : (c) 2025 Bellroy Pty Ltd
+-- License     : BSD-3-Clause
+-- Maintainer  : Bellroy Tech Team <haskell@bellroy.com>
+--
+-- This module provides the 'Concurrency' type for controlling concurrent execution
+-- of GitHub Actions workflows and jobs.
+--
+-- Concurrency settings allow you to control how many workflow runs or job executions
+-- can happen simultaneously.
+--
+-- For more information about GitHub Actions concurrency, see:
+-- <https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#concurrency>
+module Language.Github.Actions.Concurrency
+  ( Concurrency (..),
+    gen,
+  )
+where
+
+import Data.Aeson (FromJSON, ToJSON (..), (.:?), (.=))
+import qualified Data.Aeson as Aeson
+import Data.Text (Text)
+import GHC.Generics (Generic)
+import Hedgehog (MonadGen)
+import qualified Hedgehog.Gen as Gen
+import qualified Hedgehog.Range as Range
+
+-- | Concurrency settings for workflows and jobs.
+--
+-- Concurrency allows you to control whether multiple workflow runs or job executions
+-- can happen simultaneously. This is useful for preventing conflicts when deploying
+-- or when you want to ensure only one workflow processes a particular resource at a time.
+--
+-- Example usage:
+--
+-- @
+-- import Language.Github.Actions.Concurrency
+--
+-- -- Only allow one deployment per branch
+-- deploymentConcurrency :: Concurrency
+-- deploymentConcurrency = Concurrency
+--  { group = Just "${{ github.ref }}"
+--  , cancelInProgress = Just True
+--  }
+-- @
+--
+-- For more details, see: <https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#concurrency>
+data Concurrency = Concurrency
+  { -- | Concurrency group identifier
+    group :: Maybe Text,
+    -- | Whether to cancel in-progress runs
+    cancelInProgress :: Maybe Bool
+  }
+  deriving stock (Eq, Generic, Ord, Show)
+
+instance FromJSON Concurrency where
+  parseJSON = Aeson.withObject "Concurrency" $ \o -> do
+    group <- o .:? "group"
+    cancelInProgress <- o .:? "cancel-in-progress"
+    pure Concurrency {..}
+
+instance ToJSON Concurrency where
+  toJSON Concurrency {..} =
+    Aeson.object
+      [ "group" .= group,
+        "cancel-in-progress" .= cancelInProgress
+      ]
+
+gen :: (MonadGen m) => m Concurrency
+gen = do
+  group <- Gen.maybe (Gen.text (Range.linear 1 5) Gen.alphaNum)
+  cancelInProgress <- Gen.maybe Gen.bool
+  pure Concurrency {..}
diff --git a/src/Language/Github/Actions/Defaults.hs b/src/Language/Github/Actions/Defaults.hs
new file mode 100644
--- /dev/null
+++ b/src/Language/Github/Actions/Defaults.hs
@@ -0,0 +1,97 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE DerivingStrategies #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+
+-- |
+-- Module      : Language.Github.Actions.Defaults
+-- Description : Default settings for GitHub Actions workflows and jobs
+-- Copyright   : (c) 2025 Bellroy Pty Ltd
+-- License     : BSD-3-Clause
+-- Maintainer  : Bellroy Tech Team <haskell@bellroy.com>
+--
+-- This module provides the 'Defaults' type for setting default values that apply
+-- to all steps within a job or all jobs within a workflow.
+--
+-- Defaults allow you to specify common settings like shell type and working directory
+-- that will be inherited by all steps unless explicitly overridden at the step level.
+--
+-- For more information about GitHub Actions defaults, see:
+-- <https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#defaults>
+module Language.Github.Actions.Defaults
+  ( Defaults (..),
+    gen,
+  )
+where
+
+import Data.Aeson (FromJSON, ToJSON, (.:), (.:?), (.=))
+import qualified Data.Aeson as Aeson
+import Data.Maybe (catMaybes)
+import Data.Text (Text)
+import GHC.Generics (Generic)
+import Hedgehog (MonadGen)
+import qualified Hedgehog.Gen as Gen
+import qualified Hedgehog.Range as Range
+import Language.Github.Actions.Shell (Shell)
+import qualified Language.Github.Actions.Shell as Shell
+
+-- | Default settings for steps within a job or jobs within a workflow.
+--
+-- Defaults provide a convenient way to set common configuration that applies to
+-- both Jobs and Workflows without having to repeat the same settings everywhere.
+--
+--
+-- Example usage:
+--
+-- @
+-- import Language.Github.Actions.Defaults
+-- import Language.Github.Actions.Shell
+--
+-- -- Set bash as default shell for all steps
+-- bashDefaults :: Defaults
+-- bashDefaults = Defaults
+--  { runShell = Just (Bash Nothing)
+--  , runWorkingDirectory = Nothing
+--  }
+--
+-- -- Set working directory for all steps
+-- workdirDefaults :: Defaults
+-- workdirDefaults = Defaults
+--  { runShell = Nothing
+--  , runWorkingDirectory = Just "/src"
+--  }
+-- @
+--
+-- For more details, see: <https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#defaults>
+data Defaults = Defaults
+  { -- | Default shell for run commands
+    runShell :: Maybe Shell,
+    -- | Default working directory for run commands
+    runWorkingDirectory :: Maybe Text
+  }
+  deriving stock (Eq, Generic, Ord, Show)
+
+instance FromJSON Defaults where
+  parseJSON = Aeson.withObject "Defaults" $ \o -> do
+    run <- o .: "run"
+    runShell <- run .:? "shell"
+    runWorkingDirectory <- run .:? "working-directory"
+    pure Defaults {..}
+
+instance ToJSON Defaults where
+  toJSON Defaults {..} =
+    Aeson.object
+      [ "run"
+          .= Aeson.object
+            ( catMaybes
+                [ ("shell" .=) <$> runShell,
+                  ("working-directory" .=) <$> runWorkingDirectory
+                ]
+            )
+      ]
+
+gen :: (MonadGen m) => m Defaults
+gen = do
+  runShell <- Gen.maybe Shell.gen
+  runWorkingDirectory <- Gen.maybe (Gen.text (Range.linear 1 5) Gen.alphaNum)
+  pure Defaults {..}
diff --git a/src/Language/Github/Actions/Internal.hs b/src/Language/Github/Actions/Internal.hs
new file mode 100644
--- /dev/null
+++ b/src/Language/Github/Actions/Internal.hs
@@ -0,0 +1,16 @@
+{-# LANGUAGE ScopedTypeVariables #-}
+
+module Language.Github.Actions.Internal (inverseMap) where
+
+import qualified Data.Map as Map
+
+-- | Based on: <https://hackage.haskell.org/package/relude-1.2.2.0/docs/src/Relude.Enum.html#inverseMap>
+inverseMap ::
+  forall a k.
+  (Bounded a, Enum a, Ord k) =>
+  (a -> k) ->
+  (k -> Maybe a)
+inverseMap f = (`Map.lookup` dict)
+  where
+    dict :: Map.Map k a
+    dict = Map.fromList ((\a -> (f a, a)) <$> [minBound .. maxBound])
diff --git a/src/Language/Github/Actions/Job.hs b/src/Language/Github/Actions/Job.hs
new file mode 100644
--- /dev/null
+++ b/src/Language/Github/Actions/Job.hs
@@ -0,0 +1,235 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE DerivingStrategies #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+
+-- |
+-- Module      : Language.Github.Actions.Job
+-- Description : GitHub Actions job definition and serialization
+-- Copyright   : (c) 2025 Bellroy Pty Ltd
+-- License     : BSD-3-Clause
+-- Maintainer  : Bellroy Tech Team <haskell@bellroy.com>
+--
+-- This module provides the 'Job' type for representing individual jobs within GitHub Actions workflows.
+-- Jobs are collections of steps that execute on the same runner.
+--
+-- A job defines the environment, dependencies, and steps that should be executed as part of a workflow.
+-- Jobs can run in parallel or be configured to depend on other jobs.
+--
+-- For more information about GitHub Actions job syntax, see:
+-- <https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobs>
+module Language.Github.Actions.Job
+  ( Job (..),
+    gen,
+    new,
+  )
+where
+
+import Data.Aeson (FromJSON, ToJSON (..), (.!=), (.:?), (.=))
+import qualified Data.Aeson as Aeson
+import Data.List.NonEmpty (NonEmpty (..))
+import Data.Map (Map)
+import Data.Maybe (catMaybes)
+import Data.Text (Text)
+import GHC.Generics (Generic)
+import Hedgehog (MonadGen)
+import qualified Hedgehog.Gen as Gen
+import qualified Hedgehog.Range as Range
+import Language.Github.Actions.Concurrency (Concurrency)
+import qualified Language.Github.Actions.Concurrency as Concurrency
+import Language.Github.Actions.Defaults (Defaults)
+import qualified Language.Github.Actions.Defaults as Defaults
+import Language.Github.Actions.Job.Container (JobContainer)
+import qualified Language.Github.Actions.Job.Container as JobContainer
+import Language.Github.Actions.Job.Environment (JobEnvironment)
+import qualified Language.Github.Actions.Job.Environment as JobEnvironment
+import Language.Github.Actions.Job.Id (JobId)
+import qualified Language.Github.Actions.Job.Id as JobId
+import Language.Github.Actions.Job.Strategy (JobStrategy)
+import qualified Language.Github.Actions.Job.Strategy as JobStrategy
+import Language.Github.Actions.Permissions (Permissions)
+import qualified Language.Github.Actions.Permissions as Permissions
+import Language.Github.Actions.Service (Service)
+import qualified Language.Github.Actions.Service as Service
+import Language.Github.Actions.Service.Id (ServiceId)
+import qualified Language.Github.Actions.Service.Id as ServiceId
+import Language.Github.Actions.Step (Step)
+import qualified Language.Github.Actions.Step as Step
+
+-- | A job within a GitHub Actions workflow.
+--
+-- A job is a set of steps that execute on the same runner. Jobs can run in parallel
+-- or sequentially depending on their dependencies. Each job runs in its own virtual
+-- environment specified by the runner.
+--
+-- Example usage:
+--
+-- @
+-- import Language.Github.Actions.Job
+-- import qualified Language.Github.Actions.Step as Step
+--
+-- myJob :: Job
+-- myJob = new
+--  { jobName = Just "Build and Test"
+--  , runsOn = Just "ubuntu-latest"
+--  , steps = Just $ Step.new :| []
+--  }
+-- @
+--
+-- For more details, see: <https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobs>
+data Job = Job
+  { -- | Concurrency settings for this job
+    concurrency :: Maybe Concurrency,
+    -- | Container to run the job in
+    container :: Maybe JobContainer,
+    -- | Whether to continue on step failure
+    continueOnError :: Maybe Bool,
+    -- | Default settings for steps in this job
+    defaults :: Maybe Defaults,
+    -- | Environment variables for this job
+    env :: Map Text Text,
+    -- | Deployment environment settings
+    environment :: Maybe JobEnvironment,
+    -- | Display name for the job
+    jobName :: Maybe Text,
+    -- | Jobs this job depends on
+    needs :: Maybe (NonEmpty JobId),
+    -- | Outputs from this job
+    outputs :: Map Text Text,
+    -- | Permissions for this job
+    permissions :: Maybe Permissions,
+    -- | Condition for running this job
+    runIf :: Maybe Text,
+    -- | Runner type (e.g., "ubuntu-latest")
+    runsOn :: Maybe Text,
+    -- | Secrets available to this job
+    secrets :: Map Text Text,
+    -- | Services to run alongside this job
+    services :: Map ServiceId Service,
+    -- | Steps to execute in this job
+    steps :: Maybe (NonEmpty Step),
+    -- | Matrix strategy for this job
+    strategy :: Maybe JobStrategy,
+    -- | Timeout for the job in minutes
+    timeoutMinutes :: Maybe Int,
+    -- | Reusable workflow to call
+    uses :: Maybe Text,
+    -- | Inputs for reusable workflows
+    with :: Map Text Text
+  }
+  deriving stock (Eq, Generic, Ord, Show)
+
+instance FromJSON Job where
+  parseJSON = Aeson.withObject "Job" $ \o -> do
+    concurrency <- o .:? "concurrency"
+    container <- o .:? "container"
+    continueOnError <- o .:? "continue-on-error"
+    defaults <- o .:? "defaults"
+    env <- o .:? "env" .!= mempty
+    environment <- o .:? "environment"
+    jobName <- o .:? "name"
+    needs <- o .:? "needs"
+    outputs <- o .:? "outputs" .!= mempty
+    permissions <- o .:? "permissions"
+    runIf <- o .:? "if"
+    runsOn <- o .:? "runs-on"
+    secrets <- o .:? "secrets" .!= mempty
+    services <- o .:? "services" .!= mempty
+    steps <- o .:? "steps"
+    strategy <- o .:? "strategy"
+    timeoutMinutes <- o .:? "timeout-minutes"
+    uses <- o .:? "uses"
+    with <- o .:? "with" .!= mempty
+    pure Job {..}
+
+instance ToJSON Job where
+  toJSON Job {..} =
+    Aeson.object $
+      catMaybes
+        [ ("concurrency" .=) <$> concurrency,
+          ("container" .=) <$> container,
+          ("continue-on-error" .=) <$> continueOnError,
+          ("defaults" .=) <$> defaults,
+          ("env" .=) <$> monoidToMaybe env,
+          ("environment" .=) <$> environment,
+          ("if" .=) <$> runIf,
+          ("name" .=) <$> jobName,
+          ("needs" .=) <$> needs,
+          ("outputs" .=) <$> monoidToMaybe outputs,
+          ("permissions" .=) <$> permissions,
+          ("runs-on" .=) <$> runsOn,
+          ("secrets" .=) <$> monoidToMaybe secrets,
+          ("services" .=) <$> monoidToMaybe services,
+          ("steps" .=) <$> steps,
+          ("strategy" .=) <$> strategy,
+          ("timeout-minutes" .=) <$> timeoutMinutes,
+          ("uses" .=) <$> uses,
+          ("with" .=) <$> monoidToMaybe with
+        ]
+    where
+      monoidToMaybe :: (Eq a, Monoid a) => a -> Maybe a
+      monoidToMaybe a = if a == mempty then Nothing else Just a
+
+gen :: (MonadGen m) => m Job
+gen = do
+  concurrency <- Gen.maybe Concurrency.gen
+  container <- Gen.maybe JobContainer.gen
+  continueOnError <- Gen.maybe Gen.bool
+  defaults <- Gen.maybe Defaults.gen
+  env <- genTextMap
+  environment <- Gen.maybe JobEnvironment.gen
+  jobName <- Gen.maybe genText
+  needs <- Gen.maybe (Gen.nonEmpty (Range.linear 1 5) JobId.gen)
+  outputs <- genTextMap
+  permissions <- Gen.maybe Permissions.gen
+  runIf <- Gen.maybe genText
+  runsOn <- Gen.maybe genText
+  secrets <- genTextMap
+  services <- Gen.map (Range.linear 1 5) $ liftA2 (,) ServiceId.gen Service.gen
+  steps <- Gen.maybe (Gen.nonEmpty (Range.linear 1 20) Step.gen)
+  strategy <- Gen.maybe JobStrategy.gen
+  timeoutMinutes <- Gen.maybe $ Gen.int (Range.linear 1 120)
+  uses <- Gen.maybe genText
+  with <- genTextMap
+  pure Job {..}
+  where
+    genText = Gen.text (Range.linear 1 5) Gen.alphaNum
+    genTextMap = Gen.map (Range.linear 1 5) $ liftA2 (,) genText genText
+
+-- | Create a new empty 'Job' with default values.
+--
+-- This provides a minimal job that can be extended with specific steps,
+-- runner configuration, and other settings.
+--
+-- Example:
+--
+-- @
+-- buildJob = new
+--   { jobName = Just "Build"
+--   , runsOn = Just "ubuntu-latest"
+--   , steps = Just $ checkoutStep :| [buildStep]
+--   }
+-- @
+new :: Job
+new =
+  Job
+    { concurrency = Nothing,
+      container = Nothing,
+      continueOnError = Nothing,
+      defaults = Nothing,
+      env = mempty,
+      environment = Nothing,
+      jobName = Nothing,
+      needs = Nothing,
+      outputs = mempty,
+      permissions = Nothing,
+      runIf = Nothing,
+      runsOn = Nothing,
+      secrets = mempty,
+      services = mempty,
+      steps = Nothing,
+      strategy = Nothing,
+      timeoutMinutes = Nothing,
+      uses = Nothing,
+      with = mempty
+    }
diff --git a/src/Language/Github/Actions/Job/Container.hs b/src/Language/Github/Actions/Job/Container.hs
new file mode 100644
--- /dev/null
+++ b/src/Language/Github/Actions/Job/Container.hs
@@ -0,0 +1,125 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE DerivingStrategies #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+
+-- |
+-- Module      : Language.Github.Actions.Job.Container
+-- Description : Container configuration for GitHub Actions jobs
+-- Copyright   : (c) 2025 Bellroy Pty Ltd
+-- License     : BSD-3-Clause
+-- Maintainer  : Bellroy Tech Team <haskell@bellroy.com>
+--
+-- This module provides the 'JobContainer' type for configuring Docker containers
+-- that jobs run inside of in GitHub Actions workflows.
+--
+-- Job containers allow you to run job steps inside a Docker container with a specific
+-- environment, dependencies, and configuration. This provides consistency across
+-- different runner environments and enables the use of custom tooling.
+--
+-- For more information about GitHub Actions job containers, see:
+-- <https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idcontainer>
+module Language.Github.Actions.Job.Container
+  ( JobContainer (..),
+    gen,
+  )
+where
+
+import Data.Aeson (FromJSON, ToJSON, (.:?), (.=))
+import qualified Data.Aeson as Aeson
+import Data.Map (Map)
+import Data.Maybe (catMaybes)
+import Data.Text (Text)
+import GHC.Generics (Generic)
+import Hedgehog (MonadGen)
+import qualified Hedgehog.Gen as Gen
+import qualified Hedgehog.Range as Range
+
+-- | Container configuration for running a job inside a Docker container.
+--
+-- Job containers provide an isolated, consistent environment for job execution.
+-- This is useful for ensuring specific tool versions, operating system environments,
+-- or complex dependency setups.
+--
+-- Example usage:
+--
+-- @
+-- import Language.Github.Actions.Job.Container
+-- import qualified Data.Map as Map
+--
+-- -- Node.js development container
+-- nodeContainer :: JobContainer
+-- nodeContainer = JobContainer
+--  { image = Just "node:18"
+--  , env = Just $ Map.fromList [("NODE_ENV", "test")]
+--  , credentials = Nothing
+--  , options = Nothing
+--  , ports = Nothing
+--  , volumes = Nothing
+--  }
+--
+-- -- Database testing container with services
+-- dbTestContainer :: JobContainer
+-- dbTestContainer = JobContainer
+--  { image = Just "ubuntu:22.04"
+--  , env = Just $ Map.fromList [("DEBIAN_FRONTEND", "noninteractive")]
+--  , credentials = Nothing
+--  , options = Just "--network postgres"
+--  , ports = Just ["8080:8080"]
+--  , volumes = Just ["\${{ github.workspace }}:\/workspace"]
+--  }
+-- @
+--
+-- For more details, see: <https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idcontainer>
+data JobContainer = JobContainer
+  { -- | Registry credentials for private images
+    credentials :: Maybe (Map Text Text),
+    -- | Environment variables for the container
+    env :: Maybe (Map Text Text),
+    -- | Docker image to use for the container
+    image :: Maybe Text,
+    -- | Additional Docker run options
+    options :: Maybe Text,
+    -- | Ports to expose from the container
+    ports :: Maybe [Text],
+    -- | Volumes to mount in the container
+    volumes :: Maybe [Text]
+  }
+  deriving stock (Eq, Generic, Ord, Show)
+
+instance FromJSON JobContainer where
+  parseJSON = Aeson.withObject "JobContainer" $ \o -> do
+    credentials <- o .:? "credentials"
+    env <- o .:? "env"
+    image <- o .:? "image"
+    options <- o .:? "options"
+    ports <- o .:? "ports"
+    volumes <- o .:? "volumes"
+    pure JobContainer {..}
+
+instance ToJSON JobContainer where
+  toJSON JobContainer {..} =
+    Aeson.object $
+      catMaybes
+        [ ("credentials" .=) <$> credentials,
+          ("env" .=) <$> env,
+          ("image" .=) <$> image,
+          ("options" .=) <$> options,
+          ("ports" .=) <$> ports,
+          ("volumes" .=) <$> volumes
+        ]
+
+gen :: (MonadGen m) => m JobContainer
+gen = do
+  credentials <- Gen.maybe genTextMap
+  env <- Gen.maybe genTextMap
+  image <- Gen.maybe genText
+  options <- Gen.maybe genText
+  ports <-
+    Gen.maybe . Gen.list (Range.linear 0 10) $
+      Gen.text (Range.linear 1 5) Gen.digit
+  volumes <- Gen.maybe $ Gen.list (Range.linear 0 10) genText
+  pure JobContainer {..}
+  where
+    genText = Gen.text (Range.linear 1 5) Gen.alphaNum
+    genTextMap = Gen.map (Range.linear 1 5) $ liftA2 (,) genText genText
diff --git a/src/Language/Github/Actions/Job/Environment.hs b/src/Language/Github/Actions/Job/Environment.hs
new file mode 100644
--- /dev/null
+++ b/src/Language/Github/Actions/Job/Environment.hs
@@ -0,0 +1,97 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE DerivingStrategies #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+-- |
+-- Module      : Language.Github.Actions.Job.Environment
+-- Description : Deployment environment configuration for GitHub Actions jobs
+-- Copyright   : (c) 2025 Bellroy Pty Ltd
+-- License     : BSD-3-Clause
+-- Maintainer  : Bellroy Tech Team <haskell@bellroy.com>
+--
+-- This module provides the 'JobEnvironment' type for configuring deployment
+-- environments that jobs target in GitHub Actions workflows.
+--
+-- Job environments allow you to:
+-- * Target specific deployment environments (production, staging, etc.)
+-- * Control access through environment protection rules
+-- * Use environment-specific secrets and variables
+-- * Track deployment history and status
+--
+-- For more information about GitHub Actions environments, see:
+-- <https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idenvironment>
+module Language.Github.Actions.Job.Environment
+  ( JobEnvironment (..),
+    gen,
+  )
+where
+
+import Data.Aeson (FromJSON, ToJSON)
+import qualified Data.Aeson as Aeson
+import Data.Map (Map)
+import Data.Text (Text)
+import GHC.Generics (Generic)
+import Hedgehog (MonadGen)
+import qualified Hedgehog.Gen as Gen
+import qualified Hedgehog.Range as Range
+
+-- | Environment configuration for deployment jobs.
+--
+-- Job environments can be specified in two ways:
+--
+-- * 'NamedJobEnvironment' - Reference a named environment by string
+-- * 'CustomJobEnvironment' - Configure environment with custom URL and other properties
+--
+-- Named environments are the most common and reference environments configured
+-- in your repository settings. Custom environments allow inline configuration
+-- for specific deployment scenarios.
+--
+-- Example usage:
+--
+-- @
+-- import Language.Github.Actions.Job.Environment
+-- import qualified Data.Map as Map
+--
+-- -- Reference a named environment
+-- prodEnvironment :: JobEnvironment
+-- prodEnvironment = NamedJobEnvironment "production"
+--
+-- stagingEnvironment :: JobEnvironment
+-- stagingEnvironment = NamedJobEnvironment "staging"
+--
+-- -- Custom environment with URL
+-- customEnvironment :: JobEnvironment
+-- customEnvironment = CustomJobEnvironment $ Map.fromList
+--  [ ("name", "review-pr-123")
+--  , ("url", "https:\/\/pr-123.preview.example.com")
+--  ]
+-- @
+--
+-- For more details, see: <https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idenvironment>
+data JobEnvironment
+  = -- | Reference a named environment
+    NamedJobEnvironment Text
+  | -- | Custom environment configuration
+    CustomJobEnvironment (Map Text Text)
+  deriving stock (Eq, Generic, Ord, Show)
+
+instance FromJSON JobEnvironment where
+  parseJSON v = case v of
+    Aeson.String s ->
+      pure $ NamedJobEnvironment s
+    Aeson.Object o -> CustomJobEnvironment <$> Aeson.parseJSON (Aeson.Object o)
+    _ -> fail "JobEnvironment must be a string or an object"
+
+instance ToJSON JobEnvironment where
+  toJSON (NamedJobEnvironment s) = Aeson.String s
+  toJSON (CustomJobEnvironment m) = Aeson.toJSON m
+
+gen :: (MonadGen m) => m JobEnvironment
+gen =
+  Gen.choice
+    [ NamedJobEnvironment <$> genText,
+      CustomJobEnvironment <$> genTextMap
+    ]
+  where
+    genText = Gen.text (Range.linear 1 5) Gen.alphaNum
+    genTextMap = Gen.map (Range.linear 1 5) $ liftA2 (,) genText genText
diff --git a/src/Language/Github/Actions/Job/Id.hs b/src/Language/Github/Actions/Job/Id.hs
new file mode 100644
--- /dev/null
+++ b/src/Language/Github/Actions/Job/Id.hs
@@ -0,0 +1,73 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE DerivingStrategies #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+
+-- |
+-- Module      : Language.Github.Actions.Job.Id
+-- Description : Job identifiers for GitHub Actions workflows
+-- Copyright   : (c) 2025 Bellroy Pty Ltd
+-- License     : BSD-3-Clause
+-- Maintainer  : Bellroy Tech Team <haskell@bellroy.com>
+--
+-- This module provides the 'JobId' type for uniquely identifying jobs within
+-- GitHub Actions workflows.
+--
+-- Job IDs are used to reference jobs in dependency declarations (needs), outputs,
+-- and other cross-job references. They must be unique within a workflow and follow
+-- specific naming conventions.
+--
+-- For more information about GitHub Actions job IDs, see:
+-- <https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_id>
+module Language.Github.Actions.Job.Id
+  ( JobId (..),
+    gen,
+    render,
+  )
+where
+
+import Data.Aeson (FromJSON, FromJSONKey, ToJSON (..), ToJSONKey)
+import Data.Text (Text)
+import GHC.Generics (Generic)
+import Hedgehog (MonadGen)
+import qualified Hedgehog.Gen as Gen
+import qualified Hedgehog.Range as Range
+
+-- | A unique identifier for a job within a GitHub Actions workflow.
+--
+-- Job IDs are used to:
+-- * Reference jobs in dependency declarations (needs)
+-- * Access job outputs from other jobs
+-- * Identify jobs in workflow run logs and API responses
+--
+-- Job IDs must be unique within a workflow and should follow these conventions:
+-- * Start with a letter or underscore
+-- * Contain only alphanumeric characters, hyphens, and underscores
+-- * Be descriptive of the job's purpose
+--
+-- Example usage:
+--
+-- @
+-- import Language.Github.Actions.Job.Id
+--
+-- -- Simple job IDs
+-- buildJobId :: JobId
+-- buildJobId = JobId "build"
+--
+-- testJobId :: JobId
+-- testJobId = JobId "test"
+--
+-- -- More descriptive job IDs
+-- deployProdJobId :: JobId
+-- deployProdJobId = JobId "deploy-production"
+-- @
+--
+-- For more details, see: <https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_id>
+newtype JobId = JobId Text
+  deriving stock (Eq, Generic, Ord, Show)
+  deriving newtype (FromJSON, FromJSONKey, ToJSON, ToJSONKey)
+
+gen :: (MonadGen m) => m JobId
+gen = JobId <$> Gen.text (Range.linear 1 5) Gen.alphaNum
+
+render :: JobId -> Text
+render (JobId t) = t
diff --git a/src/Language/Github/Actions/Job/Strategy.hs b/src/Language/Github/Actions/Job/Strategy.hs
new file mode 100644
--- /dev/null
+++ b/src/Language/Github/Actions/Job/Strategy.hs
@@ -0,0 +1,158 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE DerivingStrategies #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+
+-- |
+-- Module      : Language.Github.Actions.Job.Strategy
+-- Description : Matrix strategies for GitHub Actions jobs
+-- Copyright   : (c) 2025 Bellroy Pty Ltd
+-- License     : BSD-3-Clause
+-- Maintainer  : Bellroy Tech Team <haskell@bellroy.com>
+--
+-- This module provides the 'JobStrategy' type for configuring matrix strategies
+-- that allow jobs to run multiple times with different configurations.
+--
+-- Matrix strategies are useful for testing across multiple:
+-- * Operating systems (Ubuntu, Windows, macOS)
+-- * Language versions (Node 16, 18, 20)
+-- * Database versions (PostgreSQL 12, 13, 14)
+-- * Or any other configurable parameters
+--
+-- For more information about GitHub Actions matrix strategies, see:
+-- <https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/running-variations-of-jobs-in-a-workflow>
+module Language.Github.Actions.Job.Strategy
+  ( JobStrategy (..),
+    gen,
+  )
+where
+
+import Data.Aeson (FromJSON, ToJSON, (.!=), (.:?), (.=))
+import qualified Data.Aeson as Aeson
+import qualified Data.Aeson.Types as Aeson
+import Data.Map (Map)
+import qualified Data.Map as Map
+import Data.Maybe (catMaybes)
+import Data.String (fromString)
+import Data.Text (Text)
+import qualified Data.Text as Text
+import GHC.Generics (Generic)
+import Hedgehog (MonadGen)
+import qualified Hedgehog.Gen as Gen
+import qualified Hedgehog.Range as Range
+
+-- | Matrix strategy configuration for running job variations.
+--
+-- Matrix strategies define how to run a job multiple times with different
+-- configurations. The matrix creates a cross-product of all variable combinations,
+-- with options to include additional combinations or exclude specific ones.
+--
+-- Example usage:
+--
+-- @
+-- import Language.Github.Actions.Job.Strategy
+-- import qualified Data.Map as Map
+-- import qualified Data.Aeson as Aeson
+--
+-- -- Simple OS and Node version matrix
+-- nodeMatrix :: JobStrategy
+-- nodeMatrix = JobStrategy
+--  { exclude = Nothing
+--  , failFast = Just False
+--  , include = Nothing
+--  , maxParallel = Just 3
+--  , otherVariables = Just $ Map.fromList
+--      [ ("os", Aeson.toJSON ["ubuntu-latest", "windows-latest", "macos-latest"])
+--      , ("node-version", Aeson.toJSON ["16", "18", "20"])
+--      ]
+--  }
+--
+-- -- Matrix with exclusions and includes
+-- complexMatrix :: JobStrategy
+-- complexMatrix = JobStrategy
+--  { exclude = Just ["{ \"os\": \"windows-latest\", \"node-version\": \"16\" }"]
+--  , failFast = Just True
+--  , include = Just ["{ \"os\": \"ubuntu-latest\", \"node-version\": \"21\", \"experimental\": true }"]
+--  , maxParallel = Nothing
+--  , otherVariables = Just $ Map.fromList
+--      [ ("os", Aeson.toJSON ["ubuntu-latest", "windows-latest"])
+--      , ("node-version", Aeson.toJSON ["18", "20"])
+--      ]
+--  }
+-- @
+--
+-- For more details, see: <https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/running-variations-of-jobs-in-a-workflow>
+data JobStrategy = JobStrategy
+  { -- | Matrix combinations to exclude
+    exclude :: Maybe [Text],
+    -- | Whether to cancel all jobs when one fails
+    failFast :: Maybe Bool,
+    -- | Additional matrix combinations to include
+    include :: Maybe [Text],
+    -- | Maximum number of parallel jobs
+    maxParallel :: Maybe Int,
+    -- | Matrix variables (os, version, etc.)
+    otherVariables :: Maybe (Map Text Aeson.Value)
+  }
+  deriving stock (Eq, Generic, Ord, Show)
+
+instance FromJSON JobStrategy where
+  parseJSON = Aeson.withObject "JobStrategy" $ \o -> do
+    matrix <- o .:? "matrix" .!= mempty
+    exclude <- matrix .:? "exclude"
+    failFast <- o .:? "fail-fast"
+    include <- matrix .:? "include"
+    maxParallel <- o .:? "max-parallel"
+    rawMatrix :: Map Text Aeson.Value <- o .:? "matrix" .!= mempty
+    let excludeKey :: Text = "exclude"
+        includeKey :: Text = "include"
+        filteredRawMatrix =
+          Map.filterWithKey
+            (\k _ -> k /= excludeKey && k /= includeKey)
+            rawMatrix
+        otherVariables =
+          if null filteredRawMatrix
+            then Nothing
+            else Just filteredRawMatrix
+    pure JobStrategy {..}
+
+instance ToJSON JobStrategy where
+  toJSON JobStrategy {..} =
+    Aeson.object $
+      catMaybes
+        [ ("fail-fast" .=) <$> failFast,
+          ("matrix" .=) <$> maybeMatrixObject,
+          ("max-parallel" .=) <$> maxParallel
+        ]
+    where
+      maybeMatrixObject :: Maybe Aeson.Value
+      maybeMatrixObject =
+        let pairs =
+              maybe [] otherVariableMapToAesonPair otherVariables
+                ++ catMaybes
+                  [ ("exclude" .=) <$> exclude,
+                    ("include" .=) <$> include
+                  ]
+         in if null pairs
+              then Nothing
+              else Just $ Aeson.object pairs
+
+      otherVariableMapToAesonPair :: Map Text Aeson.Value -> [Aeson.Pair]
+      otherVariableMapToAesonPair =
+        Map.foldMapWithKey
+          ( \k v ->
+              [fromString (Text.unpack k) .= v]
+          )
+
+gen :: (MonadGen m) => m JobStrategy
+gen = do
+  exclude <- Gen.maybe $ Gen.list (Range.linear 1 3) genText
+  failFast <- Gen.maybe Gen.bool
+  include <- Gen.maybe $ Gen.list (Range.linear 1 3) genText
+  maxParallel <- Gen.maybe $ Gen.int (Range.linear 1 10)
+  otherVariables <- Gen.maybe $ (fmap . fmap) Aeson.String genTextMap
+  pure JobStrategy {..}
+  where
+    genText = Gen.text (Range.linear 1 5) Gen.alphaNum
+    genTextMap = Gen.map (Range.linear 1 5) $ liftA2 (,) genText genText
diff --git a/src/Language/Github/Actions/Permissions.hs b/src/Language/Github/Actions/Permissions.hs
new file mode 100644
--- /dev/null
+++ b/src/Language/Github/Actions/Permissions.hs
@@ -0,0 +1,202 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE DerivingStrategies #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE QuasiQuotes #-}
+
+-- |
+-- Module      : Language.Github.Actions.Permissions
+-- Description : GitHub Actions permissions and access control
+-- Copyright   : (c) 2025 Bellroy Pty Ltd
+-- License     : BSD-3-Clause
+-- Maintainer  : Bellroy Tech Team <haskell@bellroy.com>
+--
+-- This module provides types for managing GitHub Actions permissions and access control.
+-- Permissions control what GitHub APIs and resources workflows and jobs can access.
+--
+-- You can set permissions at the workflow level (affecting all jobs) or at individual
+-- job levels. This follows the principle of least privilege by allowing you to grant
+-- only the specific permissions needed.
+--
+-- For more information about GitHub Actions permissions, see:
+-- <https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#permissions>
+module Language.Github.Actions.Permissions
+  ( Permissions (..),
+    PermissionType (..),
+    Permission (..),
+    gen,
+  )
+where
+
+import Control.Monad.Fail.Hoist (hoistFail')
+import Data.Aeson (FromJSON, FromJSONKey, ToJSON, ToJSONKey)
+import qualified Data.Aeson as Aeson
+import qualified Data.Aeson.Types as Aeson
+import Data.Map (Map)
+import Data.String.Interpolate (i)
+import Data.Text (Text)
+import GHC.Generics (Generic)
+import Hedgehog (MonadGen)
+import qualified Hedgehog.Gen as Gen
+import qualified Hedgehog.Range as Range
+import Language.Github.Actions.Internal (inverseMap)
+
+-- | Types of permissions that can be granted to GitHub Actions workflows.
+--
+-- Each permission type corresponds to a specific area of GitHub functionality
+-- that workflows might need to access.
+--
+-- For more details about each permission type, see: <https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token>
+data PermissionType
+  = -- | Manage GitHub Actions (e.g., cancel workflow runs)
+    Actions
+  | -- | Create and verify attestations
+    Attestations
+  | -- | Create and update check runs and suites
+    Checks
+  | -- | Read and write repository contents
+    Contents
+  | -- | Create and manage deployments
+    Deployments
+  | -- | Request OIDC JWT ID tokens
+    IdToken
+  | -- | Create and manage issues
+    Issues
+  | -- | Create and manage discussions
+    Discussions
+  | -- | Publish and manage packages
+    Packages
+  | -- | Deploy to GitHub Pages
+    Pages
+  | -- | Create and manage pull requests
+    PullRequests
+  | -- | Manage repository projects
+    RepositoryProjects
+  | -- | Read and write security events
+    SecurityEvents
+  | -- | Create commit status checks
+    Statuses
+  deriving stock (Bounded, Enum, Eq, Generic, Ord, Show)
+
+instance FromJSON PermissionType where
+  parseJSON =
+    Aeson.withText "PermissionType" $
+      hoistFail' . parsePermissionType
+
+instance FromJSONKey PermissionType where
+  fromJSONKey = Aeson.FromJSONKeyTextParser (Aeson.parseJSON . Aeson.String)
+
+instance ToJSON PermissionType where
+  toJSON = Aeson.String . renderPermissionType
+
+instance ToJSONKey PermissionType where
+  toJSONKey = Aeson.toJSONKeyText renderPermissionType
+
+renderPermissionType :: PermissionType -> Text
+renderPermissionType = \case
+  Actions -> "actions"
+  Attestations -> "attestations"
+  Checks -> "checks"
+  Contents -> "contents"
+  Deployments -> "deployments"
+  IdToken -> "id-token"
+  Issues -> "issues"
+  Discussions -> "discussions"
+  Packages -> "packages"
+  Pages -> "pages"
+  PullRequests -> "pull-requests"
+  RepositoryProjects -> "repository-projects"
+  SecurityEvents -> "security-events"
+  Statuses -> "statuses"
+
+parsePermissionType :: Text -> Either String PermissionType
+parsePermissionType t =
+  maybe (Left [i|Unknown PermissionType: #{t}|]) Right $
+    inverseMap renderPermissionType t
+
+-- | Permission levels that can be granted for each permission type.
+data Permission
+  = -- | No access granted
+    None
+  | -- | Read-only access
+    Read
+  | -- | Read and write access
+    Write
+  deriving stock (Bounded, Enum, Eq, Generic, Ord, Show)
+
+instance FromJSON Permission where
+  parseJSON =
+    Aeson.withText "Permission" $
+      hoistFail' . parsePermission
+
+instance ToJSON Permission where
+  toJSON = Aeson.String . renderPermission
+
+renderPermission :: Permission -> Text
+renderPermission = \case
+  None -> "none"
+  Read -> "read"
+  Write -> "write"
+
+parsePermission :: Text -> Either String Permission
+parsePermission t =
+  maybe (Left [i|Unknown Permission: #{t}|]) Right $
+    inverseMap renderPermission t
+
+-- | Overall permissions configuration for a workflow or job.
+--
+-- Permissions can be set globally (affecting all permission types) or
+-- individually for specific permission types.
+--
+-- Example usage:
+--
+-- @
+-- import Language.Github.Actions.Permissions
+--
+-- -- Grant read access to everything
+-- readOnlyPerms :: Permissions
+-- readOnlyPerms = ReadAll
+--
+-- -- Grant specific permissions only
+-- customPerms :: Permissions
+-- customPerms = Custom $ Map.fromList
+--  [ (Contents, Read)
+--  , (PullRequests, Write)
+--  ]
+-- @
+data Permissions
+  = -- | No permissions granted (empty object)
+    NoPermissions
+  | -- | Read access to all permission types
+    ReadAll
+  | -- | Write access to all permission types
+    WriteAll
+  | -- | Custom permission mapping
+    Custom (Map PermissionType Permission)
+  deriving stock (Eq, Generic, Ord, Show)
+
+instance FromJSON Permissions where
+  parseJSON v = case v of
+    Aeson.String "{}" -> pure NoPermissions
+    Aeson.String "read-all" -> pure ReadAll
+    Aeson.String "write-all" -> pure WriteAll
+    Aeson.String s -> fail [i|Invalid Permissions: #{s}|]
+    Aeson.Object o -> Custom <$> Aeson.parseJSON (Aeson.Object o)
+    _ -> fail "Permissions must be a string or an object"
+
+instance ToJSON Permissions where
+  toJSON = \case
+    NoPermissions -> Aeson.String "{}"
+    ReadAll -> Aeson.String "read-all"
+    WriteAll -> Aeson.String "write-all"
+    Custom m -> Aeson.toJSON m
+
+gen :: (MonadGen m) => m Permissions
+gen =
+  Gen.choice
+    [ pure NoPermissions,
+      pure ReadAll,
+      pure WriteAll,
+      fmap Custom . Gen.map (Range.linear 1 5) $
+        liftA2 (,) Gen.enumBounded Gen.enumBounded
+    ]
diff --git a/src/Language/Github/Actions/Service.hs b/src/Language/Github/Actions/Service.hs
new file mode 100644
--- /dev/null
+++ b/src/Language/Github/Actions/Service.hs
@@ -0,0 +1,147 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE DerivingStrategies #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+
+-- |
+-- Module      : Language.Github.Actions.Service
+-- Description : GitHub Actions service containers for jobs
+-- Copyright   : (c) 2025 Bellroy Pty Ltd
+-- License     : BSD-3-Clause
+-- Maintainer  : Bellroy Tech Team <haskell@bellroy.com>
+--
+-- This module provides the 'Service' type for defining service containers that run
+-- alongside job steps in GitHub Actions workflows.
+--
+-- Service containers are Docker containers that provide services like databases,
+-- message queues, or other dependencies that your job steps might need during execution.
+-- They run in parallel with your job and are accessible via hostname.
+--
+-- For more information about GitHub Actions service containers, see:
+-- <https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idservices>
+module Language.Github.Actions.Service
+  ( Service (..),
+    gen,
+    new,
+  )
+where
+
+import Data.Aeson (FromJSON, ToJSON (..), (.:?), (.=))
+import qualified Data.Aeson as Aeson
+import Data.Map (Map)
+import Data.Maybe (catMaybes)
+import Data.Text (Text)
+import GHC.Generics (Generic)
+import Hedgehog (MonadGen)
+import qualified Hedgehog.Gen as Gen
+import qualified Hedgehog.Range as Range
+
+-- | A service container definition for GitHub Actions jobs.
+--
+-- Service containers run Docker images that provide services your job steps can use.
+-- Common examples include databases, caches, and message queues.
+--
+-- Service containers are automatically started before job steps run and stopped after
+-- the job completes. They can be accessed by job steps using their service ID as hostname.
+--
+-- Example usage:
+--
+-- @
+-- import Language.Github.Actions.Service
+-- import qualified Data.Map as Map
+--
+-- -- PostgreSQL database service
+-- postgresService :: Service
+-- postgresService = new
+--  { image = Just "postgres:13"
+--  , env = Just $ Map.fromList
+--      [ ("POSTGRES_PASSWORD", "postgres")
+--      , ("POSTGRES_DB", "testdb")
+--      ]
+--  , ports = Just ["5432:5432"]
+--  }
+--
+-- -- Redis cache service
+-- redisService :: Service
+-- redisService = new
+--  { image = Just "redis:6-alpine"
+--  , ports = Just ["6379:6379"]
+--  }
+-- @
+--
+-- For more details, see: <https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idservices>
+data Service = Service
+  { -- | Registry credentials for private images
+    credentials :: Maybe (Map Text Text),
+    -- | Environment variables for the service
+    env :: Maybe (Map Text Text),
+    -- | Docker image to use for the service
+    image :: Maybe Text,
+    -- | Additional Docker options
+    options :: Maybe Text,
+    -- | Ports to expose from the service
+    ports :: Maybe [Text],
+    -- | Volumes to mount in the service
+    volumes :: Maybe [Text]
+  }
+  deriving stock (Eq, Generic, Ord, Show)
+
+instance FromJSON Service where
+  parseJSON = Aeson.withObject "Service" $ \o -> do
+    credentials <- o .:? "credentials"
+    env <- o .:? "env"
+    image <- o .:? "image"
+    options <- o .:? "options"
+    ports <- o .:? "ports"
+    volumes <- o .:? "volumes"
+    pure Service {..}
+
+instance ToJSON Service where
+  toJSON Service {..} =
+    Aeson.object $
+      catMaybes
+        [ ("credentials" .=) <$> credentials,
+          ("env" .=) <$> env,
+          ("image" .=) <$> image,
+          ("options" .=) <$> options,
+          ("ports" .=) <$> ports,
+          ("volumes" .=) <$> volumes
+        ]
+
+gen :: (MonadGen m) => m Service
+gen = do
+  credentials <- Gen.maybe genTextMap
+  env <- Gen.maybe genTextMap
+  image <- Gen.maybe genText
+  options <- Gen.maybe genText
+  ports <- Gen.maybe . Gen.list (Range.linear 1 3) $ Gen.text (Range.linear 1 5) Gen.digit
+  volumes <- Gen.maybe . Gen.list (Range.linear 1 3) $ genText
+  pure Service {..}
+  where
+    genText = Gen.text (Range.linear 1 5) Gen.alphaNum
+    genTextMap = Gen.map (Range.linear 1 5) $ liftA2 (,) genText genText
+
+-- | Create a new empty 'Service' with default values.
+--
+-- This provides a minimal service definition that can be extended with specific
+-- image, ports, environment variables, and other configuration.
+--
+-- Example:
+--
+-- @
+-- databaseService = new
+--   { image = Just "postgres:13"
+--   , env = Just $ Map.singleton "POSTGRES_PASSWORD" "secret"
+--   , ports = Just ["5432:5432"]
+--   }
+-- @
+new :: Service
+new =
+  Service
+    { credentials = Nothing,
+      env = Nothing,
+      image = Nothing,
+      options = Nothing,
+      ports = Nothing,
+      volumes = Nothing
+    }
diff --git a/src/Language/Github/Actions/Service/Id.hs b/src/Language/Github/Actions/Service/Id.hs
new file mode 100644
--- /dev/null
+++ b/src/Language/Github/Actions/Service/Id.hs
@@ -0,0 +1,72 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE DerivingStrategies #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+
+-- |
+-- Module      : Language.Github.Actions.Service.Id
+-- Description : Service identifiers for GitHub Actions workflows
+-- Copyright   : (c) 2025 Bellroy Pty Ltd
+-- License     : BSD-3-Clause
+-- Maintainer  : Bellroy Tech Team <haskell@bellroy.com>
+--
+-- This module provides the 'ServiceId' type for uniquely identifying service
+-- containers within GitHub Actions jobs.
+--
+-- Service IDs are used to reference service containers from job steps via hostname.
+-- They must be unique within a job and follow specific naming conventions.
+--
+-- For more information about GitHub Actions service containers, see:
+-- <https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idservices>
+module Language.Github.Actions.Service.Id
+  ( ServiceId (..),
+    gen,
+    render,
+  )
+where
+
+import Data.Aeson (FromJSON, FromJSONKey, ToJSON (..), ToJSONKey)
+import Data.Text (Text)
+import GHC.Generics (Generic)
+import Hedgehog (MonadGen)
+import qualified Hedgehog.Gen as Gen
+import qualified Hedgehog.Range as Range
+
+-- | A unique identifier for a service container within a GitHub Actions job.
+--
+-- Service IDs are used to:
+-- * Reference the service container from job steps using the service ID as hostname
+-- * Configure service-specific settings like ports, environment variables, and health checks
+-- * Identify services in workflow run logs and container networks
+--
+-- Service IDs must be unique within a job and should follow these conventions:
+-- * Start with a letter or underscore
+-- * Contain only alphanumeric characters, hyphens, and underscores
+-- * Be descriptive of the service's purpose
+--
+-- Example usage:
+--
+-- @
+-- import Language.Github.Actions.Service.Id
+--
+-- -- Database service IDs
+-- postgresServiceId :: ServiceId
+-- postgresServiceId = ServiceId "postgres"
+--
+-- redisServiceId :: ServiceId
+-- redisServiceId = ServiceId "redis"
+--
+-- -- More descriptive service IDs
+-- testDatabaseServiceId :: ServiceId
+-- testDatabaseServiceId = ServiceId "test-database"
+-- @
+--
+-- For more details, see: <https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idservices>
+newtype ServiceId = ServiceId Text
+  deriving stock (Eq, Generic, Ord, Show)
+  deriving newtype (FromJSON, FromJSONKey, ToJSON, ToJSONKey)
+
+gen :: (MonadGen m) => m ServiceId
+gen = ServiceId <$> Gen.text (Range.linear 1 5) Gen.alphaNum
+
+render :: ServiceId -> Text
+render (ServiceId t) = t
diff --git a/src/Language/Github/Actions/Shell.hs b/src/Language/Github/Actions/Shell.hs
new file mode 100644
--- /dev/null
+++ b/src/Language/Github/Actions/Shell.hs
@@ -0,0 +1,158 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE DerivingStrategies #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE QuasiQuotes #-}
+{-# LANGUAGE RankNTypes #-}
+
+-- |
+-- Module      : Language.Github.Actions.Shell
+-- Description : GitHub Actions shell configuration for steps
+-- Copyright   : (c) 2025 Bellroy Pty Ltd
+-- License     : BSD-3-Clause
+-- Maintainer  : Bellroy Tech Team <haskell@bellroy.com>
+--
+-- This module provides the 'Shell' type for specifying which shell to use when
+-- running commands in GitHub Actions steps.
+--
+-- Different shells provide different capabilities and are available on different
+-- operating systems. The shell can be specified at the workflow, job, or step level.
+--
+-- For more information about GitHub Actions shell configuration, see:
+-- <https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsshell>
+module Language.Github.Actions.Shell
+  ( Platform (..),
+    Shell (..),
+    arguments,
+    gen,
+    supportedPlatforms,
+  )
+where
+
+import Control.Monad.Fail.Hoist (hoistFail')
+import Data.Aeson (FromJSON, ToJSON)
+import qualified Data.Aeson as Aeson
+import Data.List.NonEmpty (NonEmpty (..))
+import Data.String.Interpolate (i)
+import Data.Text (Text)
+import qualified Data.Text as Text
+import GHC.Generics (Generic)
+import Hedgehog (MonadGen)
+import qualified Hedgehog.Gen as Gen
+import qualified Hedgehog.Range as Range
+
+-- | Shell types available for running commands in GitHub Actions steps.
+--
+-- Each shell has different capabilities and platform availability:
+--
+-- * 'Bash' - Available on all platforms, most commonly used
+-- * 'Sh' - POSIX sh, available on Linux and macOS only
+-- * 'Python' - Executes commands as Python code
+-- * 'Cmd' - Windows Command Prompt, Windows only
+-- * 'Powershell' - Windows PowerShell 5.1, Windows only
+-- * 'Pwsh' - PowerShell Core, available on all platforms
+--
+-- Example usage:
+--
+-- @
+-- import Language.Github.Actions.Shell
+--
+-- -- Use bash with default options
+-- bashShell :: Shell
+-- bashShell = Bash Nothing
+--
+-- -- Use bash with specific options
+-- bashWithOptions :: Shell
+-- bashWithOptions = Bash (Just "--noprofile --norc")
+--
+-- -- Use PowerShell Core
+-- pwshShell :: Shell
+-- pwshShell = Pwsh Nothing
+-- @
+--
+-- For more details, see: <https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsshell>
+data Shell
+  = -- | Bash shell with optional arguments
+    Bash (Maybe Text)
+  | -- | POSIX sh shell (Linux/macOS only) with optional arguments
+    Sh (Maybe Text)
+  | -- | Python interpreter with optional arguments
+    Python (Maybe Text)
+  | -- | Windows cmd.exe with optional arguments
+    Cmd (Maybe Text)
+  | -- | Windows PowerShell 5.1 with optional arguments
+    Powershell (Maybe Text)
+  | -- | PowerShell Core with optional arguments
+    Pwsh (Maybe Text)
+  deriving stock (Eq, Generic, Ord, Show)
+
+instance FromJSON Shell where
+  parseJSON =
+    Aeson.withText "Shell" $
+      hoistFail' . parseShell
+
+instance ToJSON Shell where
+  toJSON = Aeson.String . renderShell
+
+renderShell :: Shell -> Text
+renderShell = \case
+  Bash args -> [i|bash#{maybe "" ((" " <>)) args}|]
+  Sh args -> [i|sh#{maybe "" ((" " <>)) args}|]
+  Python args -> [i|python#{maybe "" ((" " <>)) args}|]
+  Cmd args -> [i|cmd#{maybe "" ((" " <>)) args}|]
+  Powershell args -> [i|powershell#{maybe "" ((" " <>)) args}|]
+  Pwsh args -> [i|pwsh#{maybe "" ((" " <>)) args}|]
+
+parseShell :: Text -> Either String Shell
+parseShell t =
+  case tokens of
+    "bash" : args -> Right . Bash $ maybeArgs args
+    "cmd" : args -> Right . Cmd $ maybeArgs args
+    "powershell" : args -> Right . Powershell $ maybeArgs args
+    "pwsh" : args -> Right . Pwsh $ maybeArgs args
+    "python" : args -> Right . Python $ maybeArgs args
+    "sh" : args -> Right . Sh $ maybeArgs args
+    _ -> Left [i|Unknown shell: #{t}|]
+  where
+    tokens = Text.words t
+    maybeArgs = \case
+      [] -> Nothing
+      args -> Just $ Text.unwords args
+
+gen :: (MonadGen m) => m Shell
+gen =
+  Gen.choice
+    [ Bash <$> Gen.maybe genText,
+      Sh <$> Gen.maybe genText,
+      Python <$> Gen.maybe genText,
+      Cmd <$> Gen.maybe genText,
+      Powershell <$> Gen.maybe genText,
+      Pwsh <$> Gen.maybe genText
+    ]
+  where
+    genText = Gen.text (Range.linear 1 5) Gen.alphaNum
+
+data Platform = Windows | MacOS | Linux
+
+supportedPlatforms :: Shell -> NonEmpty Platform
+supportedPlatforms = \case
+  Bash _ -> Linux :| [MacOS, Windows]
+  Sh _ -> MacOS :| []
+  Python _ -> Linux :| [MacOS, Windows]
+  Cmd _ -> Windows :| []
+  Powershell _ -> Windows :| []
+  Pwsh _ -> Windows :| []
+
+-- | A lens into the arguments for a shell, compatible with the "lens" package
+--
+-- @
+-- arguments :: Lens' Shell (Maybe Text)
+-- @
+arguments :: forall f. (Functor f) => (Maybe Text -> f (Maybe Text)) -> Shell -> f Shell
+arguments f = \case
+  Bash args -> Bash <$> f args
+  Sh args -> Sh <$> f args
+  Python args -> Python <$> f args
+  Cmd args -> Cmd <$> f args
+  Powershell args -> Powershell <$> f args
+  Pwsh args -> Pwsh <$> f args
diff --git a/src/Language/Github/Actions/Step.hs b/src/Language/Github/Actions/Step.hs
new file mode 100644
--- /dev/null
+++ b/src/Language/Github/Actions/Step.hs
@@ -0,0 +1,186 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE DerivingStrategies #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+
+-- |
+-- Module      : Language.Github.Actions.Step
+-- Description : GitHub Actions step definition and serialization
+-- Copyright   : (c) 2025 Bellroy Pty Ltd
+-- License     : BSD-3-Clause
+-- Maintainer  : Bellroy Tech Team <haskell@bellroy.com>
+--
+-- This module provides the 'Step' type for representing individual steps within GitHub Actions jobs.
+-- Steps are the individual tasks that make up a job, such as checking out code, running commands,
+-- or using pre-built actions.
+--
+-- A step can either run a command/script or use a pre-built action from the GitHub marketplace
+-- or a custom action. Steps run sequentially within a job.
+--
+-- For more information about GitHub Actions step syntax, see:
+-- <https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idsteps>
+module Language.Github.Actions.Step
+  ( Step (..),
+    gen,
+    new,
+  )
+where
+
+import Data.Aeson (FromJSON, ToJSON (..), (.!=), (.:?), (.=))
+import qualified Data.Aeson as Aeson
+import Data.Map (Map)
+import Data.Maybe (catMaybes)
+import Data.Text (Text)
+import GHC.Generics (Generic)
+import Hedgehog (MonadGen)
+import qualified Hedgehog.Gen as Gen
+import qualified Hedgehog.Range as Range
+import Language.Github.Actions.Shell (Shell)
+import qualified Language.Github.Actions.Shell as Shell
+import Language.Github.Actions.Step.Id (StepId)
+import qualified Language.Github.Actions.Step.Id as StepId
+import Language.Github.Actions.Step.With (StepWith)
+import qualified Language.Github.Actions.Step.With as StepWith
+
+-- | A step within a GitHub Actions job.
+--
+-- A step is an individual task within a job. Steps can run commands, scripts, or actions.
+-- Each step runs in the runner environment and can use outputs from previous steps.
+--
+-- There are two main types of steps:
+-- * Command steps that use 'run' to execute shell commands
+-- * Action steps that use 'uses' to run a pre-built action
+--
+-- Example usage:
+--
+-- @
+-- import Language.Github.Actions.Step
+--
+-- -- A command step
+-- commandStep :: Step
+-- commandStep = new
+--  { name = Just "Run tests"
+--  , run = Just "npm test"
+--  }
+--
+-- -- An action step
+-- actionStep :: Step
+-- actionStep = new
+--  { name = Just "Checkout code"
+--  , uses = Just "actions/checkout\@v4"
+--  }
+-- @
+--
+-- For more details, see: <https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idsteps>
+data Step = Step
+  { -- | Whether to continue job execution if this step fails
+    continueOnError :: Bool,
+    -- | Environment variables for this step
+    env :: Map Text Text,
+    -- | Display name for the step
+    name :: Maybe Text,
+    -- | Command or script to run
+    run :: Maybe Text,
+    -- | Condition for running this step
+    runIf :: Maybe Text,
+    -- | Shell to use for running commands
+    shell :: Maybe Shell,
+    -- | Unique identifier for this step
+    stepId :: Maybe StepId,
+    -- | Timeout for the step in minutes
+    timeoutMinutes :: Maybe Int,
+    -- | Action to run (e.g., "actions/checkout@v4")
+    uses :: Maybe Text,
+    -- | Inputs for the action
+    with :: Maybe StepWith,
+    -- | Working directory for the step
+    workingDirectory :: Maybe Text
+  }
+  deriving stock (Eq, Generic, Ord, Show)
+
+instance FromJSON Step where
+  parseJSON = Aeson.withObject "Step" $ \o -> do
+    continueOnError <- o .:? "continue-on-error" .!= False
+    env <- o .:? "env" .!= mempty
+    name <- o .:? "name"
+    run <- o .:? "run"
+    runIf <- o .:? "if"
+    uses <- o .:? "uses"
+    shell <- o .:? "shell"
+    stepId <- o .:? "id"
+    timeoutMinutes <- o .:? "timeout-minutes"
+    with <- o .:? "with"
+    workingDirectory <- o .:? "working-directory"
+    pure Step {..}
+
+instance ToJSON Step where
+  toJSON Step {..} =
+    Aeson.object $
+      catMaybes
+        [ if continueOnError then Just ("continue-on-error" .= True) else Nothing,
+          ("env" .=) <$> monoidToMaybe env,
+          ("id" .=) <$> stepId,
+          ("if" .=) <$> runIf,
+          ("name" .=) <$> name,
+          ("run" .=) <$> run,
+          ("shell" .=) <$> shell,
+          ("timeout-minutes" .=) <$> timeoutMinutes,
+          ("uses" .=) <$> uses,
+          ("with" .=) <$> with,
+          ("working-directory" .=) <$> workingDirectory
+        ]
+    where
+      monoidToMaybe :: (Eq a, Monoid a) => a -> Maybe a
+      monoidToMaybe a = if a == mempty then Nothing else Just a
+
+gen :: (MonadGen m) => m Step
+gen = do
+  continueOnError <- Gen.bool
+  env <- genTextMap
+  name <- Gen.maybe genText
+  run <- Gen.maybe genText
+  runIf <- Gen.maybe genText
+  shell <- Gen.maybe Shell.gen
+  stepId <- Gen.maybe StepId.gen
+  timeoutMinutes <- Gen.maybe $ Gen.int (Range.linear 1 120)
+  uses <- Gen.maybe genText
+  with <- Gen.maybe StepWith.gen
+  workingDirectory <- Gen.maybe genText
+  pure Step {..}
+  where
+    genText = Gen.text (Range.linear 1 5) Gen.alphaNum
+    genTextMap = Gen.map (Range.linear 3 20) $ liftA2 (,) genText genText
+
+-- | Create a new empty 'Step' with default values.
+--
+-- This provides a minimal step that can be extended with specific commands,
+-- actions, or other configuration.
+--
+-- Example:
+--
+-- @
+-- checkoutStep = new
+--   { name = Just "Checkout repository"
+--   , uses = Just "actions/checkout\@v4"
+--   }
+--
+-- commandStep = new
+--   { name = Just "Build project"
+--   , run = Just "make build"
+--   }
+-- @
+new :: Step
+new =
+  Step
+    { continueOnError = False,
+      env = mempty,
+      name = Nothing,
+      run = Nothing,
+      runIf = Nothing,
+      shell = Nothing,
+      stepId = Nothing,
+      timeoutMinutes = Nothing,
+      uses = Nothing,
+      with = Nothing,
+      workingDirectory = Nothing
+    }
diff --git a/src/Language/Github/Actions/Step/Id.hs b/src/Language/Github/Actions/Step/Id.hs
new file mode 100644
--- /dev/null
+++ b/src/Language/Github/Actions/Step/Id.hs
@@ -0,0 +1,73 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE DerivingStrategies #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+
+-- |
+-- Module      : Language.Github.Actions.Step.Id
+-- Description : Step identifiers for GitHub Actions workflows
+-- Copyright   : (c) 2025 Bellroy Pty Ltd
+-- License     : BSD-3-Clause
+-- Maintainer  : Bellroy Tech Team <haskell@bellroy.com>
+--
+-- This module provides the 'StepId' type for uniquely identifying steps within
+-- GitHub Actions jobs.
+--
+-- Step IDs are used to reference step outputs from later steps in the same job
+-- or from other jobs. They must be unique within a job and follow specific
+-- naming conventions.
+--
+-- For more information about GitHub Actions step IDs, see:
+-- <https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsid>
+module Language.Github.Actions.Step.Id
+  ( StepId (..),
+    gen,
+    render,
+  )
+where
+
+import Data.Aeson (FromJSON, FromJSONKey, ToJSON (..), ToJSONKey)
+import Data.Text (Text)
+import GHC.Generics (Generic)
+import Hedgehog (MonadGen)
+import qualified Hedgehog.Gen as Gen
+import qualified Hedgehog.Range as Range
+
+-- | A unique identifier for a step within a GitHub Actions job.
+--
+-- Step IDs are used to:
+-- * Reference step outputs from later steps using @steps.<step_id>.outputs.<output_name>@
+-- * Reference step outcomes using @steps.<step_id>.outcome@ or @steps.<step_id>.conclusion@
+-- * Create dependencies between steps (though steps run sequentially by default)
+--
+-- Step IDs must be unique within a job and should follow these conventions:
+-- * Start with a letter or underscore
+-- * Contain only alphanumeric characters, hyphens, and underscores
+-- * Be descriptive of the step's purpose
+--
+-- Example usage:
+--
+-- @
+-- import Language.Github.Actions.Step.Id
+--
+-- -- Simple step IDs
+-- checkoutStepId :: StepId
+-- checkoutStepId = StepId "checkout"
+--
+-- buildStepId :: StepId
+-- buildStepId = StepId "build"
+--
+-- -- More descriptive step IDs
+-- uploadArtifactsStepId :: StepId
+-- uploadArtifactsStepId = StepId "upload-build-artifacts"
+-- @
+--
+-- For more details, see: <https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsid>
+newtype StepId = StepId Text
+  deriving stock (Eq, Generic, Ord, Show)
+  deriving newtype (FromJSON, FromJSONKey, ToJSON, ToJSONKey)
+
+gen :: (MonadGen m) => m StepId
+gen = StepId <$> Gen.text (Range.linear 1 5) Gen.alphaNum
+
+render :: StepId -> Text
+render (StepId t) = t
diff --git a/src/Language/Github/Actions/Step/With.hs b/src/Language/Github/Actions/Step/With.hs
new file mode 100644
--- /dev/null
+++ b/src/Language/Github/Actions/Step/With.hs
@@ -0,0 +1,134 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE DerivingStrategies #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+
+-- |
+-- Module      : Language.Github.Actions.Step.With
+-- Description : Input parameters for GitHub Actions steps
+-- Copyright   : (c) 2025 Bellroy Pty Ltd
+-- License     : BSD-3-Clause
+-- Maintainer  : Bellroy Tech Team <haskell@bellroy.com>
+--
+-- This module provides the 'StepWith' type for specifying input parameters
+-- to GitHub Actions steps that use pre-built actions.
+--
+-- The 'with' keyword allows you to pass inputs to actions, which can be:
+-- * Environment variables for the action
+-- * Docker container arguments (for Docker actions)
+-- * Configuration parameters specific to the action
+--
+-- For more information about GitHub Actions step inputs, see:
+-- <https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idstepswith>
+module Language.Github.Actions.Step.With
+  ( StepWith (..),
+    gen,
+  )
+where
+
+import Data.Aeson (FromJSON, ToJSON (..), (.:), (.:?), (.=))
+import qualified Data.Aeson as Aeson
+import qualified Data.Aeson.KeyMap as AesonKeyMap
+import Data.Map (Map)
+import Data.Maybe (catMaybes)
+import qualified Data.Set as Set
+import Data.Text (Text)
+import GHC.Generics (Generic)
+import Hedgehog (MonadGen)
+import qualified Hedgehog.Gen as Gen
+import qualified Hedgehog.Range as Range
+
+-- | Docker container arguments for Docker actions.
+--
+-- Specifies the entry point and optional arguments for Docker actions.
+data StepWithDockerArgsAttrs = StepWithDockerArgsAttrs
+  { -- | Docker container entry point
+    entryPoint :: Text,
+    -- | Optional arguments to pass to the entry point
+    args :: Maybe Text
+  }
+  deriving stock (Eq, Generic, Ord, Show)
+
+-- | Input parameters for GitHub Actions steps.
+--
+-- Step inputs can be specified in two main ways:
+--
+-- * 'StepWithDockerArgs' - For Docker actions that need entry point and arguments
+-- * 'StepWithEnv' - For actions that accept environment variables or general inputs
+--
+-- Most actions use the environment variable format, where inputs are passed
+-- as key-value pairs.
+--
+-- Example usage:
+--
+-- @
+-- import Language.Github.Actions.Step.With
+-- import qualified Data.Map as Map
+--
+-- -- Environment inputs for actions/checkout
+-- checkoutInputs :: StepWith
+-- checkoutInputs = StepWithEnv $ Map.fromList
+--  [ ("repository", "owner/repo")
+--  , ("ref", "main")
+--  , ("token", "\${{ secrets.GITHUB_TOKEN }}")
+--  ]
+--
+-- -- Docker action inputs
+-- dockerInputs :: StepWith
+-- dockerInputs = StepWithDockerArgs $ StepWithDockerArgsAttrs
+--  { entryPoint = "/entrypoint.sh"
+--  , args = Just "arg1 arg2"
+--  }
+--
+-- -- Action inputs for actions/upload-artifact
+-- uploadInputs :: StepWith
+-- uploadInputs = StepWithEnv $ Map.fromList
+--  [ ("name", "build-artifacts")
+--  , ("path", "dist/")
+--  , ("retention-days", "30")
+--  ]
+-- @
+--
+-- For more details, see: <https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idstepswith>
+data StepWith
+  = -- | Docker action arguments
+    StepWithDockerArgs StepWithDockerArgsAttrs
+  | -- | Environment variables/general inputs
+    StepWithEnv (Map Text Text)
+  deriving stock (Eq, Generic, Ord, Show)
+
+instance FromJSON StepWith where
+  parseJSON = Aeson.withObject "StepWith" $ \o ->
+    let objectKeySet = Set.fromList (AesonKeyMap.keys o)
+        dockerKeySet = Set.fromList ["entryPoint", "args"]
+     in if objectKeySet `Set.isSubsetOf` dockerKeySet
+          then do
+            entryPoint <- o .: "entryPoint"
+            args <- o .:? "args"
+            pure . StepWithDockerArgs $ StepWithDockerArgsAttrs {..}
+          else StepWithEnv <$> Aeson.parseJSON (Aeson.Object o)
+
+instance ToJSON StepWith where
+  toJSON = \case
+    StepWithDockerArgs StepWithDockerArgsAttrs {..} ->
+      Aeson.object $
+        catMaybes
+          [ Just $ "entryPoint" .= entryPoint,
+            ("args" .=) <$> args
+          ]
+    StepWithEnv env ->
+      toJSON env
+
+gen :: (MonadGen m) => m StepWith
+gen =
+  Gen.choice
+    [ StepWithDockerArgs <$> do
+        entryPoint <- genText
+        args <- Gen.maybe genText
+        pure StepWithDockerArgsAttrs {..},
+      StepWithEnv <$> genTextMap
+    ]
+  where
+    genText = Gen.text (Range.linear 1 5) Gen.alphaNum
+    genTextMap = Gen.map (Range.linear 1 5) $ liftA2 (,) genText genText
diff --git a/src/Language/Github/Actions/Workflow.hs b/src/Language/Github/Actions/Workflow.hs
new file mode 100644
--- /dev/null
+++ b/src/Language/Github/Actions/Workflow.hs
@@ -0,0 +1,204 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE DerivingStrategies #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+
+-- |
+-- Module      : Language.Github.Actions.Workflow
+-- Description : GitHub Actions workflow definition and serialization
+-- Copyright   : (c) 2025 Bellroy Pty Ltd
+-- License     : BSD-3-Clause
+-- Maintainer  : Bellroy Tech Team <haskell@bellroy.com>
+--
+-- This module provides the core 'Workflow' type for representing GitHub Actions workflows,
+-- along with JSON serialization/deserialization and generation utilities.
+--
+-- GitHub Actions workflows are defined in YAML files that specify automated processes
+-- triggered by events in a GitHub repository. This module provides a type-safe way to
+-- construct and serialize these workflows.
+--
+-- For more information about GitHub Actions workflow syntax, see:
+-- <https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions>
+module Language.Github.Actions.Workflow
+  ( Workflow (..),
+    gen,
+    new,
+  )
+where
+
+import Data.Aeson (FromJSON, ToJSON (..), (.!=), (.:), (.:?), (.=))
+import qualified Data.Aeson as Aeson
+import qualified Data.Aeson.KeyMap as AesonKeyMap
+import Data.Map (Map)
+import Data.Maybe (catMaybes)
+import Data.Set (Set)
+import qualified Data.Set as Set
+import Data.Text (Text)
+import GHC.Generics (Generic)
+import Hedgehog (MonadGen)
+import qualified Hedgehog.Gen as Gen
+import qualified Hedgehog.Range as Range
+import Language.Github.Actions.Concurrency (Concurrency)
+import qualified Language.Github.Actions.Concurrency as Concurrency
+import Language.Github.Actions.Defaults (Defaults)
+import qualified Language.Github.Actions.Defaults as Defaults
+import Language.Github.Actions.Job (Job)
+import qualified Language.Github.Actions.Job as Job
+import Language.Github.Actions.Job.Id (JobId)
+import qualified Language.Github.Actions.Job.Id as JobId
+import Language.Github.Actions.Permissions (Permissions)
+import qualified Language.Github.Actions.Permissions as Permissions
+import Language.Github.Actions.Workflow.Trigger (WorkflowTrigger)
+import qualified Language.Github.Actions.Workflow.Trigger as WorkflowTrigger
+
+-- | Internal type for workflow job steps (used in JSON parsing)
+data WorkflowJobStep = WorkflowJobStep
+  { -- | Optional step name
+    name :: Maybe Text,
+    -- | Optional run command
+    run :: Maybe Text
+  }
+  deriving stock (Eq, Generic, Ord, Show)
+
+-- | A GitHub Actions workflow definition.
+--
+-- A workflow defines automated processes that run in response to events in your repository.
+-- Workflows are defined in YAML files stored in the @.github\/workflows@ directory.
+--
+-- Example usage:
+--
+-- @
+-- import Language.Github.Actions.Workflow
+-- import qualified Language.Github.Actions.Job as Job
+-- import qualified Language.Github.Actions.Job.Id as JobId
+--
+-- myWorkflow :: Workflow
+-- myWorkflow = new
+--  { workflowName = Just "CI"
+--  , jobs = Map.singleton (JobId "build") Job.new
+--  , on = Set.singleton pushTrigger
+--  }
+-- @
+--
+-- For more details, see: <https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions>
+data Workflow = Workflow
+  { -- | Concurrency settings to limit workflow runs
+    concurrency :: Maybe Concurrency,
+    -- | Default settings for all jobs in the workflow
+    defaults :: Maybe Defaults,
+    -- | Environment variables available to all jobs
+    env :: Map Text Text,
+    -- | The jobs that make up the workflow
+    jobs :: Map JobId Job,
+    -- | Events that trigger the workflow
+    on :: Set WorkflowTrigger,
+    -- | Permissions granted to the workflow
+    permissions :: Maybe Permissions,
+    -- | Name for workflow runs
+    runName :: Maybe Text,
+    -- | Name of the workflow
+    workflowName :: Maybe Text
+  }
+  deriving stock (Eq, Generic, Ord, Show)
+
+instance FromJSON Workflow where
+  parseJSON = Aeson.withObject "Workflow" $ \o -> do
+    concurrency <- o .:? "concurrency"
+    defaults <- o .:? "defaults"
+    env <- o .:? "env" .!= mempty
+    jobs <- o .: "jobs"
+    Aeson.Object onObject <- o .: "on"
+    triggers <-
+      traverse
+        ( Aeson.parseJSON
+            . Aeson.Object
+            . uncurry AesonKeyMap.singleton
+        )
+        $ AesonKeyMap.toList onObject
+    let on = Set.fromList triggers
+    permissions <- o .:? "permissions"
+    runName <- o .:? "run-name"
+    workflowName <- o .:? "name"
+    pure Workflow {..}
+
+instance ToJSON Workflow where
+  toJSON Workflow {..} =
+    Aeson.object $
+      catMaybes
+        [ ("concurrency" .=) <$> concurrency,
+          ("defaults" .=) <$> defaults,
+          ("env" .=) <$> monoidToMaybe env,
+          Just $ "jobs" .= jobs,
+          ("name" .=) <$> workflowName,
+          Just $ "on" .= Aeson.Object (mconcat (forceToJSONObject . Aeson.toJSON <$> Set.toList on)),
+          ("permissions" .=) <$> permissions,
+          ("run-name" .=) <$> runName
+        ]
+    where
+      forceToJSONObject :: Aeson.Value -> Aeson.Object
+      forceToJSONObject (Aeson.Object o) = o
+      forceToJSONObject _ = error "Expected Aeson.Object"
+
+      monoidToMaybe :: (Eq a, Monoid a) => a -> Maybe a
+      monoidToMaybe a = if a == mempty then Nothing else Just a
+
+gen :: (MonadGen m) => m Workflow
+gen = do
+  concurrency <- Gen.maybe Concurrency.gen
+  defaults <- Gen.maybe Defaults.gen
+  env <- genTextMap
+  jobs <-
+    Gen.map (Range.linear 1 5) (liftA2 (,) JobId.gen Job.gen)
+  on <-
+    Set.fromList
+      . snd
+      . foldr onlyAddIfJsonKeyNotAlreadyPresent ([], [])
+      <$> Gen.list (Range.linear 1 5) WorkflowTrigger.gen
+
+  permissions <- Gen.maybe Permissions.gen
+  runName <- Gen.maybe genText
+  workflowName <- Gen.maybe genText
+  pure Workflow {..}
+  where
+    onlyAddIfJsonKeyNotAlreadyPresent ::
+      WorkflowTrigger ->
+      ([AesonKeyMap.Key], [WorkflowTrigger]) ->
+      ([AesonKeyMap.Key], [WorkflowTrigger])
+    onlyAddIfJsonKeyNotAlreadyPresent trigger (keys, triggers) =
+      let key = case Aeson.toJSONKey of
+            Aeson.ToJSONKeyText f _ -> f trigger
+            _ -> error "Expected Aeson.ToJSONKeyText"
+       in if key `elem` keys
+            then (keys, triggers)
+            else (key : keys, trigger : triggers)
+
+    genText = Gen.text (Range.linear 1 5) Gen.alphaNum
+    genTextMap = Gen.map (Range.linear 1 5) $ liftA2 (,) genText genText
+
+-- | Create a new empty 'Workflow' with default values.
+--
+-- This provides a minimal workflow that can be extended with specific jobs,
+-- triggers, and other configuration.
+--
+-- Example:
+--
+-- @
+-- myWorkflow = new
+--   { workflowName = Just "My Workflow"
+--   , jobs = Map.singleton (JobId "test") Job.new
+--   , on = Set.singleton (PushTrigger pushTriggerDefaults)
+--   }
+-- @
+new :: Workflow
+new =
+  Workflow
+    { concurrency = Nothing,
+      defaults = Nothing,
+      env = mempty,
+      jobs = mempty,
+      on = mempty,
+      permissions = Nothing,
+      runName = Nothing,
+      workflowName = Nothing
+    }
diff --git a/src/Language/Github/Actions/Workflow/Trigger.hs b/src/Language/Github/Actions/Workflow/Trigger.hs
new file mode 100644
--- /dev/null
+++ b/src/Language/Github/Actions/Workflow/Trigger.hs
@@ -0,0 +1,1459 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE DerivingStrategies #-}
+{-# LANGUAGE DuplicateRecordFields #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE QuasiQuotes #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+
+-- |
+-- Module      : Language.Github.Actions.Workflow.Trigger
+-- Description : GitHub Actions workflow trigger events and configurations
+-- Copyright   : (c) 2025 Bellroy Pty Ltd
+-- License     : BSD-3-Clause
+-- Maintainer  : Bellroy Tech Team <haskell@bellroy.com>
+--
+-- This module provides comprehensive support for GitHub Actions workflow triggers,
+-- which determine when workflows should run.
+--
+-- Workflow triggers respond to various GitHub events such as pushes, pull requests,
+-- issue updates, scheduled times, and external repository dispatch events. Each trigger
+-- type can be configured with specific activity types and filtering criteria.
+--
+-- The main types include:
+--
+-- * 'WorkflowTrigger' - The primary trigger type supporting all GitHub event types
+-- * Activity type enums for each event (e.g., 'PullRequestActivityType', 'IssuesActivityType')
+-- * Attribute types for triggers with complex configuration (e.g., 'PushTriggerAttributes')
+--
+-- Example usage:
+--
+-- @
+-- import Language.Github.Actions.Workflow.Trigger
+--
+-- -- Trigger on push to main branch
+-- pushTrigger :: WorkflowTrigger
+-- pushTrigger = PushTrigger $ PushTriggerAttributes
+--   { branches = Just ("main" :| [])
+--   , branchesIgnore = Nothing
+--   , paths = Nothing
+--   , pathsIgnore = Nothing
+--   , tags = Nothing
+--   }
+--
+-- -- Trigger on pull request opened or synchronized
+-- prTrigger :: WorkflowTrigger
+-- prTrigger = PullRequestTrigger $ PullRequestTriggerAttributes
+--   { activityTypes = Just (PullRequestOpened :| [PullRequestSynchronize])
+--   , branches = Nothing
+--   , branchesIgnore = Nothing
+--   , paths = Nothing
+--   , pathsIgnore = Nothing
+--   }
+-- @
+--
+-- For more information about GitHub Actions events and triggers, see:
+-- <https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows>
+module Language.Github.Actions.Workflow.Trigger
+  ( BranchProtectionRuleActivityType (..),
+    CheckRunActivityType (..),
+    DiscussionActivityType (..),
+    DiscussionCommentActivityType (..),
+    IssueCommentActivityType (..),
+    IssuesActivityType (..),
+    LabelActivityType (..),
+    MilestoneActivityType (..),
+    PullRequestActivityType (..),
+    PullRequestReviewActivityType (..),
+    PullRequestReviewCommentActivityType (..),
+    PullRequestTargetActivityType (..),
+    PullRequestTargetTriggerAttributes (..),
+    PullRequestTriggerAttributes (..),
+    PushTriggerAttributes (..),
+    RegistryPackageActivityType (..),
+    ReleaseActivityType (..),
+    WorkflowCallAttributes (..),
+    WorkflowDispatchAttributes (..),
+    WorkflowRunActivityType (..),
+    WorkflowRunTriggerAttributes (..),
+    WorkflowTrigger (..),
+    gen,
+  )
+where
+
+import Control.Applicative ((<|>))
+import Control.Monad (guard)
+import Control.Monad.Fail.Hoist (hoistFail')
+import Data.Aeson (FromJSON, ToJSON, ToJSONKey, (.:), (.:?), (.=))
+import qualified Data.Aeson as Aeson
+import qualified Data.Aeson.KeyMap as AesonKeyMap
+import qualified Data.Aeson.Types as Aeson
+import Data.List.NonEmpty (NonEmpty (..), nonEmpty)
+import Data.Map (Map)
+import qualified Data.Map as Map
+import Data.Maybe (catMaybes, fromMaybe, isJust)
+import Data.String.Interpolate (i)
+import Data.Text (Text)
+import Data.Traversable (for)
+import qualified Data.Vector as Vector
+import GHC.Generics (Generic)
+import Hedgehog (MonadGen)
+import qualified Hedgehog.Gen as Gen
+import qualified Hedgehog.Range as Range
+import Language.Github.Actions.Internal (inverseMap)
+
+data BranchProtectionRuleActivityType
+  = BranchProtectionRuleCreated
+  | BranchProtectionRuleDeleted
+  | BranchProtectionRuleEdited
+  deriving stock (Bounded, Enum, Eq, Generic, Ord, Show)
+
+instance FromJSON BranchProtectionRuleActivityType where
+  parseJSON =
+    Aeson.withText "BranchProtectionRuleActivityType" $
+      hoistFail' . parseBranchProtectionRuleActivityType
+
+instance ToJSON BranchProtectionRuleActivityType where
+  toJSON = Aeson.String . renderBranchProtectionRuleActivityType
+
+renderBranchProtectionRuleActivityType :: BranchProtectionRuleActivityType -> Text
+renderBranchProtectionRuleActivityType = \case
+  BranchProtectionRuleCreated -> "created"
+  BranchProtectionRuleDeleted -> "deleted"
+  BranchProtectionRuleEdited -> "edited"
+
+parseBranchProtectionRuleActivityType :: Text -> Either String BranchProtectionRuleActivityType
+parseBranchProtectionRuleActivityType t =
+  maybe (Left [i|Unknown BranchProtectionRuleActivityType: #{t}|]) Right $
+    inverseMap renderBranchProtectionRuleActivityType t
+
+data CheckRunActivityType
+  = CheckRunCompleted
+  | CheckRunCreated
+  | CheckRunRequestedAction
+  | CheckRunRerequested
+  deriving stock (Bounded, Enum, Eq, Generic, Ord, Show)
+
+instance FromJSON CheckRunActivityType where
+  parseJSON =
+    Aeson.withText "CheckRunActivityType" $
+      hoistFail' . parseCheckRunActivityType
+
+instance ToJSON CheckRunActivityType where
+  toJSON = Aeson.String . renderCheckRunActivityType
+
+renderCheckRunActivityType :: CheckRunActivityType -> Text
+renderCheckRunActivityType = \case
+  CheckRunCompleted -> "completed"
+  CheckRunCreated -> "created"
+  CheckRunRequestedAction -> "requested_action"
+  CheckRunRerequested -> "rerequested"
+
+parseCheckRunActivityType :: Text -> Either String CheckRunActivityType
+parseCheckRunActivityType t =
+  maybe (Left [i|Unknown CheckRunActivityType: #{t}|]) Right $
+    inverseMap renderCheckRunActivityType t
+
+data DiscussionActivityType
+  = DiscussionAnswered
+  | DiscussionCategoryChanged
+  | DiscussionCreated
+  | DiscussionDeleted
+  | DiscussionEdited
+  | DiscussionLabeled
+  | DiscussionLocked
+  | DiscussionPinned
+  | DiscussionTransferred
+  | DiscussionUnanswered
+  | DiscussionUnlabeled
+  | DiscussionUnlocked
+  | DiscussionUnpinned
+  deriving stock (Bounded, Enum, Eq, Generic, Ord, Show)
+
+instance FromJSON DiscussionActivityType where
+  parseJSON =
+    Aeson.withText "DiscussionActivityType" $
+      hoistFail' . parseDiscussionActivityType
+
+instance ToJSON DiscussionActivityType where
+  toJSON = Aeson.String . renderDiscussionActivityType
+
+renderDiscussionActivityType :: DiscussionActivityType -> Text
+renderDiscussionActivityType = \case
+  DiscussionAnswered -> "answered"
+  DiscussionCategoryChanged -> "category_changed"
+  DiscussionCreated -> "created"
+  DiscussionDeleted -> "deleted"
+  DiscussionEdited -> "edited"
+  DiscussionLabeled -> "labeled"
+  DiscussionLocked -> "locked"
+  DiscussionPinned -> "pinned"
+  DiscussionTransferred -> "transferred"
+  DiscussionUnanswered -> "unanswered"
+  DiscussionUnlabeled -> "unlabeled"
+  DiscussionUnlocked -> "unlocked"
+  DiscussionUnpinned -> "unpinned"
+
+parseDiscussionActivityType :: Text -> Either String DiscussionActivityType
+parseDiscussionActivityType t =
+  maybe (Left [i|Unknown DiscussionActivityType: #{t}|]) Right $
+    inverseMap renderDiscussionActivityType t
+
+data DiscussionCommentActivityType
+  = DiscussionCommentCreated
+  | DiscussionCommentDeleted
+  | DiscussionCommentEdited
+  deriving stock (Bounded, Enum, Eq, Generic, Ord, Show)
+
+instance FromJSON DiscussionCommentActivityType where
+  parseJSON =
+    Aeson.withText "DiscussionCommentActivityType" $
+      hoistFail' . parseDiscussionCommentActivityType
+
+instance ToJSON DiscussionCommentActivityType where
+  toJSON = Aeson.String . renderDiscussionCommentActivityType
+
+renderDiscussionCommentActivityType :: DiscussionCommentActivityType -> Text
+renderDiscussionCommentActivityType = \case
+  DiscussionCommentCreated -> "created"
+  DiscussionCommentDeleted -> "deleted"
+  DiscussionCommentEdited -> "edited"
+
+parseDiscussionCommentActivityType :: Text -> Either String DiscussionCommentActivityType
+parseDiscussionCommentActivityType t =
+  maybe (Left [i|Unknown DiscussionCommentActivityType: #{t}|]) Right $
+    inverseMap renderDiscussionCommentActivityType t
+
+data IssueCommentActivityType
+  = IssueCommentCreated
+  | IssueCommentDeleted
+  | IssueCommentEdited
+  deriving stock (Bounded, Enum, Eq, Generic, Ord, Show)
+
+instance FromJSON IssueCommentActivityType where
+  parseJSON =
+    Aeson.withText "IssueCommentActivityType" $
+      hoistFail' . parseIssueCommentActivityType
+
+instance ToJSON IssueCommentActivityType where
+  toJSON = Aeson.String . renderIssueCommentActivityType
+
+renderIssueCommentActivityType :: IssueCommentActivityType -> Text
+renderIssueCommentActivityType = \case
+  IssueCommentCreated -> "created"
+  IssueCommentDeleted -> "deleted"
+  IssueCommentEdited -> "edited"
+
+parseIssueCommentActivityType :: Text -> Either String IssueCommentActivityType
+parseIssueCommentActivityType t =
+  maybe (Left [i|Unknown IssueCommentActivityType: #{t}|]) Right $
+    inverseMap renderIssueCommentActivityType t
+
+data IssuesActivityType
+  = IssuesAssigned
+  | IssuesClosed
+  | IssuesDeleted
+  | IssuesDemilestoned
+  | IssuesEdited
+  | IssuesLabeled
+  | IssuesLocked
+  | IssuesMilestoned
+  | IssuesOpened
+  | IssuesPinned
+  | IssuesReopened
+  | IssuesTransferred
+  | IssuesUnassigned
+  | IssuesUnlabeled
+  | IssuesUnlocked
+  | IssuesUnpinned
+  deriving stock (Bounded, Enum, Eq, Generic, Ord, Show)
+
+instance FromJSON IssuesActivityType where
+  parseJSON =
+    Aeson.withText "IssuesActivityType" $
+      hoistFail' . parseIssuesActivityType
+
+instance ToJSON IssuesActivityType where
+  toJSON = Aeson.String . renderIssuesActivityType
+
+renderIssuesActivityType :: IssuesActivityType -> Text
+renderIssuesActivityType = \case
+  IssuesAssigned -> "assigned"
+  IssuesClosed -> "closed"
+  IssuesDeleted -> "deleted"
+  IssuesDemilestoned -> "demilestoned"
+  IssuesEdited -> "edited"
+  IssuesLabeled -> "labeled"
+  IssuesLocked -> "locked"
+  IssuesMilestoned -> "milestoned"
+  IssuesOpened -> "opened"
+  IssuesPinned -> "pinned"
+  IssuesReopened -> "reopened"
+  IssuesTransferred -> "transferred"
+  IssuesUnassigned -> "unassigned"
+  IssuesUnlabeled -> "unlabeled"
+  IssuesUnlocked -> "unlocked"
+  IssuesUnpinned -> "unpinned"
+
+parseIssuesActivityType :: Text -> Either String IssuesActivityType
+parseIssuesActivityType t =
+  maybe (Left [i|Unknown IssuesActivityType: #{t}|]) Right $
+    inverseMap renderIssuesActivityType t
+
+data LabelActivityType
+  = LabelCreated
+  | LabelDeleted
+  | LabelEdited
+  deriving stock (Bounded, Enum, Eq, Generic, Ord, Show)
+
+instance FromJSON LabelActivityType where
+  parseJSON =
+    Aeson.withText "LabelActivityType" $
+      hoistFail' . parseLabelActivityType
+
+instance ToJSON LabelActivityType where
+  toJSON = Aeson.String . renderLabelActivityType
+
+renderLabelActivityType :: LabelActivityType -> Text
+renderLabelActivityType = \case
+  LabelCreated -> "created"
+  LabelDeleted -> "deleted"
+  LabelEdited -> "edited"
+
+parseLabelActivityType :: Text -> Either String LabelActivityType
+parseLabelActivityType t =
+  maybe (Left [i|Unknown LabelActivityType: #{t}|]) Right $
+    inverseMap renderLabelActivityType t
+
+data MilestoneActivityType
+  = MilestoneCreated
+  | MilestoneClosed
+  | MilestoneDeleted
+  | MilestoneEdited
+  | MilestoneOpened
+  deriving stock (Bounded, Enum, Eq, Generic, Ord, Show)
+
+instance FromJSON MilestoneActivityType where
+  parseJSON =
+    Aeson.withText "MilestoneActivityType" $
+      hoistFail' . parseMilestoneActivityType
+
+instance ToJSON MilestoneActivityType where
+  toJSON = Aeson.String . renderMilestoneActivityType
+
+renderMilestoneActivityType :: MilestoneActivityType -> Text
+renderMilestoneActivityType = \case
+  MilestoneCreated -> "created"
+  MilestoneClosed -> "closed"
+  MilestoneDeleted -> "deleted"
+  MilestoneEdited -> "edited"
+  MilestoneOpened -> "opened"
+
+parseMilestoneActivityType :: Text -> Either String MilestoneActivityType
+parseMilestoneActivityType t =
+  maybe (Left [i|Unknown MilestoneActivityType: #{t}|]) Right $
+    inverseMap renderMilestoneActivityType t
+
+-- | Activity types for pull request trigger events.
+--
+-- These specify which pull request activities should trigger the workflow.
+-- Common combinations include opened/synchronized for CI workflows, or
+-- closed for deployment workflows.
+--
+-- Example usage:
+--
+-- @
+-- -- Trigger on pull request creation and updates
+-- ciTrigger :: PullRequestActivityType
+-- ciTrigger = PullRequestTrigger $ PullRequestTriggerAttributes
+--  { activityTypes = Just (PullRequestOpened :| [PullRequestSynchronize])
+--  , branches = Nothing
+--  , branchesIgnore = Nothing
+--  , paths = Nothing
+--  , pathsIgnore = Nothing
+--  }
+-- @
+--
+-- For more details, see: <https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#pull_request>
+data PullRequestActivityType
+  = -- | Pull request was assigned to someone
+    PullRequestAssigned
+  | -- | Auto-merge was disabled
+    PullRequestAutoMergeDisabled
+  | -- | Auto-merge was enabled
+    PullRequestAutoMergeEnabled
+  | -- | Pull request was closed
+    PullRequestClosed
+  | -- | Pull request was converted to draft
+    PullRequestConvertedToDraft
+  | -- | Milestone was removed from pull request
+    PullRequestDemilestoned
+  | -- | Pull request was removed from merge queue
+    PullRequestDequeued
+  | -- | Pull request title or body was edited
+    PullRequestEdited
+  | -- | Pull request was added to merge queue
+    PullRequestEnqueued
+  | -- | Label was added to pull request
+    PullRequestLabeled
+  | -- | Pull request conversation was locked
+    PullRequestLocked
+  | -- | Milestone was added to pull request
+    PullRequestMilestoned
+  | -- | Pull request was opened
+    PullRequestOpened
+  | -- | Draft pull request was marked ready for review
+    PullRequestReadyForReview
+  | -- | Pull request was reopened
+    PullRequestReopened
+  | -- | Review request was removed
+    PullRequestReviewRequestRemoved
+  | -- | Review was requested
+    PullRequestReviewRequested
+  | -- | Pull request's head branch was updated
+    PullRequestSynchronize
+  | -- | Pull request was unassigned
+    PullRequestUnassigned
+  | -- | Label was removed from pull request
+    PullRequestUnlabeled
+  | -- | Pull request conversation was unlocked
+    PullRequestUnlocked
+  deriving stock (Bounded, Enum, Eq, Generic, Ord, Show)
+
+instance FromJSON PullRequestActivityType where
+  parseJSON =
+    Aeson.withText "PullRequestActivityType" $
+      hoistFail' . parsePullRequestActivityType
+
+instance ToJSON PullRequestActivityType where
+  toJSON = Aeson.String . renderPullRequestActivityType
+
+renderPullRequestActivityType :: PullRequestActivityType -> Text
+renderPullRequestActivityType = \case
+  PullRequestAssigned -> "assigned"
+  PullRequestAutoMergeDisabled -> "auto_merge_disabled"
+  PullRequestAutoMergeEnabled -> "auto_merge_enabled"
+  PullRequestClosed -> "closed"
+  PullRequestConvertedToDraft -> "converted_to_draft"
+  PullRequestDemilestoned -> "demilestoned"
+  PullRequestDequeued -> "dequeued"
+  PullRequestEdited -> "edited"
+  PullRequestEnqueued -> "enqueued"
+  PullRequestLabeled -> "labeled"
+  PullRequestLocked -> "locked"
+  PullRequestMilestoned -> "milestoned"
+  PullRequestOpened -> "opened"
+  PullRequestReadyForReview -> "ready_for_review"
+  PullRequestReopened -> "reopened"
+  PullRequestReviewRequestRemoved -> "review_request_removed"
+  PullRequestReviewRequested -> "review_requested"
+  PullRequestSynchronize -> "synchronize"
+  PullRequestUnassigned -> "unassigned"
+  PullRequestUnlabeled -> "unlabeled"
+  PullRequestUnlocked -> "unlocked"
+
+parsePullRequestActivityType :: Text -> Either String PullRequestActivityType
+parsePullRequestActivityType t =
+  maybe (Left [i|Unknown PullRequestActivityType: #{t}|]) Right $
+    inverseMap renderPullRequestActivityType t
+
+data PullRequestTriggerAttributes = PullRequestTriggerAttributes
+  { activityTypes :: Maybe (NonEmpty PullRequestActivityType),
+    branches :: Maybe (NonEmpty Text),
+    branchesIgnore :: Maybe (NonEmpty Text),
+    paths :: Maybe (NonEmpty Text),
+    pathsIgnore :: Maybe (NonEmpty Text)
+  }
+  deriving stock (Eq, Generic, Ord, Show)
+
+instance FromJSON PullRequestTriggerAttributes where
+  parseJSON = Aeson.withObject "PullRequestTriggerAttributes" $ \o -> do
+    activityTypes <- o .:? "types"
+    branches <- o .:? "branches"
+    branchesIgnore <- o .:? "branches-ignore"
+    paths <- o .:? "paths"
+    pathsIgnore <- o .:? "paths-ignore"
+    pure PullRequestTriggerAttributes {..}
+
+instance ToJSON PullRequestTriggerAttributes where
+  toJSON PullRequestTriggerAttributes {..} =
+    Aeson.object $
+      catMaybes
+        [ ("branches" .=) <$> branches,
+          ("branches-ignore" .=) <$> branchesIgnore,
+          ("paths" .=) <$> paths,
+          ("paths-ignore" .=) <$> pathsIgnore,
+          ("types" .=) <$> activityTypes
+        ]
+
+data PullRequestReviewActivityType
+  = PullRequestReviewDismissed
+  | PullRequestReviewEdited
+  | PullRequestReviewSubmitted
+  deriving stock (Bounded, Enum, Eq, Generic, Ord, Show)
+
+instance FromJSON PullRequestReviewActivityType where
+  parseJSON =
+    Aeson.withText "PullRequestReviewActivityType" $
+      hoistFail' . parsePullRequestReviewActivityType
+
+instance ToJSON PullRequestReviewActivityType where
+  toJSON = Aeson.String . renderPullRequestReviewActivityType
+
+renderPullRequestReviewActivityType :: PullRequestReviewActivityType -> Text
+renderPullRequestReviewActivityType = \case
+  PullRequestReviewDismissed -> "dismissed"
+  PullRequestReviewEdited -> "edited"
+  PullRequestReviewSubmitted -> "submitted"
+
+parsePullRequestReviewActivityType :: Text -> Either String PullRequestReviewActivityType
+parsePullRequestReviewActivityType t =
+  maybe (Left [i|Unknown PullRequestReviewActivityType: #{t}|]) Right $
+    inverseMap renderPullRequestReviewActivityType t
+
+data PullRequestReviewCommentActivityType
+  = PullRequestReviewCommentCreated
+  | PullRequestReviewCommentDeleted
+  | PullRequestReviewCommentEdited
+  deriving stock (Bounded, Enum, Eq, Generic, Ord, Show)
+
+instance FromJSON PullRequestReviewCommentActivityType where
+  parseJSON =
+    Aeson.withText "PullRequestReviewCommentActivityType" $
+      hoistFail' . parsePullRequestReviewCommentActivityType
+
+instance ToJSON PullRequestReviewCommentActivityType where
+  toJSON = Aeson.String . renderPullRequestReviewCommentActivityType
+
+renderPullRequestReviewCommentActivityType :: PullRequestReviewCommentActivityType -> Text
+renderPullRequestReviewCommentActivityType = \case
+  PullRequestReviewCommentCreated -> "created"
+  PullRequestReviewCommentDeleted -> "deleted"
+  PullRequestReviewCommentEdited -> "edited"
+
+parsePullRequestReviewCommentActivityType :: Text -> Either String PullRequestReviewCommentActivityType
+parsePullRequestReviewCommentActivityType t =
+  maybe (Left [i|Unknown PullRequestReviewCommentActivityType: #{t}|]) Right $
+    inverseMap renderPullRequestReviewCommentActivityType t
+
+data PullRequestTargetActivityType
+  = PullRequestTargetAssigned
+  | PullRequestTargetAutoMergeDisabled
+  | PullRequestTargetAutoMergeEnabled
+  | PullRequestTargetClosed
+  | PullRequestTargetConvertedToDraft
+  | PullRequestTargetEdited
+  | PullRequestTargetLabeled
+  | PullRequestTargetLocked
+  | PullRequestTargetOpened
+  | PullRequestTargetReadyForReview
+  | PullRequestTargetReopened
+  | PullRequestTargetReviewRequestRemoved
+  | PullRequestTargetReviewRequested
+  | PullRequestTargetSynchronize
+  | PullRequestTargetUnassigned
+  | PullRequestTargetUnlabeled
+  | PullRequestTargetUnlocked
+  deriving stock (Bounded, Enum, Eq, Generic, Ord, Show)
+
+instance FromJSON PullRequestTargetActivityType where
+  parseJSON =
+    Aeson.withText "PullRequestTargetActivityType" $
+      hoistFail' . parsePullRequestTargetActivityType
+
+instance ToJSON PullRequestTargetActivityType where
+  toJSON = Aeson.String . renderPullRequestTargetActivityType
+
+renderPullRequestTargetActivityType :: PullRequestTargetActivityType -> Text
+renderPullRequestTargetActivityType = \case
+  PullRequestTargetAssigned -> "assigned"
+  PullRequestTargetAutoMergeDisabled -> "auto_merge_disabled"
+  PullRequestTargetAutoMergeEnabled -> "auto_merge_enabled"
+  PullRequestTargetClosed -> "closed"
+  PullRequestTargetConvertedToDraft -> "converted_to_draft"
+  PullRequestTargetEdited -> "edited"
+  PullRequestTargetLabeled -> "labeled"
+  PullRequestTargetLocked -> "locked"
+  PullRequestTargetOpened -> "opened"
+  PullRequestTargetReadyForReview -> "ready_for_review"
+  PullRequestTargetReopened -> "reopened"
+  PullRequestTargetReviewRequestRemoved -> "review_request_removed"
+  PullRequestTargetReviewRequested -> "review_requested"
+  PullRequestTargetSynchronize -> "synchronize"
+  PullRequestTargetUnassigned -> "unassigned"
+  PullRequestTargetUnlabeled -> "unlabeled"
+  PullRequestTargetUnlocked -> "unlocked"
+
+parsePullRequestTargetActivityType :: Text -> Either String PullRequestTargetActivityType
+parsePullRequestTargetActivityType t =
+  maybe (Left [i|Unknown PullRequestTargetActivityType: #{t}|]) Right $
+    inverseMap renderPullRequestTargetActivityType t
+
+data PullRequestTargetTriggerAttributes = PullRequestTargetTriggerAttributes
+  { activityTypes :: Maybe (NonEmpty PullRequestTargetActivityType),
+    branches :: Maybe (NonEmpty Text),
+    branchesIgnore :: Maybe (NonEmpty Text),
+    paths :: Maybe (NonEmpty Text),
+    pathsIgnore :: Maybe (NonEmpty Text)
+  }
+  deriving stock (Eq, Generic, Ord, Show)
+
+instance FromJSON PullRequestTargetTriggerAttributes where
+  parseJSON = Aeson.withObject "PullRequestTargetTriggerAttributes" $ \o -> do
+    activityTypes <- o .:? "types"
+    branches <- o .:? "branches"
+    branchesIgnore <- o .:? "branches-ignore"
+    paths <- o .:? "paths"
+    pathsIgnore <- o .:? "paths-ignore"
+    pure PullRequestTargetTriggerAttributes {..}
+
+instance ToJSON PullRequestTargetTriggerAttributes where
+  toJSON PullRequestTargetTriggerAttributes {..} =
+    Aeson.object $
+      catMaybes
+        [ ("branches" .=) <$> branches,
+          ("branches-ignore" .=) <$> branchesIgnore,
+          ("paths" .=) <$> paths,
+          ("paths-ignore" .=) <$> pathsIgnore,
+          ("types" .=) <$> activityTypes
+        ]
+
+-- | Configuration attributes for push trigger events.
+--
+-- Push triggers can be filtered by branches, paths, and tags to control exactly
+-- when the workflow should run. This allows for fine-grained control over which
+-- repository changes should initiate workflow execution.
+--
+-- Example usage:
+--
+-- @
+-- -- Trigger on pushes to main or develop branches
+-- pushMainDevelop :: PushTriggerAttributes
+-- pushMainDevelop = PushTriggerAttributes
+--  { branches = Just ("main" :| ["develop"])
+--  , branchesIgnore = Nothing
+--  , paths = Nothing
+--  , pathsIgnore = Nothing
+--  , tags = Nothing
+--  }
+--
+-- -- Trigger on pushes to docs directory, ignoring gh-pages
+-- pushDocsOnly :: PushTriggerAttributes
+-- pushDocsOnly = PushTriggerAttributes
+--  { branches = Nothing
+--  , branchesIgnore = Just ("gh-pages" :| [])
+--  , paths = Just ("docs\/**" :| [])
+--  , pathsIgnore = Nothing
+--  , tags = Nothing
+--  }
+-- @
+--
+-- For more details, see: <https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#push>
+data PushTriggerAttributes = PushTriggerAttributes
+  { -- | Branches to include (default: all)
+    branches :: Maybe (NonEmpty Text),
+    -- | Branches to exclude
+    branchesIgnore :: Maybe (NonEmpty Text),
+    -- | File paths to include (default: all)
+    paths :: Maybe (NonEmpty Text),
+    -- | File paths to exclude
+    pathsIgnore :: Maybe (NonEmpty Text),
+    -- | Tags to include
+    tags :: Maybe (NonEmpty Text)
+  }
+  deriving stock (Eq, Generic, Ord, Show)
+
+instance FromJSON PushTriggerAttributes where
+  parseJSON = Aeson.withObject "PushTriggerAttributes" $ \o -> do
+    branches <- o .:? "branches"
+    branchesIgnore <- o .:? "branches-ignore"
+    paths <- o .:? "paths"
+    pathsIgnore <- o .:? "paths-ignore"
+    tags <- o .:? "tags"
+    pure PushTriggerAttributes {..}
+
+instance ToJSON PushTriggerAttributes where
+  toJSON PushTriggerAttributes {..} =
+    Aeson.object $
+      catMaybes
+        [ ("branches" .=) <$> branches,
+          ("branches-ignore" .=) <$> branchesIgnore,
+          ("paths" .=) <$> paths,
+          ("paths-ignore" .=) <$> pathsIgnore,
+          ("tags" .=) <$> tags
+        ]
+
+data RegistryPackageActivityType
+  = RegistryPackagePublished
+  | RegistryPackageUpdated
+  deriving stock (Bounded, Enum, Eq, Generic, Ord, Show)
+
+instance FromJSON RegistryPackageActivityType where
+  parseJSON =
+    Aeson.withText "RegistryPackageActivityType" $
+      hoistFail' . parseRegistryPackageActivityType
+
+instance ToJSON RegistryPackageActivityType where
+  toJSON = Aeson.String . renderRegistryPackageActivityType
+
+renderRegistryPackageActivityType :: RegistryPackageActivityType -> Text
+renderRegistryPackageActivityType = \case
+  RegistryPackagePublished -> "published"
+  RegistryPackageUpdated -> "updated"
+
+parseRegistryPackageActivityType :: Text -> Either String RegistryPackageActivityType
+parseRegistryPackageActivityType t =
+  maybe (Left [i|Unknown RegistryPackageActivityType: #{t}|]) Right $
+    inverseMap renderRegistryPackageActivityType t
+
+data ReleaseActivityType
+  = ReleaseCreated
+  | ReleaseDeleted
+  | ReleaseEdited
+  | ReleasePrereleased
+  | ReleasePublished
+  | ReleaseReleased
+  | ReleaseUnpublished
+  deriving stock (Bounded, Enum, Eq, Generic, Ord, Show)
+
+instance FromJSON ReleaseActivityType where
+  parseJSON =
+    Aeson.withText "ReleaseActivityType" $ hoistFail' . parseReleaseActivityType
+
+instance ToJSON ReleaseActivityType where
+  toJSON = Aeson.String . renderReleaseActivityType
+
+renderReleaseActivityType :: ReleaseActivityType -> Text
+renderReleaseActivityType = \case
+  ReleaseCreated -> "created"
+  ReleaseDeleted -> "deleted"
+  ReleaseEdited -> "edited"
+  ReleasePrereleased -> "prereleased"
+  ReleasePublished -> "published"
+  ReleaseReleased -> "released"
+  ReleaseUnpublished -> "unpublished"
+
+parseReleaseActivityType :: Text -> Either String ReleaseActivityType
+parseReleaseActivityType t =
+  maybe (Left [i|Unknown ReleaseActivityType: #{t}|]) Right $
+    inverseMap renderReleaseActivityType t
+
+data WorkflowCallInputType
+  = WorkflowCallInputTypeBoolean
+  | WorkflowCallInputTypeNumber
+  | WorkflowCallInputTypeString
+  deriving stock (Bounded, Enum, Eq, Generic, Ord, Show)
+
+instance FromJSON WorkflowCallInputType where
+  parseJSON =
+    Aeson.withText "WorkflowCallInputType" $ hoistFail' . parseWorkflowCallInputType
+
+instance ToJSON WorkflowCallInputType where
+  toJSON = Aeson.String . renderWorkflowCallInputType
+
+renderWorkflowCallInputType :: WorkflowCallInputType -> Text
+renderWorkflowCallInputType = \case
+  WorkflowCallInputTypeBoolean -> "boolean"
+  WorkflowCallInputTypeNumber -> "number"
+  WorkflowCallInputTypeString -> "string"
+
+parseWorkflowCallInputType :: Text -> Either String WorkflowCallInputType
+parseWorkflowCallInputType t =
+  maybe (Left [i|Unknown WorkflowCallInputType: #{t}|]) Right $
+    inverseMap renderWorkflowCallInputType t
+
+data WorkflowCallInput = WorkflowCallInput
+  { description :: Maybe Text,
+    default_ :: Maybe Aeson.Value,
+    required :: Maybe Bool,
+    type_ :: WorkflowCallInputType
+  }
+  deriving stock (Eq, Generic, Ord, Show)
+
+instance FromJSON WorkflowCallInput where
+  parseJSON = Aeson.withObject "WorkflowCallInput" $ \o -> do
+    description <- o .:? "description"
+    default_ <- o .:? "default"
+    required <- o .:? "required"
+    type_ <- o .: "type"
+    pure WorkflowCallInput {..}
+
+instance ToJSON WorkflowCallInput where
+  toJSON WorkflowCallInput {..} =
+    Aeson.object $
+      catMaybes
+        [ ("description" .=) <$> description,
+          ("default" .=) <$> default_,
+          ("required" .=) <$> required,
+          Just $ "type" .= type_
+        ]
+
+data WorkflowCallOutput = WorkflowCallOutput
+  { description :: Maybe Text,
+    value :: Text
+  }
+  deriving stock (Eq, Generic, Ord, Show)
+
+instance FromJSON WorkflowCallOutput where
+  parseJSON = Aeson.withObject "WorkflowCallOutput" $ \o -> do
+    description <- o .:? "description"
+    value <- o .: "value"
+    pure WorkflowCallOutput {..}
+
+instance ToJSON WorkflowCallOutput where
+  toJSON WorkflowCallOutput {..} =
+    Aeson.object $
+      catMaybes
+        [ ("description" .=) <$> description,
+          Just $ "value" .= value
+        ]
+
+data WorkflowCallSecret = WorkflowCallSecret
+  { description :: Maybe Text,
+    required :: Maybe Bool
+  }
+  deriving stock (Eq, Generic, Ord, Show)
+
+instance FromJSON WorkflowCallSecret where
+  parseJSON = Aeson.withObject "WorkflowCallSecret" $ \o -> do
+    description <- o .:? "description"
+    required <- o .:? "required"
+    pure WorkflowCallSecret {..}
+
+instance ToJSON WorkflowCallSecret where
+  toJSON WorkflowCallSecret {..} =
+    Aeson.object $
+      catMaybes
+        [ ("description" .=) <$> description,
+          ("required" .=) <$> required
+        ]
+
+data WorkflowCallAttributes = WorkflowCallAttributes
+  { inputs :: Maybe (Map Text WorkflowCallInput),
+    outputs :: Maybe (Map Text WorkflowCallOutput),
+    secrets :: Maybe (Map Text WorkflowCallSecret)
+  }
+  deriving stock (Eq, Generic, Ord, Show)
+
+instance FromJSON WorkflowCallAttributes where
+  parseJSON = Aeson.withObject "WorkflowCallAttributes" $ \o -> do
+    inputs <- o .:? "inputs"
+    outputs <- o .:? "outputs"
+    secrets <- o .:? "secrets"
+    pure WorkflowCallAttributes {..}
+
+instance ToJSON WorkflowCallAttributes where
+  toJSON WorkflowCallAttributes {..} =
+    Aeson.object $
+      catMaybes
+        [ ("inputs" .=) <$> inputs,
+          ("outputs" .=) <$> outputs,
+          ("secrets" .=) <$> secrets
+        ]
+
+data WorkflowDispatchInputType
+  = WorkflowDispatchInputTypeBoolean
+  | WorkflowDispatchInputTypeChoice (NonEmpty Text)
+  | WorkflowDispatchInputTypeEnvironment
+  | WorkflowDispatchInputTypeNumber
+  | WorkflowDispatchInputTypeString
+  deriving stock (Eq, Generic, Ord, Show)
+
+instance FromJSON WorkflowDispatchInputType where
+  parseJSON = Aeson.withObject "WorkflowDispatchInputType" $ \o -> do
+    t :: Text <- o .: "type"
+    maybeOptions <- o .:? "options"
+    case t of
+      "boolean" -> pure WorkflowDispatchInputTypeBoolean
+      "choice" -> case maybeOptions of
+        Just (op :| ops) -> pure $ WorkflowDispatchInputTypeChoice $ op :| ops
+        Nothing -> fail "Expected a non-empty list of options"
+      "environment" -> pure WorkflowDispatchInputTypeEnvironment
+      "number" -> pure WorkflowDispatchInputTypeNumber
+      "string" -> pure WorkflowDispatchInputTypeString
+      _ -> fail [i|Unknown WorkflowDispatchInputType: #{t}|]
+
+data WorkflowDispatchInput = WorkflowDispatchInput
+  { description :: Maybe Text,
+    default_ :: Maybe Aeson.Value,
+    required :: Maybe Bool,
+    type_ :: Maybe WorkflowDispatchInputType
+  }
+  deriving stock (Eq, Generic, Ord, Show)
+
+instance FromJSON WorkflowDispatchInput where
+  parseJSON = Aeson.withObject "WorkflowDispatchInput" $ \o -> do
+    description <- o .:? "description"
+    default_ <- o .:? "default"
+    required <- o .:? "required"
+    workflowDispatchInputTypeText :: Maybe Text <- o .:? "type"
+    type_ <-
+      maybe
+        (pure Nothing)
+        (const . Aeson.parseJSON $ Aeson.Object o)
+        workflowDispatchInputTypeText
+    pure WorkflowDispatchInput {..}
+
+instance ToJSON WorkflowDispatchInput where
+  toJSON WorkflowDispatchInput {..} =
+    Aeson.object $
+      catMaybes
+        [ ("description" .=) <$> description,
+          ("default" .=) <$> default_,
+          ("required" .=) <$> required,
+          ("type" .=)
+            . ( \case
+                  WorkflowDispatchInputTypeBoolean ->
+                    "boolean" :: Text
+                  WorkflowDispatchInputTypeChoice _ ->
+                    "choice"
+                  WorkflowDispatchInputTypeEnvironment ->
+                    "environment"
+                  WorkflowDispatchInputTypeNumber ->
+                    "number"
+                  WorkflowDispatchInputTypeString ->
+                    "string"
+              )
+            <$> type_,
+          type_
+            >>= ( \case
+                    WorkflowDispatchInputTypeChoice ops ->
+                      Just $ "options" .= ops
+                    _ ->
+                      Nothing
+                )
+        ]
+
+newtype WorkflowDispatchAttributes = WorkflowDispatchAttributes
+  { inputs :: Maybe (Map Text WorkflowDispatchInput)
+  }
+  deriving stock (Eq, Generic, Ord, Show)
+
+instance FromJSON WorkflowDispatchAttributes where
+  parseJSON = Aeson.withObject "WorkflowDispatchAttributes" $ \o -> do
+    inputs <- o .:? "inputs"
+    pure WorkflowDispatchAttributes {..}
+
+instance ToJSON WorkflowDispatchAttributes where
+  toJSON WorkflowDispatchAttributes {..} =
+    Aeson.object $
+      catMaybes
+        [ ("inputs" .=) <$> inputs
+        ]
+
+data WorkflowRunActivityType
+  = WorkflowRunCompleted
+  | WorkflowRunInProgress
+  | WorkflowRunRequested
+  deriving stock (Bounded, Enum, Eq, Generic, Ord, Show)
+
+instance FromJSON WorkflowRunActivityType where
+  parseJSON =
+    Aeson.withText "WorkflowRunActivityType" $ hoistFail' . parseWorkflowRunActivityType
+
+instance ToJSON WorkflowRunActivityType where
+  toJSON = Aeson.String . renderWorkflowRunActivityType
+
+renderWorkflowRunActivityType :: WorkflowRunActivityType -> Text
+renderWorkflowRunActivityType = \case
+  WorkflowRunCompleted -> "completed"
+  WorkflowRunInProgress -> "in_progress"
+  WorkflowRunRequested -> "requested"
+
+parseWorkflowRunActivityType :: Text -> Either String WorkflowRunActivityType
+parseWorkflowRunActivityType t =
+  maybe (Left [i|Unknown WorkflowRunActivityType: #{t}|]) Right $
+    inverseMap renderWorkflowRunActivityType t
+
+data WorkflowRunTriggerAttributes = WorkflowRunTriggerAttributes
+  { activityTypes :: NonEmpty WorkflowRunActivityType,
+    workflows :: Maybe (NonEmpty Text),
+    branches :: Maybe (NonEmpty Text),
+    branchesIgnore :: Maybe (NonEmpty Text)
+  }
+  deriving stock (Eq, Generic, Ord, Show)
+
+instance FromJSON WorkflowRunTriggerAttributes where
+  parseJSON = Aeson.withObject "WorkflowRunTriggerAttributes" $ \o -> do
+    activityTypes <- o .: "types"
+    workflows <- o .:? "workflows"
+    branches <- o .:? "branches"
+    branchesIgnore <- o .:? "branches-ignore"
+    pure WorkflowRunTriggerAttributes {..}
+
+instance ToJSON WorkflowRunTriggerAttributes where
+  toJSON WorkflowRunTriggerAttributes {..} =
+    Aeson.object $
+      catMaybes
+        [ Just $ "types" .= activityTypes,
+          ("workflows" .=) <$> workflows,
+          ("branches" .=) <$> branches,
+          ("branches-ignore" .=) <$> branchesIgnore
+        ]
+
+-- | Comprehensive enumeration of all GitHub Actions workflow trigger events.
+--
+-- Each trigger corresponds to a specific GitHub event that can initiate a workflow run.
+-- Many triggers can be configured with activity types to specify exactly which sub-events
+-- should cause the workflow to run.
+--
+-- Common trigger examples:
+--
+-- * 'PushTrigger' - Runs on pushes to repository branches or tags
+-- * 'PullRequestTrigger' - Runs on pull request events (open, sync, close, etc.)
+-- * 'ScheduleTrigger' - Runs on a schedule using cron syntax
+-- * 'WorkflowDispatchTrigger' - Allows manual workflow execution
+-- * 'IssuesTrigger' - Runs on issue events (open, close, label, etc.)
+--
+-- For triggers with complex configuration, attribute types provide filtering options:
+--
+-- @
+-- -- Push trigger with branch filtering
+-- pushToMain :: WorkflowTrigger
+-- pushToMain = PushTrigger $ PushTriggerAttributes
+--  { branches = Just ("main" :| [])
+--  , branchesIgnore = Nothing
+--  , paths = Nothing
+--  , pathsIgnore = Nothing
+--  , tags = Nothing
+--  }
+--
+-- -- Issue trigger for specific activity types
+-- issueEvents :: WorkflowTrigger
+-- issueEvents = IssuesTrigger (IssuesOpened :| [IssuesClosed, IssuesLabeled])
+-- @
+--
+-- For complete event documentation, see: <https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows>
+data WorkflowTrigger
+  = -- | Branch protection rule events
+    BranchProtectionRuleTrigger (NonEmpty BranchProtectionRuleActivityType)
+  | -- | Check run events
+    CheckRunTrigger (NonEmpty CheckRunActivityType)
+  | -- | Check suite completion
+    CheckSuiteCompletedTrigger
+  | -- | Branch or tag creation
+    CreateTrigger
+  | -- | Branch or tag deletion
+    DeleteTrigger
+  | -- | Deployment events
+    DeploymentTrigger
+  | -- | Deployment status changes
+    DeploymentStatusTrigger
+  | -- | Discussion events
+    DiscussionTrigger (NonEmpty DiscussionActivityType)
+  | -- | Discussion comment events
+    DiscussionCommentTrigger (NonEmpty DiscussionCommentActivityType)
+  | -- | Repository fork events
+    ForkTrigger
+  | -- | Wiki page events
+    GollumTrigger
+  | -- | Issue comment events
+    IssueCommentTrigger (NonEmpty IssueCommentActivityType)
+  | -- | Issue events
+    IssuesTrigger (NonEmpty IssuesActivityType)
+  | -- | Label events
+    LabelTrigger (NonEmpty LabelActivityType)
+  | -- | Merge group check requests
+    MergeGroupChecksRequestedTrigger
+  | -- | Milestone events
+    MilestoneTrigger (NonEmpty MilestoneActivityType)
+  | -- | GitHub Pages build events
+    PageBuildTrigger
+  | -- | Repository publicity changes
+    PublicTrigger
+  | -- | Pull request events with filtering
+    PullRequestTrigger PullRequestTriggerAttributes
+  | -- | Pull request review events
+    PullRequestReviewTrigger (NonEmpty PullRequestReviewActivityType)
+  | -- | PR review comment events
+    PullRequestReviewCommentTrigger (NonEmpty PullRequestReviewCommentActivityType)
+  | -- | Pull request target events with filtering
+    PullRequestTargetTrigger PullRequestTargetTriggerAttributes
+  | -- | Push events with filtering
+    PushTrigger PushTriggerAttributes
+  | -- | Package registry events
+    RegistryPackageTrigger (NonEmpty RegistryPackageActivityType)
+  | -- | Release events
+    ReleaseTrigger (NonEmpty ReleaseActivityType)
+  | -- | External repository dispatch events
+    RepositoryDispatchTrigger (NonEmpty Text)
+  | -- | Scheduled events (cron expressions)
+    ScheduleTrigger (NonEmpty Text)
+  | -- | Commit status events
+    StatusTrigger
+  | -- | Repository watch events
+    WatchStartedTrigger
+  | -- | Reusable workflow calls
+    WorkflowCallTrigger WorkflowCallAttributes
+  | -- | Manual workflow dispatch
+    WorkflowDispatchTrigger WorkflowDispatchAttributes
+  | -- | Workflow run events with filtering
+    WorkflowRunTrigger WorkflowRunTriggerAttributes
+  deriving stock (Eq, Generic, Ord, Show)
+
+instance FromJSON WorkflowTrigger where
+  parseJSON = Aeson.withObject "WorkflowTrigger" $ \o -> do
+    maybeBranchProtectionRuleActivityTypes <-
+      maybeActivityTypesInNestedObject o "branch_protection_rule"
+    maybeCheckRunActivityTypes <- maybeActivityTypesInNestedObject o "check_run"
+    maybeCheckSuiteActivityTypes :: Maybe (NonEmpty Text) <-
+      maybeActivityTypesInNestedObject o "check_suite"
+    maybeCreate :: Maybe Aeson.Value <- o .:? "create"
+    maybeDelete :: Maybe Aeson.Value <- o .:? "delete"
+    maybeDeployment :: Maybe Aeson.Value <- o .:? "deployment"
+    maybeDeploymentStatus :: Maybe Aeson.Value <- o .:? "deployment_status"
+    maybeDiscussionActivityTypes <-
+      maybeActivityTypesInNestedObject o "discussion"
+    maybeDiscussionCommentActivityTypes <-
+      maybeActivityTypesInNestedObject o "discussion_comment"
+    maybeFork :: Maybe Aeson.Value <- o .:? "fork"
+    maybeGollum :: Maybe Aeson.Value <- o .:? "gollum"
+    maybeIssueCommentActivityTypes <-
+      maybeActivityTypesInNestedObject o "issue_comment"
+    maybeIssuesActivityTypes <- maybeActivityTypesInNestedObject o "issues"
+    maybeLabelActivityTypes <- maybeActivityTypesInNestedObject o "label"
+    maybeMergeGroupChecksRequestedActivityTypes :: Maybe (NonEmpty Text) <-
+      maybeActivityTypesInNestedObject o "merge_group"
+    maybeMilestoneActivityTypes <- maybeActivityTypesInNestedObject o "milestone"
+    maybePageBuild :: Maybe Aeson.Value <- o .:? "page_build"
+    maybePublic :: Maybe Aeson.Value <- o .:? "public"
+    maybePullRequestTriggerAttributes <- o .:? "pull_request"
+    maybePullRequestReviewActivityTypes <-
+      maybeActivityTypesInNestedObject o "pull_request_review"
+    maybePullRequestReviewCommentActivityTypes <-
+      maybeActivityTypesInNestedObject o "pull_request_review_comment"
+    maybePullRequestTargetTriggerAttributes <- o .:? "pull_request_target"
+    maybePushTriggerAttributes <- o .:? "push"
+    maybeRegistryPackageActivityTypes <- maybeActivityTypesInNestedObject o "registry_package"
+    maybeReleaseActivityTypes <- maybeActivityTypesInNestedObject o "release"
+    maybeRepositoryDispatchActivityTypes :: Maybe (NonEmpty Text) <-
+      maybeActivityTypesInNestedObject o "repository_dispatch"
+    maybeScheduleCrons <- maybeScheduleCronsParser o
+    maybeStatus :: Maybe Aeson.Value <- o .:? "status"
+    maybeWatchStartedActivityTypes :: Maybe (NonEmpty Text) <-
+      maybeActivityTypesInNestedObject o "watch"
+    maybeWorkflowCall <- o .:? "workflow_call"
+    maybeWorkflowDispatch <- o .:? "workflow_dispatch"
+    maybeWorkflowRun <- o .:? "workflow_run"
+    hoistFail'
+      . fromMaybe (Left [i|Invalid workflow trigger (`on` property):\n#{AesonKeyMap.keys o}|])
+      $ (Right . BranchProtectionRuleTrigger <$> maybeBranchProtectionRuleActivityTypes)
+        <|> (Right . CheckRunTrigger <$> maybeCheckRunActivityTypes)
+        <|> ( Right CheckSuiteCompletedTrigger
+                <$ guard
+                  (maybeCheckSuiteActivityTypes == Just ("completed" :| []))
+            )
+        <|> (Right CreateTrigger <$ guard (isJust maybeCreate))
+        <|> (Right DeleteTrigger <$ guard (isJust maybeDelete))
+        <|> (Right DeploymentTrigger <$ guard (isJust maybeDeployment))
+        <|> (Right DeploymentStatusTrigger <$ guard (isJust maybeDeploymentStatus))
+        <|> (Right . DiscussionTrigger <$> maybeDiscussionActivityTypes)
+        <|> (Right . DiscussionCommentTrigger <$> maybeDiscussionCommentActivityTypes)
+        <|> (Right ForkTrigger <$ guard (isJust maybeFork))
+        <|> (Right GollumTrigger <$ guard (isJust maybeGollum))
+        <|> (Right . IssueCommentTrigger <$> maybeIssueCommentActivityTypes)
+        <|> (Right . IssuesTrigger <$> maybeIssuesActivityTypes)
+        <|> (Right . LabelTrigger <$> maybeLabelActivityTypes)
+        <|> ( Right MergeGroupChecksRequestedTrigger
+                <$ guard
+                  (maybeMergeGroupChecksRequestedActivityTypes == Just ("checks_requested" :| []))
+            )
+        <|> (Right . MilestoneTrigger <$> maybeMilestoneActivityTypes)
+        <|> (Right PageBuildTrigger <$ guard (isJust maybePageBuild))
+        <|> (Right PublicTrigger <$ guard (isJust maybePublic))
+        <|> (Right . PullRequestTrigger <$> maybePullRequestTriggerAttributes)
+        <|> (Right . PullRequestReviewTrigger <$> maybePullRequestReviewActivityTypes)
+        <|> (Right . PullRequestReviewCommentTrigger <$> maybePullRequestReviewCommentActivityTypes)
+        <|> (Right . PullRequestTargetTrigger <$> maybePullRequestTargetTriggerAttributes)
+        <|> (Right . PushTrigger <$> maybePushTriggerAttributes)
+        <|> (Right . RegistryPackageTrigger <$> maybeRegistryPackageActivityTypes)
+        <|> (Right . ReleaseTrigger <$> maybeReleaseActivityTypes)
+        <|> (Right . RepositoryDispatchTrigger <$> maybeRepositoryDispatchActivityTypes)
+        <|> (Right . ScheduleTrigger <$> maybeScheduleCrons)
+        <|> (Right StatusTrigger <$ guard (isJust maybeStatus))
+        <|> ( Right WatchStartedTrigger
+                <$ guard
+                  (maybeWatchStartedActivityTypes == Just ("started" :| []))
+            )
+        <|> (Right . WorkflowCallTrigger <$> maybeWorkflowCall)
+        <|> (Right . WorkflowDispatchTrigger <$> maybeWorkflowDispatch)
+        <|> (Right . WorkflowRunTrigger <$> maybeWorkflowRun)
+    where
+      maybeActivityTypesInNestedObject ::
+        (FromJSON a) =>
+        Aeson.Object ->
+        Aeson.Key ->
+        Aeson.Parser (Maybe (NonEmpty a))
+      maybeActivityTypesInNestedObject o attributeName =
+        o
+          .:? attributeName
+          >>= maybe
+            (pure Nothing)
+            (Aeson.withObject "ActivityTypes" (.: "types"))
+
+      maybeScheduleCronsParser :: Aeson.Object -> Aeson.Parser (Maybe (NonEmpty Text))
+      maybeScheduleCronsParser o =
+        o
+          .:? "schedule"
+          >>= maybe
+            (pure Nothing)
+            ( Aeson.withArray "Crons" $ \a ->
+                fmap nonEmpty . for (Vector.toList a) $
+                  Aeson.withObject "Cron" (.: "cron")
+            )
+
+instance ToJSONKey WorkflowTrigger where
+  toJSONKey =
+    Aeson.toJSONKeyText $ \case
+      BranchProtectionRuleTrigger _ -> "branch_protection_rule"
+      CheckRunTrigger _ -> "check_run"
+      CheckSuiteCompletedTrigger -> "check_suite"
+      CreateTrigger -> "create"
+      DeleteTrigger -> "delete"
+      DeploymentTrigger -> "deployment"
+      DeploymentStatusTrigger -> "deployment_status"
+      DiscussionTrigger _ -> "discussion"
+      DiscussionCommentTrigger _ -> "discussion_comment"
+      ForkTrigger -> "fork"
+      GollumTrigger -> "gollum"
+      IssueCommentTrigger _ -> "issue_comment"
+      IssuesTrigger _ -> "issues"
+      LabelTrigger _ -> "label"
+      MergeGroupChecksRequestedTrigger -> "merge_group"
+      MilestoneTrigger _ -> "milestone"
+      PageBuildTrigger -> "page_build"
+      PublicTrigger -> "public"
+      PullRequestTrigger _ -> "pull_request"
+      PullRequestReviewTrigger _ -> "pull_request_review"
+      PullRequestReviewCommentTrigger _ -> "pull_request_review_comment"
+      PullRequestTargetTrigger _ -> "pull_request_target"
+      PushTrigger _ -> "push"
+      RegistryPackageTrigger _ -> "registry_package"
+      ReleaseTrigger _ -> "release"
+      RepositoryDispatchTrigger _ -> "repository_dispatch"
+      ScheduleTrigger _ -> "schedule"
+      StatusTrigger -> "status"
+      WatchStartedTrigger -> "watch"
+      WorkflowCallTrigger _ -> "workflow_call"
+      WorkflowDispatchTrigger _ -> "workflow_dispatch"
+      WorkflowRunTrigger _ -> "workflow_run"
+
+instance ToJSON WorkflowTrigger where
+  toJSON trigger =
+    Aeson.toJSON . Map.singleton trigger $ case trigger of
+      BranchProtectionRuleTrigger activityTypes ->
+        Aeson.object ["types" .= activityTypes]
+      CheckRunTrigger activityTypes ->
+        Aeson.object ["types" .= activityTypes]
+      CheckSuiteCompletedTrigger ->
+        Aeson.object ["types" .= [Aeson.String "completed"]]
+      CreateTrigger ->
+        Aeson.object []
+      DeleteTrigger ->
+        Aeson.object []
+      DeploymentTrigger ->
+        Aeson.object []
+      DeploymentStatusTrigger ->
+        Aeson.object []
+      DiscussionTrigger activityTypes ->
+        Aeson.object ["types" .= activityTypes]
+      DiscussionCommentTrigger activityTypes ->
+        Aeson.object ["types" .= activityTypes]
+      ForkTrigger ->
+        Aeson.object []
+      GollumTrigger ->
+        Aeson.object []
+      IssueCommentTrigger activityTypes ->
+        Aeson.object ["types" .= activityTypes]
+      IssuesTrigger activityTypes ->
+        Aeson.object ["types" .= activityTypes]
+      LabelTrigger activityTypes ->
+        Aeson.object ["types" .= activityTypes]
+      MergeGroupChecksRequestedTrigger ->
+        Aeson.object ["types" .= [Aeson.String "checks_requested"]]
+      MilestoneTrigger activityTypes ->
+        Aeson.object ["types" .= activityTypes]
+      PageBuildTrigger ->
+        Aeson.object []
+      PublicTrigger ->
+        Aeson.object []
+      PullRequestTrigger attrs ->
+        Aeson.toJSON attrs
+      PullRequestReviewTrigger activityTypes ->
+        Aeson.object ["types" .= activityTypes]
+      PullRequestReviewCommentTrigger activityTypes ->
+        Aeson.object ["types" .= activityTypes]
+      PullRequestTargetTrigger attrs ->
+        Aeson.toJSON attrs
+      PushTrigger attrs ->
+        Aeson.toJSON attrs
+      RegistryPackageTrigger activityTypes ->
+        Aeson.object ["types" .= activityTypes]
+      ReleaseTrigger activityTypes ->
+        Aeson.object ["types" .= activityTypes]
+      RepositoryDispatchTrigger activityTypes ->
+        Aeson.object ["types" .= activityTypes]
+      ScheduleTrigger crons ->
+        Aeson.toJSON $ (\cron -> Aeson.object ["cron" .= cron]) <$> crons
+      StatusTrigger ->
+        Aeson.object []
+      WatchStartedTrigger ->
+        Aeson.object ["types" .= [Aeson.String "started"]]
+      WorkflowCallTrigger attrs ->
+        Aeson.toJSON attrs
+      WorkflowDispatchTrigger attrs ->
+        Aeson.toJSON attrs
+      WorkflowRunTrigger attrs ->
+        Aeson.toJSON attrs
+
+gen :: (MonadGen m) => m WorkflowTrigger
+gen =
+  Gen.choice
+    [ BranchProtectionRuleTrigger <$> Gen.nonEmpty (Range.linear 1 3) Gen.enumBounded,
+      CheckRunTrigger <$> Gen.nonEmpty (Range.linear 1 3) Gen.enumBounded,
+      pure CheckSuiteCompletedTrigger,
+      pure CreateTrigger,
+      pure DeleteTrigger,
+      pure DeploymentTrigger,
+      pure DeploymentStatusTrigger,
+      DiscussionTrigger <$> Gen.nonEmpty (Range.linear 1 3) Gen.enumBounded,
+      DiscussionCommentTrigger <$> Gen.nonEmpty (Range.linear 1 3) Gen.enumBounded,
+      pure ForkTrigger,
+      pure GollumTrigger,
+      IssueCommentTrigger <$> Gen.nonEmpty (Range.linear 1 3) Gen.enumBounded,
+      IssuesTrigger <$> Gen.nonEmpty (Range.linear 1 3) Gen.enumBounded,
+      LabelTrigger <$> Gen.nonEmpty (Range.linear 1 3) Gen.enumBounded,
+      pure MergeGroupChecksRequestedTrigger,
+      MilestoneTrigger <$> Gen.nonEmpty (Range.linear 1 3) Gen.enumBounded,
+      pure PageBuildTrigger,
+      pure PublicTrigger,
+      PullRequestTrigger <$> genPullRequestTriggerAttributes,
+      PullRequestReviewTrigger <$> Gen.nonEmpty (Range.linear 1 3) Gen.enumBounded,
+      PullRequestReviewCommentTrigger <$> Gen.nonEmpty (Range.linear 1 3) Gen.enumBounded,
+      PullRequestTargetTrigger <$> genPullRequestTargetAttributes,
+      PushTrigger <$> genPushTriggerAttributes,
+      RegistryPackageTrigger <$> Gen.nonEmpty (Range.linear 1 3) Gen.enumBounded,
+      ReleaseTrigger <$> Gen.nonEmpty (Range.linear 1 3) Gen.enumBounded,
+      RepositoryDispatchTrigger <$> Gen.nonEmpty (Range.linear 1 3) genText,
+      ScheduleTrigger <$> Gen.nonEmpty (Range.linear 1 5) genText,
+      pure StatusTrigger,
+      pure WatchStartedTrigger,
+      WorkflowCallTrigger <$> genWorkflowCallAttributes,
+      WorkflowDispatchTrigger <$> genWorkflowDispatchAttributes,
+      WorkflowRunTrigger <$> genWorkflowRunTriggerAttributes
+    ]
+
+genText :: (MonadGen m) => m Text
+genText = Gen.text (Range.linear 1 5) Gen.alphaNum
+
+genMap :: (MonadGen m) => m a -> m (Map Text a)
+genMap ga = Gen.map (Range.linear 1 5) $ liftA2 (,) genText ga
+
+genPullRequestTargetAttributes :: (MonadGen m) => m PullRequestTargetTriggerAttributes
+genPullRequestTargetAttributes = do
+  activityTypes <-
+    Gen.maybe $
+      Gen.nonEmpty (Range.linear 1 3) Gen.enumBounded
+  branches <-
+    Gen.maybe $ Gen.nonEmpty (Range.linear 1 5) genText
+  branchesIgnore <-
+    Gen.maybe $ Gen.nonEmpty (Range.linear 1 5) genText
+  paths <-
+    Gen.maybe $ Gen.nonEmpty (Range.linear 1 5) genText
+  pathsIgnore <-
+    Gen.maybe $ Gen.nonEmpty (Range.linear 1 5) genText
+  pure PullRequestTargetTriggerAttributes {..}
+
+genPullRequestTriggerAttributes :: (MonadGen m) => m PullRequestTriggerAttributes
+genPullRequestTriggerAttributes = do
+  activityTypes <-
+    Gen.maybe $
+      Gen.nonEmpty (Range.linear 1 3) Gen.enumBounded
+  branches <-
+    Gen.maybe $ Gen.nonEmpty (Range.linear 1 5) genText
+  branchesIgnore <-
+    Gen.maybe $ Gen.nonEmpty (Range.linear 1 5) genText
+  paths <-
+    Gen.maybe $ Gen.nonEmpty (Range.linear 1 5) genText
+  pathsIgnore <-
+    Gen.maybe $ Gen.nonEmpty (Range.linear 1 5) genText
+  pure PullRequestTriggerAttributes {..}
+
+genPushTriggerAttributes :: (MonadGen m) => m PushTriggerAttributes
+genPushTriggerAttributes = do
+  branches <-
+    Gen.maybe $ Gen.nonEmpty (Range.linear 1 5) genText
+  branchesIgnore <-
+    Gen.maybe $ Gen.nonEmpty (Range.linear 1 5) genText
+  paths <-
+    Gen.maybe $ Gen.nonEmpty (Range.linear 1 5) genText
+  pathsIgnore <-
+    Gen.maybe $ Gen.nonEmpty (Range.linear 1 5) genText
+  tags <-
+    Gen.maybe $ Gen.nonEmpty (Range.linear 1 5) genText
+  pure PushTriggerAttributes {..}
+
+genWorkflowCallAttributes :: (MonadGen m) => m WorkflowCallAttributes
+genWorkflowCallAttributes = do
+  inputs <- Gen.maybe $ genMap genWorkflowCallInput
+  outputs <- Gen.maybe $ genMap genWorkflowCallOutput
+  secrets <- Gen.maybe $ genMap genWorkflowCallSecret
+  pure WorkflowCallAttributes {..}
+
+genWorkflowCallInput :: (MonadGen m) => m WorkflowCallInput
+genWorkflowCallInput = do
+  description <- Gen.maybe genText
+  default_ <-
+    Gen.maybe $
+      Aeson.String
+        <$> Gen.text (Range.linear 3 20) Gen.alphaNum
+  required <- Gen.maybe Gen.bool
+  type_ <- Gen.enumBounded
+  pure WorkflowCallInput {..}
+
+genWorkflowCallOutput :: (MonadGen m) => m WorkflowCallOutput
+genWorkflowCallOutput = do
+  description <- Gen.maybe genText
+  value <- genText
+  pure WorkflowCallOutput {..}
+
+genWorkflowCallSecret :: (MonadGen m) => m WorkflowCallSecret
+genWorkflowCallSecret = do
+  description <- Gen.maybe genText
+  required <- Gen.maybe Gen.bool
+  pure WorkflowCallSecret {..}
+
+genWorkflowDispatchAttributes :: (MonadGen m) => m WorkflowDispatchAttributes
+genWorkflowDispatchAttributes = do
+  inputs <- Gen.maybe $ genMap genWorkflowDispatchInput
+  pure WorkflowDispatchAttributes {..}
+
+genWorkflowDispatchInput :: (MonadGen m) => m WorkflowDispatchInput
+genWorkflowDispatchInput = do
+  description <- Gen.maybe genText
+  default_ <-
+    Gen.maybe $
+      Aeson.String
+        <$> Gen.text (Range.linear 3 20) Gen.alphaNum
+  required <- Gen.maybe Gen.bool
+  type_ <- Gen.maybe genWorkflowDispatchInputType
+  pure WorkflowDispatchInput {..}
+
+genWorkflowDispatchInputType :: (MonadGen m) => m WorkflowDispatchInputType
+genWorkflowDispatchInputType = do
+  Gen.choice
+    [ pure WorkflowDispatchInputTypeBoolean,
+      WorkflowDispatchInputTypeChoice <$> Gen.nonEmpty (Range.linear 1 5) genText,
+      pure WorkflowDispatchInputTypeEnvironment,
+      pure WorkflowDispatchInputTypeNumber,
+      pure WorkflowDispatchInputTypeString
+    ]
+
+genWorkflowRunTriggerAttributes :: (MonadGen m) => m WorkflowRunTriggerAttributes
+genWorkflowRunTriggerAttributes = do
+  activityTypes <- Gen.nonEmpty (Range.linear 1 3) Gen.enumBounded
+  workflows <- Gen.maybe $ Gen.nonEmpty (Range.linear 1 5) genText
+  branches <- Gen.maybe $ Gen.nonEmpty (Range.linear 1 5) genText
+  branchesIgnore <- Gen.maybe $ Gen.nonEmpty (Range.linear 1 5) genText
+  pure WorkflowRunTriggerAttributes {..}
diff --git a/test/Language/Github/Actions/WorkflowTest.hs b/test/Language/Github/Actions/WorkflowTest.hs
new file mode 100644
--- /dev/null
+++ b/test/Language/Github/Actions/WorkflowTest.hs
@@ -0,0 +1,68 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+{-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-}
+
+module Language.Github.Actions.WorkflowTest where
+
+import qualified Data.Aeson as Aeson
+import Data.Bifunctor (first)
+import qualified Data.ByteString as BS
+import Data.List (isPrefixOf)
+import qualified Data.Text as Text
+import Data.Text.Encoding (encodeUtf8)
+import qualified Data.Yaml as Yaml
+import Hedgehog (Gen, Property, forAll, property, tripping)
+import Language.Github.Actions.Workflow (Workflow)
+import qualified Language.Github.Actions.Workflow as Workflow
+import System.FilePath (takeBaseName)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.Golden (findByExtension)
+import Test.Tasty.Golden.Extra.GoldenVsToYAML (goldenVsToYaml)
+import Text.Show.Pretty (ppShow)
+
+test_goldenWorkflowFromYaml :: IO TestTree
+test_goldenWorkflowFromYaml = do
+  testYamlFiles <-
+    filter (not . (reverse ".golden.yml" `isPrefixOf`) . reverse)
+      <$> findByExtension [".yml"] "test/golden"
+  pure $
+    testGroup
+      "Yaml Roundtrip"
+      [ runGoldenVsToYamlFileTest testYamlFilePath
+        | testYamlFilePath <- testYamlFiles
+      ]
+  where
+    runGoldenVsToYamlFileTest testYamlFilePath =
+      let outputFilePath =
+            (<> ".golden.yml")
+              . reverse
+              . drop 4
+              $ reverse testYamlFilePath
+          haskellOutputFilePath =
+            (<> ".hs.txt")
+              . reverse
+              . drop 4
+              $ reverse testYamlFilePath
+       in goldenVsToYaml
+            ("roundtrip " <> takeBaseName testYamlFilePath)
+            testYamlFilePath
+            $ do
+              putStrLn $ "roundtrip " <> takeBaseName testYamlFilePath
+              eitherWorkflow <- Yaml.decodeFileEither @Workflow testYamlFilePath
+              either
+                (BS.writeFile outputFilePath >> (\_ -> fail "YAML decoding failed"))
+                (\workflow -> writeOutputFiles outputFilePath haskellOutputFilePath workflow >> pure workflow)
+                $ first (encodeUtf8 . Text.pack . Yaml.prettyPrintParseException) eitherWorkflow
+    writeOutputFiles :: FilePath -> FilePath -> Workflow -> IO ()
+    writeOutputFiles outputFilePath haskellOutputFilePath workflow = do
+      BS.writeFile outputFilePath (Yaml.encode workflow)
+        >> BS.writeFile haskellOutputFilePath (encodeUtf8 . Text.pack $ ppShow workflow)
+
+hprop_WorkflowRoundTrip :: Property
+hprop_WorkflowRoundTrip =
+  trippingJSON Workflow.gen
+
+trippingJSON :: (Eq a, Show a, Aeson.FromJSON a, Aeson.ToJSON a) => Gen a -> Property
+trippingJSON gen = property $ do
+  a <- forAll gen
+  tripping a Aeson.encode Aeson.eitherDecode
diff --git a/test/Main.hs b/test/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/Main.hs
@@ -0,0 +1,1 @@
+{-# OPTIONS_GHC -F -pgmF tasty-discover -optF --tree-display #-}
diff --git a/test/golden/configuration-main.golden.yml b/test/golden/configuration-main.golden.yml
new file mode 100644
--- /dev/null
+++ b/test/golden/configuration-main.golden.yml
@@ -0,0 +1,550 @@
+concurrency:
+  cancel-in-progress: false
+  group: ${{ github.ref }}
+env:
+  SYSTEM_NAME: Product Configuration
+jobs:
+  build:
+    needs:
+    - validateContent
+    runs-on: ubuntu-20.04
+    steps:
+    - if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/stable'
+      name: checkout branch
+      uses: bellroy/gha-workflows/composite/checkout@master
+      with:
+        fetch-depth: '0'
+        token: ${{ env.GH_TOKEN }}
+    - if: github.ref != 'refs/heads/master' && github.ref != 'refs/heads/stable'
+      name: checkout branch
+      uses: bellroy/gha-workflows/composite/checkout@master
+      with:
+        fetch-depth: '1'
+        token: ${{ env.GH_TOKEN }}
+    - id: extract_branch
+      name: Extract branch name
+      run: |
+        echo "branch=${GITHUB_REF#refs/heads/}" >> $GITHUB_OUTPUT
+      shell: bash
+    - if: github.ref == 'refs/heads/master'
+      name: Set env variables for staging
+      run: |
+        echo "DESTINATION=Staging Environment" >> $GITHUB_ENV
+        echo "ENVIRONMENT=staging" >> $GITHUB_ENV
+    - if: github.ref == 'refs/heads/stable'
+      name: Set env variables for production
+      run: |
+        echo "DESTINATION=Production Environment" >> $GITHUB_ENV
+        echo "ENVIRONMENT=production" >> $GITHUB_ENV
+    - name: Install and setup nix
+      uses: bellroy/gha-workflows/composite/install-nix@master
+      with:
+        bellroy-nix-cache-access: none
+        nix-config-access-tokens: github.com=${{ secrets.READ_HASKELL_REPO_PAT }}
+    - name: Install script packages
+      run: |
+        sudo apt-get update
+        sudo apt-get install -y parallel
+    - name: Get Pull Request number
+      run: |
+        echo "PR_NUMBER=$(gh pr view --json number -q .number)" >> $GITHUB_ENV
+      working-directory: ${{ env.GITHUB_WORKSPACE }}
+    - id: changed-files
+      name: Get changed files
+      uses: tj-actions/changed-files@48d8f15b2aaa3d255ca5af3eba4870f807ce6b3c
+      with:
+        path: ${{ env.GIT_REPOSITORY_PATH }}
+    - id: check_changes
+      name: Check for CSV changes
+      run: echo "csv_changed=${{ steps.changed-files.outputs.all_changed_files ==
+        '' && '0' || contains(steps.changed-files.outputs.all_changed_files, '.csv')
+        && '1' || '0' }}" >> $GITHUB_OUTPUT
+    - if: steps.check_changes.outputs.csv_changed == '1'
+      name: Check that all configuration CSV files are sorted alphabetically
+      run: |
+        set +e
+        IFS= output=$(./scripts/check_configuration_csvs_sorted 2>&1)
+        status=$?
+
+        if (($status)); then
+          echo "Configuration CSV files are not sorted:" >>/tmp/pr-comment.txt
+          echo "" >>/tmp/pr-comment.txt
+          echo "\`\`\`" >>/tmp/pr-comment.txt
+          echo $output >>/tmp/pr-comment.txt
+          echo "\`\`\`" >>/tmp/pr-comment.txt
+          exit $status
+        else
+          echo "All configuration CSV files are sorted."
+        fi
+    - id: fetch_byproduct
+      name: Fetch byproduct
+      uses: bellroy/gha-workflows/composite/get-tool@master
+      with:
+        token: ${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}
+        tool-name: byproduct
+        tool-version: latest
+    - id: fetch_gitapult
+      name: Fetch gitapult
+      uses: bellroy/gha-workflows/composite/get-tool@master
+      with:
+        token: ${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}
+        tool-name: gitapult
+        tool-version: latest
+    - id: fetch_config-rules-cli
+      name: Fetch config-rules-cli
+      uses: bellroy/gha-workflows/composite/get-tool@master
+      with:
+        token: ${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}
+        tool-name: config-rules-cli
+        tool-version: latest
+    - id: fetch_powerful-owl
+      name: Fetch powerful-owl
+      uses: bellroy/gha-workflows/composite/get-tool@master
+      with:
+        token: ${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}
+        tool-name: powerful-owl
+        tool-version: latest
+    - name: Run byproduct once
+      run: |
+        find products/autogenerated/ -type f -name '*.json' | grep -v './\.' | xargs rm
+        ./byproduct execute --once --in products/configuration --out products/autogenerated
+    - name: Configure git
+      run: |
+        git config --global user.name "${{ github.actor }}"
+        git config --global user.email "${{ github.actor }}@users.noreply.github.com"
+    - name: Update autogenerated product files
+      run: |
+        git add products/autogenerated
+        if git commit -m "Update autogenerated product files"; then
+          echo "GENERATED_ANOTHER_COMMIT=true" >> $GITHUB_ENV
+        else
+          echo "No changes detected"
+        fi
+    - name: Update products autogenerated content blocks
+      run: |
+        scripts/update_products_autogenerated_content_blocks
+        git add products/content
+
+        if git commit -m "Update autogenerated products content blocks"; then
+          echo "GENERATED_ANOTHER_COMMIT=true" >> $GITHUB_ENV
+        else
+          echo "No changes detected"
+        fi
+    - name: Set merge base
+      run: |
+        if [ "${{ github.ref }}" = "refs/heads/master" ]; then
+          echo "MERGE_BASE=origin/stable" >> $GITHUB_ENV
+        else
+          echo "MERGE_BASE=$(git show-branch --merge-base origin/master ${{ steps.extract_branch.outputs.branch }})" >> $GITHUB_ENV
+        fi
+    - if: env.PR_NUMBER != 0
+      name: Write byproduct validate output to env variable
+      run: |
+        set +e
+        IFS= output=$(./gitapult changeset --git_root "$GITHUB_WORKSPACE" --from "${{ env.MERGE_BASE }}" | ./byproduct validate --markdown 2>&1)
+        status=$?
+
+        if (($status)); then
+          echo $output >>/tmp/pr-comment.txt
+          exit $status
+        else
+          echo "Byproduct validation succeeded."
+        fi
+    - name: Update feed autogenerated content blocks
+      run: |
+        nix run .#update_feeds_autogenerated_content_blocks
+        git add feeds/content
+
+        if git commit -m "Update autogenerated feed content blocks"; then
+          echo "GENERATED_ANOTHER_COMMIT=true" >> $GITHUB_ENV
+        else
+          echo "No changes detected"
+        fi
+    - name: Update feed autogenerated rendering data
+      run: |
+        POWERFUL_OWL_COMMAND=$GITHUB_WORKSPACE/powerful-owl POWERFUL_OWL_FOLDER=./feeds/ ./scripts/generate-rendering-data
+        git add ./feeds/page_rendering_data
+        git add ./feeds/rendering_data
+
+        if git commit -m "Update feed autogenerated rendering data"; then
+          echo "GENERATED_ANOTHER_COMMIT=true" >> $GITHUB_ENV
+        else
+          echo "No changes detected"
+        fi
+      working-directory: ${{ env.GIT_REPOSITORY_PATH }}
+    - name: Update feed golden tests
+      run: |
+        scripts/update_feeds_golden_tests
+        git add feeds/tests/golden
+
+        if git commit -m "Update autogenerated feeds golden test outputs"; then
+          echo "GENERATED_ANOTHER_COMMIT=true" >> $GITHUB_ENV
+        else
+          echo "No changes detected"
+        fi
+    - name: Update autogenerated shipping configuration files
+      run: |
+        set +e
+        IFS= output=$(./config-rules-cli shipping-options-transform --input-csv-dir shipping/configuration --output-json-dir shipping/autogenerated)
+        status=$?
+
+        if (($status)); then
+          echo "Failed to transform shipping data" >>/tmp/pr-comment.txt
+          echo "" >>/tmp/pr-comment.txt
+          echo "\`\`\`" >>/tmp/pr-comment.txt
+          echo $output >>/tmp/pr-comment.txt
+          echo "\`\`\`" >>/tmp/pr-comment.txt
+          exit $status
+        else
+          git add shipping/autogenerated
+          if git commit -m "Update autogenerated shipping configuration files"; then
+            echo "GENERATED_ANOTHER_COMMIT=true" >> $GITHUB_ENV
+          else
+            echo "No changes detected"
+          fi
+        fi
+    - name: Generate new shipping tests
+      run: |
+        SHIPPING_TEST_DIRECTORY=shipping/tests
+          find $SHIPPING_TEST_DIRECTORY -type f -name '*.result.json' -exec rm {} \;
+            ./config-rules-cli -- shipping-options-check --repo-dir .
+    - if: env.PR_NUMBER != 0 && github.ref != 'refs/heads/stable' && github.ref !=
+        'refs/heads/master'
+      name: Commit new shipping tests
+      run: |
+        if [ $(git status shipping/tests --porcelain=v1 | wc -l) -gt 0 ]
+        then
+          git add shipping/tests
+          git commit -m "Update shipping tests"
+          echo "GENERATED_ANOTHER_COMMIT=true" >> $GITHUB_ENV
+        fi
+    - name: Update autogenerated promotion configuration files
+      run: |
+        set +e
+        IFS= output=$(./config-rules-cli promotion-transform --input-csv-dir promotions/configuration --output-json-dir promotions/autogenerated)
+        status=$?
+
+        if (($status)); then
+          echo "Failed to transform promotions data" >>/tmp/pr-comment.txt
+          echo "" >>/tmp/pr-comment.txt
+          echo "\`\`\`" >>/tmp/pr-comment.txt
+          echo $output >>/tmp/pr-comment.txt
+          echo "\`\`\`" >>/tmp/pr-comment.txt
+          exit $status
+        else
+          git add promotions/autogenerated
+          if git commit -m "Update autogenerated promotions configuration files"; then
+            echo "GENERATED_ANOTHER_COMMIT=true" >> $GITHUB_ENV
+          else
+            echo "No changes detected"
+          fi
+        fi
+    - name: Generate new promotion rule tests
+      run: |
+        PROMOTIONS_TEST_DIRECTORY=promotions/tests
+          find $PROMOTIONS_TEST_DIRECTORY -type f -name '*.result.json' -exec rm {} \;
+            ./config-rules-cli -- promotion-check --repo-dir .
+    - if: env.PR_NUMBER != 0 && github.ref != 'refs/heads/stable' && github.ref !=
+        'refs/heads/master'
+      name: Commit new promotion tests
+      run: |
+        if [ $(git status promotions/tests --porcelain=v1 | wc -l) -gt 0 ]
+        then
+          git add promotions/tests
+          git commit -m "Update promotion tests"
+          echo "GENERATED_ANOTHER_COMMIT=true" >> $GITHUB_ENV
+        fi
+    - if: env.GENERATED_ANOTHER_COMMIT == 'true'
+      name: Push any new commits and exit workflow if commits pushed
+      run: |
+        git push --set-upstream origin HEAD
+
+        gh run cancel ${{ github.run_id }}
+        gh run watch ${{ github.run_id }}
+    - id: current-time
+      name: Get current time
+      run: echo "timestamp=$(date -u +"%y%m%d%H%M%S")" >> $GITHUB_OUTPUT
+    - id: extract_changes
+      if: github.ref == 'refs/heads/stable' || github.ref == 'refs/heads/master'
+      name: Set changes variable
+      run: |
+        echo "COMMIT_CHANGES<<EOF" >> $GITHUB_ENV
+        echo "$(git log --oneline --format=\"%s\" $(git tag -l [0-9]*_${{ env.ENVIRONMENT }} | tail -n 1)..${{ steps.extract_branch.outputs.branch }} | grep -v 'Merge pull request' | grep -v "Merge branch 'master' into" | sed -e 's/^"//' -e 's/"$//' | cat)" >> $GITHUB_ENV
+        echo "EOF" >> $GITHUB_ENV
+      shell: bash
+      working-directory: ${{ env.GITHUB_WORKSPACE }}
+    - env:
+        SLACK_BOT_TOKEN: ${{ secrets.BELLROY_SLACK_TOKEN }}
+      id: slack-deploy
+      if: github.ref == 'refs/heads/stable' || github.ref == 'refs/heads/master'
+      name: Slack - Notifications - Post to slack
+      uses: slackapi/slack-github-action@70cd7be8e40a46e8b0eced40b0de447bdb42f68e
+      with:
+        channel-id: ${{ env.SLACK_CHANNEL_ID }}
+        payload: |
+          {
+            "text": ":loudspeaker: *${{ github.actor }}* is deploying *${{ env.SYSTEM_NAME }}* to *${{ env.DESTINATION }}* from *${{ steps.extract_branch.outputs.branch }}*",
+            "attachments": [
+              {
+                "fallback": "Deployment summary",
+                "color": "ffd966",
+                "fields": [
+                  {
+                    "title": "What is being deployed",
+                    "value": ${{ toJSON(env.COMMIT_CHANGES) }},
+                    "short": false
+                  },
+                  {
+                    "title": "Status",
+                    "short": true,
+                    "value": "In Progress"
+                  }
+                ]
+              }
+            ]
+          }
+    - if: github.ref == 'refs/heads/master'
+      name: Push content to staging
+      run: |
+        set -e
+        # XXX: Gitapult cannot distinguish datasets, so we push all data to
+        # both endpoints and allow the server to decide what to do.
+        # Kafka Webhooks accepts all datasets at once
+        ./gitapult run \
+          --git_root "$GITHUB_WORKSPACE" \
+          --authorization_token "$KW_STAGING_API_KEY" \
+          --changeset_url "$KW_STAGING_BASE_URL/gitapult/configuration/records" \
+          --revision_url "$KW_STAGING_BASE_URL/gitapult/configuration/revision" \
+          --api_key \
+          --compress \
+          --max-payload-bytes 3000000
+
+        # XXX: V3 doesn't care about rendering data, but it is too large to send.
+        # As at 2024-02-14 gitapult does not support selecting which data to send.
+        git -C "$GIT_REPOSITORY_PATH" rm feeds/rendering_data/.gitapult.json
+        ./gitapult run \
+          --git_root "$GITHUB_WORKSPACE" \
+          --authorization_token "$SF_STAGING_API_KEY" \
+          --changeset_url "$SF_STAGING_BASE_URL/api/v1/configuration/shipping" \
+          --revision_url "$SF_STAGING_BASE_URL/api/v1/configuration/shipping/revisions" && \
+        git -C "$GIT_REPOSITORY_PATH" restore --staged feeds/rendering_data/.gitapult.json
+        git -C "$GIT_REPOSITORY_PATH" restore feeds/rendering_data/.gitapult.json
+    - if: success() && github.ref == 'refs/heads/master'
+      name: Tag published staging content
+      run: |
+        git tag "${{ join(steps.current-time.outputs.*, '\n') }}_staging" && git push --tags
+    - if: github.ref == 'refs/heads/master'
+      name: Open PR to stable
+      run: |
+        curl -X POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: token ${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}" https://api.github.com/repos/${{ github.repository }}/pulls -d '{"head":"master","base":"stable","title":"Merge branch master into stable","body":"This PR was automatically generated by CI."}'
+        PR_URL=$(curl -sX GET -H "Accept: application/vnd.github.v3+json" -H "Authorization: token ${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}" https://api.github.com/repos/${{ github.repository }}/pulls\?head\=bellroy:master\&base\=stable | jq -r '.[0].html_url | select(length>0)')
+        echo "PR_URL=$PR_URL" >> $GITHUB_ENV
+    - continue-on-error: true
+      if: github.ref == 'refs/heads/master'
+      name: Set assignee
+      run: |
+        PR_RESPONSE=$(curl -sX GET -H "Accept: application/vnd.github.v3+json" -H "Authorization: token ${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}" https://api.github.com/repos/${{ github.repository }}/pulls\?head\=bellroy:master\&base\=stable | jq -r '.[0]')
+        PR_NUM=$(echo "$PR_RESPONSE" | jq -r ".number")
+        PR_ASSIGNEE=$(echo "$PR_RESPONSE" | jq -r ".assignee")
+        COMMITS_RESPONSE=$(curl -sX GET -H "Accept: application/vnd.github.v3+json" -H "Authorization: token ${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}" https://api.github.com/repos/${{ github.repository }}/pulls/$PR_NUM/commits)
+        FIRST_COMMITTER=$(curl -sX GET -H "Accept: application/vnd.github.v3+json" -H "Authorization: token ${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}" https://api.github.com/repos/${{ github.repository }}/pulls/$PR_NUM/commits | jq -r '. | sort_by(.commit.author.date) | .[0] | .author.login')
+        # special case for squashed PRs created by trike-deploy e.g. gha-workflows
+        if [[ "$FIRST_COMMITTER" == "trike-deploy" ]]; then
+            FIRST_COMMITTER=$(echo "$COMMITS_RESPONSE" | jq -r '. | sort_by(.commit.author.date) | .[0]' | sed -En "s/.*Co-authored-by: (.*) <.*@.*>\",/\1/p")
+        fi
+        if [[ "$PR_ASSIGNEE" == "null" ]]; then
+            curl -sX POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: token ${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}" https://api.github.com/repos/${{ github.repository }}/issues/$PR_NUM/assignees -d "{\"assignees\":[\"$FIRST_COMMITTER\"]}"
+        fi
+    - if: github.ref == 'refs/heads/stable'
+      name: Push content to production
+      run: |
+        set -e
+        # XXX: Gitapult cannot distinguish datasets, so we push all data to
+        # both endpoints and allow the server to decide what to do.
+        # Kafka Webhooks accepts all datasets at once
+        ./gitapult run \
+          --git_root "$GITHUB_WORKSPACE" \
+          --authorization_token "$KW_PRODUCTION_API_KEY" \
+          --changeset_url "$KW_PRODUCTION_BASE_URL/gitapult/configuration/records" \
+          --revision_url "$KW_PRODUCTION_BASE_URL/gitapult/configuration/revision" \
+          --api_key \
+          --compress \
+          --max-payload-bytes 3000000
+
+        # XXX: V3 doesn't care about rendering data, but it is too large to send.
+        # As at 2024-02-14 gitapult does not support selecting which data to send.
+        git -C "$GIT_REPOSITORY_PATH" rm feeds/rendering_data/.gitapult.json
+        ./gitapult run \
+          --git_root "$GITHUB_WORKSPACE" \
+          --authorization_token "$SF_PRODUCTION_API_KEY" \
+          --changeset_url "$SF_PRODUCTION_BASE_URL/api/v1/configuration/shipping" \
+          --revision_url "$SF_PRODUCTION_BASE_URL/api/v1/configuration/shipping/revisions" && \
+        git -C "$GIT_REPOSITORY_PATH" restore --staged feeds/rendering_data/.gitapult.json
+        git -C "$GIT_REPOSITORY_PATH" restore feeds/rendering_data/.gitapult.json
+    - if: success() && github.ref == 'refs/heads/stable'
+      name: Tag published production content
+      run: |
+        git tag "${{ join(steps.current-time.outputs.*, '\n') }}_production" && git push --tags
+    - if: github.ref == 'refs/heads/master'
+      name: Set success message variable for staging
+      run: |
+        echo "SUCCESS_MESSAGE=:tada: \nA PR has been opened against the stable branch: ${{ env.PR_URL }}" >> $GITHUB_ENV
+        echo "SUCCESS_MESSAGE_BODY=https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks\n\n*PR for Production:* ${{ env.PR_URL }}" >> $GITHUB_ENV
+    - if: github.ref == 'refs/heads/stable'
+      name: Set success message variable for production
+      run: |
+        echo "SUCCESS_MESSAGE=:tada:" >> $GITHUB_ENV
+        echo "SUCCESS_MESSAGE_BODY=https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks" >> $GITHUB_ENV
+    - env:
+        SLACK_BOT_TOKEN: ${{ secrets.BELLROY_SLACK_TOKEN }}
+      id: slack-deploy-success
+      if: success() && (github.ref == 'refs/heads/stable' || github.ref == 'refs/heads/master')
+      name: Post to Slack if published successfully
+      uses: slackapi/slack-github-action@70cd7be8e40a46e8b0eced40b0de447bdb42f68e
+      with:
+        channel-id: ${{ env.SLACK_CHANNEL_ID }}
+        payload: |
+          {
+            "text": ":loudspeaker: *${{ github.actor }}* has deployed *${{ env.SYSTEM_NAME }}* to *${{ env.DESTINATION }}* from *${{ steps.extract_branch.outputs.branch }}* ${{ env.SUCCESS_MESSAGE }}",
+            "attachments": [
+              {
+                "fallback": "Deployment summary",
+                "color": "00ff00",
+                "fields": [
+                  {
+                    "title": "What was deployed",
+                    "value": ${{ toJSON(env.COMMIT_CHANGES) }},
+                    "short": false
+                  },
+                  {
+                    "title": "Status",
+                    "short": true,
+                    "value": "Deployed"
+                  },
+                  {
+                    "title": "Output",
+                    "short": false,
+                    "value": "${{ env.SUCCESS_MESSAGE_BODY }}"
+                  }
+                ]
+              }
+            ]
+          }
+        update-ts: ${{ steps.slack-deploy.outputs.ts }}
+    - env:
+        SLACK_BOT_TOKEN: ${{ secrets.BELLROY_SLACK_TOKEN }}
+      id: slack-deploy-failed
+      if: failure() && (github.ref == 'refs/heads/stable' || github.ref == 'refs/heads/master')
+      name: Post to Slack if build/deploy fails
+      uses: slackapi/slack-github-action@70cd7be8e40a46e8b0eced40b0de447bdb42f68e
+      with:
+        channel-id: ${{ env.SLACK_CHANNEL_ID }}
+        payload: |
+          { "text": ":loudspeaker: *BUILD/DEPLOY FAILURE* of *${{ env.SYSTEM_NAME }}* to *${{ env.DESTINATION }}*",
+            "attachments": [
+              {
+                "fallback": "Deployment summary",
+                "color": "ff0000",
+                "fields": [
+                  {
+                    "title": "What was being deployed",
+                    "value": ${{ toJSON(env.COMMIT_CHANGES) }},
+                    "short": false
+                  },
+                  {
+                    "title": "Status",
+                    "short": true,
+                    "value": "Failed"
+                  },
+                  {
+                    "title": "Who broke it",
+                    "value": "${{ github.actor }}"
+                  },
+                  {
+                    "title": "Output",
+                    "short": false,
+                    "value": "https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks"
+                  }
+                ]
+              }
+            ]
+          }
+        update-ts: ${{ steps.slack-deploy.outputs.ts }}
+    - if: always() && env.PR_NUMBER != 0
+      name: Add comment to PR
+      run: gh pr comment ${{ env.PR_NUMBER }} --body-file /tmp/pr-comment.txt || true
+      working-directory: ${{ env.GITHUB_WORKSPACE }}
+  validateContent:
+    runs-on: ubuntu-20.04
+    steps:
+    - id: checkout_branch
+      name: checkout branch
+      uses: bellroy/gha-workflows/composite/checkout@master
+      with:
+        fetch-depth: '1'
+        path: ${{ env.GITHUB_WORKSPACE }}
+        token: ${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}
+    - id: extract_branch
+      name: Extract branch name
+      run: |
+        echo "branch=${GITHUB_REF#refs/heads/}" >>$GITHUB_OUTPUT
+      shell: bash
+    - name: Install and setup nix
+      uses: bellroy/gha-workflows/composite/install-nix@master
+      with:
+        bellroy-nix-cache-access: none
+    - id: fetch_gitapult
+      name: Fetch gitapult
+      uses: bellroy/gha-workflows/composite/get-tool@master
+      with:
+        token: ${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}
+        tool-name: gitapult
+        tool-version: latest
+    - id: fetch_powerful-owl
+      name: Fetch powerful-owl
+      uses: bellroy/gha-workflows/composite/get-tool@master
+      with:
+        token: ${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}
+        tool-name: powerful-owl
+        tool-version: latest
+    - name: Get Pull Request number
+      run: |
+        echo "PR_NUMBER=$(gh pr view --json number -q .number)" >> $GITHUB_ENV
+      working-directory: ${{ env.GITHUB_WORKSPACE }}
+    - name: Ensure tmp directory exists
+      run: mkdir -p tmp
+      working-directory: ${{ env.GITHUB_WORKSPACE }}
+    - name: Validate with gitapult
+      run: |
+        ./gitapult validate --git_root "$GITHUB_WORKSPACE"
+    - name: Validate content with powerful-owl
+      run: |
+        for CONTENT_DIR in feeds products
+        do
+          if [[ "$PR_NUMBER" ]]
+          then
+            ./powerful-owl validate --markdown --content_path "$GITHUB_WORKSPACE/$CONTENT_DIR" &>>/tmp/pr-comment.txt
+          else
+            ./powerful-owl validate --content_path "$GITHUB_WORKSPACE/$CONTENT_DIR"
+          fi
+        done
+    - if: always() && env.PR_NUMBER != 0
+      name: Add comment to PR
+      run: gh pr comment ${{ env.PR_NUMBER }} --body-file /tmp/pr-comment.txt || true
+      working-directory: ${{ env.GITHUB_WORKSPACE }}
+    - if: failure() && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/stable')
+      name: Post to slack if build fails
+      uses: bellroy/gha-workflows/composite/slack-update@master
+      with:
+        branch: ${{ steps.checkout_branch.outputs.branch }}
+        channel-id: ${{ env.SLACK_CHANNEL_ID }}
+        message-type: build-fail
+        show-authors: 'true'
+        slack-bot-token: ${{ secrets.BELLROY_SLACK_TOKEN }}
+    timeout-minutes: 30
+name: Publish
+'on':
+  push:
+    branches-ignore:
+    - refs/tags/*_staging
+    - refs/tags/*_production
diff --git a/test/golden/configuration-main.hs.txt b/test/golden/configuration-main.hs.txt
new file mode 100644
--- /dev/null
+++ b/test/golden/configuration-main.hs.txt
@@ -0,0 +1,1030 @@
+Workflow
+  { concurrency =
+      Just
+        Concurrency
+          { group = Just "${{ github.ref }}"
+          , cancelInProgress = Just False
+          }
+  , defaults = Nothing
+  , env = fromList [ ( "SYSTEM_NAME" , "Product Configuration" ) ]
+  , jobs =
+      fromList
+        [ ( JobId "build"
+          , Job
+              { concurrency = Nothing
+              , container = Nothing
+              , continueOnError = Nothing
+              , defaults = Nothing
+              , env = fromList []
+              , environment = Nothing
+              , jobName = Nothing
+              , needs = Just (JobId "validateContent" :| [])
+              , outputs = fromList []
+              , permissions = Nothing
+              , runIf = Nothing
+              , runsOn = Just "ubuntu-20.04"
+              , secrets = fromList []
+              , services = fromList []
+              , steps =
+                  Just
+                    (Step
+                       { continueOnError = False
+                       , env = fromList []
+                       , name = Just "checkout branch"
+                       , run = Nothing
+                       , runIf =
+                           Just
+                             "github.ref == 'refs/heads/master' || github.ref == 'refs/heads/stable'"
+                       , shell = Nothing
+                       , stepId = Nothing
+                       , timeoutMinutes = Nothing
+                       , uses = Just "bellroy/gha-workflows/composite/checkout@master"
+                       , with =
+                           Just
+                             (StepWithEnv
+                                (fromList
+                                   [ ( "fetch-depth" , "0" )
+                                   , ( "token" , "${{ env.GH_TOKEN }}" )
+                                   ]))
+                       , workingDirectory = Nothing
+                       } :|
+                       [ Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "checkout branch"
+                           , run = Nothing
+                           , runIf =
+                               Just
+                                 "github.ref != 'refs/heads/master' && github.ref != 'refs/heads/stable'"
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Just "bellroy/gha-workflows/composite/checkout@master"
+                           , with =
+                               Just
+                                 (StepWithEnv
+                                    (fromList
+                                       [ ( "fetch-depth" , "1" )
+                                       , ( "token" , "${{ env.GH_TOKEN }}" )
+                                       ]))
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Extract branch name"
+                           , run =
+                               Just
+                                 "echo \"branch=${GITHUB_REF#refs/heads/}\" >> $GITHUB_OUTPUT\n"
+                           , runIf = Nothing
+                           , shell = Just (Bash Nothing)
+                           , stepId = Just (StepId "extract_branch")
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Set env variables for staging"
+                           , run =
+                               Just
+                                 "echo \"DESTINATION=Staging Environment\" >> $GITHUB_ENV\necho \"ENVIRONMENT=staging\" >> $GITHUB_ENV\n"
+                           , runIf = Just "github.ref == 'refs/heads/master'"
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Set env variables for production"
+                           , run =
+                               Just
+                                 "echo \"DESTINATION=Production Environment\" >> $GITHUB_ENV\necho \"ENVIRONMENT=production\" >> $GITHUB_ENV\n"
+                           , runIf = Just "github.ref == 'refs/heads/stable'"
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Install and setup nix"
+                           , run = Nothing
+                           , runIf = Nothing
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Just "bellroy/gha-workflows/composite/install-nix@master"
+                           , with =
+                               Just
+                                 (StepWithEnv
+                                    (fromList
+                                       [ ( "bellroy-nix-cache-access" , "none" )
+                                       , ( "nix-config-access-tokens"
+                                         , "github.com=${{ secrets.READ_HASKELL_REPO_PAT }}"
+                                         )
+                                       ]))
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Install script packages"
+                           , run =
+                               Just "sudo apt-get update\nsudo apt-get install -y parallel\n"
+                           , runIf = Nothing
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Get Pull Request number"
+                           , run =
+                               Just
+                                 "echo \"PR_NUMBER=$(gh pr view --json number -q .number)\" >> $GITHUB_ENV\n"
+                           , runIf = Nothing
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Just "${{ env.GITHUB_WORKSPACE }}"
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Get changed files"
+                           , run = Nothing
+                           , runIf = Nothing
+                           , shell = Nothing
+                           , stepId = Just (StepId "changed-files")
+                           , timeoutMinutes = Nothing
+                           , uses =
+                               Just
+                                 "tj-actions/changed-files@48d8f15b2aaa3d255ca5af3eba4870f807ce6b3c"
+                           , with =
+                               Just
+                                 (StepWithEnv
+                                    (fromList [ ( "path" , "${{ env.GIT_REPOSITORY_PATH }}" ) ]))
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Check for CSV changes"
+                           , run =
+                               Just
+                                 "echo \"csv_changed=${{ steps.changed-files.outputs.all_changed_files == '' && '0' || contains(steps.changed-files.outputs.all_changed_files, '.csv') && '1' || '0' }}\" >> $GITHUB_OUTPUT"
+                           , runIf = Nothing
+                           , shell = Nothing
+                           , stepId = Just (StepId "check_changes")
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name =
+                               Just
+                                 "Check that all configuration CSV files are sorted alphabetically"
+                           , run =
+                               Just
+                                 "set +e\nIFS= output=$(./scripts/check_configuration_csvs_sorted 2>&1)\nstatus=$?\n\nif (($status)); then\n  echo \"Configuration CSV files are not sorted:\" >>/tmp/pr-comment.txt\n  echo \"\" >>/tmp/pr-comment.txt\n  echo \"\\`\\`\\`\" >>/tmp/pr-comment.txt\n  echo $output >>/tmp/pr-comment.txt\n  echo \"\\`\\`\\`\" >>/tmp/pr-comment.txt\n  exit $status\nelse\n  echo \"All configuration CSV files are sorted.\"\nfi\n"
+                           , runIf = Just "steps.check_changes.outputs.csv_changed == '1'"
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Fetch byproduct"
+                           , run = Nothing
+                           , runIf = Nothing
+                           , shell = Nothing
+                           , stepId = Just (StepId "fetch_byproduct")
+                           , timeoutMinutes = Nothing
+                           , uses = Just "bellroy/gha-workflows/composite/get-tool@master"
+                           , with =
+                               Just
+                                 (StepWithEnv
+                                    (fromList
+                                       [ ( "token" , "${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}" )
+                                       , ( "tool-name" , "byproduct" )
+                                       , ( "tool-version" , "latest" )
+                                       ]))
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Fetch gitapult"
+                           , run = Nothing
+                           , runIf = Nothing
+                           , shell = Nothing
+                           , stepId = Just (StepId "fetch_gitapult")
+                           , timeoutMinutes = Nothing
+                           , uses = Just "bellroy/gha-workflows/composite/get-tool@master"
+                           , with =
+                               Just
+                                 (StepWithEnv
+                                    (fromList
+                                       [ ( "token" , "${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}" )
+                                       , ( "tool-name" , "gitapult" )
+                                       , ( "tool-version" , "latest" )
+                                       ]))
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Fetch config-rules-cli"
+                           , run = Nothing
+                           , runIf = Nothing
+                           , shell = Nothing
+                           , stepId = Just (StepId "fetch_config-rules-cli")
+                           , timeoutMinutes = Nothing
+                           , uses = Just "bellroy/gha-workflows/composite/get-tool@master"
+                           , with =
+                               Just
+                                 (StepWithEnv
+                                    (fromList
+                                       [ ( "token" , "${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}" )
+                                       , ( "tool-name" , "config-rules-cli" )
+                                       , ( "tool-version" , "latest" )
+                                       ]))
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Fetch powerful-owl"
+                           , run = Nothing
+                           , runIf = Nothing
+                           , shell = Nothing
+                           , stepId = Just (StepId "fetch_powerful-owl")
+                           , timeoutMinutes = Nothing
+                           , uses = Just "bellroy/gha-workflows/composite/get-tool@master"
+                           , with =
+                               Just
+                                 (StepWithEnv
+                                    (fromList
+                                       [ ( "token" , "${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}" )
+                                       , ( "tool-name" , "powerful-owl" )
+                                       , ( "tool-version" , "latest" )
+                                       ]))
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Run byproduct once"
+                           , run =
+                               Just
+                                 "find products/autogenerated/ -type f -name '*.json' | grep -v './\\.' | xargs rm\n./byproduct execute --once --in products/configuration --out products/autogenerated\n"
+                           , runIf = Nothing
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Configure git"
+                           , run =
+                               Just
+                                 "git config --global user.name \"${{ github.actor }}\"\ngit config --global user.email \"${{ github.actor }}@users.noreply.github.com\"\n"
+                           , runIf = Nothing
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Update autogenerated product files"
+                           , run =
+                               Just
+                                 "git add products/autogenerated\nif git commit -m \"Update autogenerated product files\"; then\n  echo \"GENERATED_ANOTHER_COMMIT=true\" >> $GITHUB_ENV\nelse\n  echo \"No changes detected\"\nfi\n"
+                           , runIf = Nothing
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Update products autogenerated content blocks"
+                           , run =
+                               Just
+                                 "scripts/update_products_autogenerated_content_blocks\ngit add products/content\n\nif git commit -m \"Update autogenerated products content blocks\"; then\n  echo \"GENERATED_ANOTHER_COMMIT=true\" >> $GITHUB_ENV\nelse\n  echo \"No changes detected\"\nfi\n"
+                           , runIf = Nothing
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Set merge base"
+                           , run =
+                               Just
+                                 "if [ \"${{ github.ref }}\" = \"refs/heads/master\" ]; then\n  echo \"MERGE_BASE=origin/stable\" >> $GITHUB_ENV\nelse\n  echo \"MERGE_BASE=$(git show-branch --merge-base origin/master ${{ steps.extract_branch.outputs.branch }})\" >> $GITHUB_ENV\nfi\n"
+                           , runIf = Nothing
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Write byproduct validate output to env variable"
+                           , run =
+                               Just
+                                 "set +e\nIFS= output=$(./gitapult changeset --git_root \"$GITHUB_WORKSPACE\" --from \"${{ env.MERGE_BASE }}\" | ./byproduct validate --markdown 2>&1)\nstatus=$?\n\nif (($status)); then\n  echo $output >>/tmp/pr-comment.txt\n  exit $status\nelse\n  echo \"Byproduct validation succeeded.\"\nfi\n"
+                           , runIf = Just "env.PR_NUMBER != 0"
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Update feed autogenerated content blocks"
+                           , run =
+                               Just
+                                 "nix run .#update_feeds_autogenerated_content_blocks\ngit add feeds/content\n\nif git commit -m \"Update autogenerated feed content blocks\"; then\n  echo \"GENERATED_ANOTHER_COMMIT=true\" >> $GITHUB_ENV\nelse\n  echo \"No changes detected\"\nfi\n"
+                           , runIf = Nothing
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Update feed autogenerated rendering data"
+                           , run =
+                               Just
+                                 "POWERFUL_OWL_COMMAND=$GITHUB_WORKSPACE/powerful-owl POWERFUL_OWL_FOLDER=./feeds/ ./scripts/generate-rendering-data\ngit add ./feeds/page_rendering_data\ngit add ./feeds/rendering_data\n\nif git commit -m \"Update feed autogenerated rendering data\"; then\n  echo \"GENERATED_ANOTHER_COMMIT=true\" >> $GITHUB_ENV\nelse\n  echo \"No changes detected\"\nfi\n"
+                           , runIf = Nothing
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Just "${{ env.GIT_REPOSITORY_PATH }}"
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Update feed golden tests"
+                           , run =
+                               Just
+                                 "scripts/update_feeds_golden_tests\ngit add feeds/tests/golden\n\nif git commit -m \"Update autogenerated feeds golden test outputs\"; then\n  echo \"GENERATED_ANOTHER_COMMIT=true\" >> $GITHUB_ENV\nelse\n  echo \"No changes detected\"\nfi\n"
+                           , runIf = Nothing
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Update autogenerated shipping configuration files"
+                           , run =
+                               Just
+                                 "set +e\nIFS= output=$(./config-rules-cli shipping-options-transform --input-csv-dir shipping/configuration --output-json-dir shipping/autogenerated)\nstatus=$?\n\nif (($status)); then\n  echo \"Failed to transform shipping data\" >>/tmp/pr-comment.txt\n  echo \"\" >>/tmp/pr-comment.txt\n  echo \"\\`\\`\\`\" >>/tmp/pr-comment.txt\n  echo $output >>/tmp/pr-comment.txt\n  echo \"\\`\\`\\`\" >>/tmp/pr-comment.txt\n  exit $status\nelse\n  git add shipping/autogenerated\n  if git commit -m \"Update autogenerated shipping configuration files\"; then\n    echo \"GENERATED_ANOTHER_COMMIT=true\" >> $GITHUB_ENV\n  else\n    echo \"No changes detected\"\n  fi\nfi\n"
+                           , runIf = Nothing
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Generate new shipping tests"
+                           , run =
+                               Just
+                                 "SHIPPING_TEST_DIRECTORY=shipping/tests\n  find $SHIPPING_TEST_DIRECTORY -type f -name '*.result.json' -exec rm {} \\;\n    ./config-rules-cli -- shipping-options-check --repo-dir .\n"
+                           , runIf = Nothing
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Commit new shipping tests"
+                           , run =
+                               Just
+                                 "if [ $(git status shipping/tests --porcelain=v1 | wc -l) -gt 0 ]\nthen\n  git add shipping/tests\n  git commit -m \"Update shipping tests\"\n  echo \"GENERATED_ANOTHER_COMMIT=true\" >> $GITHUB_ENV\nfi\n"
+                           , runIf =
+                               Just
+                                 "env.PR_NUMBER != 0 && github.ref != 'refs/heads/stable' && github.ref != 'refs/heads/master'"
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Update autogenerated promotion configuration files"
+                           , run =
+                               Just
+                                 "set +e\nIFS= output=$(./config-rules-cli promotion-transform --input-csv-dir promotions/configuration --output-json-dir promotions/autogenerated)\nstatus=$?\n\nif (($status)); then\n  echo \"Failed to transform promotions data\" >>/tmp/pr-comment.txt\n  echo \"\" >>/tmp/pr-comment.txt\n  echo \"\\`\\`\\`\" >>/tmp/pr-comment.txt\n  echo $output >>/tmp/pr-comment.txt\n  echo \"\\`\\`\\`\" >>/tmp/pr-comment.txt\n  exit $status\nelse\n  git add promotions/autogenerated\n  if git commit -m \"Update autogenerated promotions configuration files\"; then\n    echo \"GENERATED_ANOTHER_COMMIT=true\" >> $GITHUB_ENV\n  else\n    echo \"No changes detected\"\n  fi\nfi\n"
+                           , runIf = Nothing
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Generate new promotion rule tests"
+                           , run =
+                               Just
+                                 "PROMOTIONS_TEST_DIRECTORY=promotions/tests\n  find $PROMOTIONS_TEST_DIRECTORY -type f -name '*.result.json' -exec rm {} \\;\n    ./config-rules-cli -- promotion-check --repo-dir .\n"
+                           , runIf = Nothing
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Commit new promotion tests"
+                           , run =
+                               Just
+                                 "if [ $(git status promotions/tests --porcelain=v1 | wc -l) -gt 0 ]\nthen\n  git add promotions/tests\n  git commit -m \"Update promotion tests\"\n  echo \"GENERATED_ANOTHER_COMMIT=true\" >> $GITHUB_ENV\nfi\n"
+                           , runIf =
+                               Just
+                                 "env.PR_NUMBER != 0 && github.ref != 'refs/heads/stable' && github.ref != 'refs/heads/master'"
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name =
+                               Just "Push any new commits and exit workflow if commits pushed"
+                           , run =
+                               Just
+                                 "git push --set-upstream origin HEAD\n\ngh run cancel ${{ github.run_id }}\ngh run watch ${{ github.run_id }}\n"
+                           , runIf = Just "env.GENERATED_ANOTHER_COMMIT == 'true'"
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Get current time"
+                           , run =
+                               Just
+                                 "echo \"timestamp=$(date -u +\"%y%m%d%H%M%S\")\" >> $GITHUB_OUTPUT"
+                           , runIf = Nothing
+                           , shell = Nothing
+                           , stepId = Just (StepId "current-time")
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Set changes variable"
+                           , run =
+                               Just
+                                 "echo \"COMMIT_CHANGES<<EOF\" >> $GITHUB_ENV\necho \"$(git log --oneline --format=\\\"%s\\\" $(git tag -l [0-9]*_${{ env.ENVIRONMENT }} | tail -n 1)..${{ steps.extract_branch.outputs.branch }} | grep -v 'Merge pull request' | grep -v \"Merge branch 'master' into\" | sed -e 's/^\"//' -e 's/\"$//' | cat)\" >> $GITHUB_ENV\necho \"EOF\" >> $GITHUB_ENV\n"
+                           , runIf =
+                               Just
+                                 "github.ref == 'refs/heads/stable' || github.ref == 'refs/heads/master'"
+                           , shell = Just (Bash Nothing)
+                           , stepId = Just (StepId "extract_changes")
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Just "${{ env.GITHUB_WORKSPACE }}"
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env =
+                               fromList
+                                 [ ( "SLACK_BOT_TOKEN" , "${{ secrets.BELLROY_SLACK_TOKEN }}" ) ]
+                           , name = Just "Slack - Notifications - Post to slack"
+                           , run = Nothing
+                           , runIf =
+                               Just
+                                 "github.ref == 'refs/heads/stable' || github.ref == 'refs/heads/master'"
+                           , shell = Nothing
+                           , stepId = Just (StepId "slack-deploy")
+                           , timeoutMinutes = Nothing
+                           , uses =
+                               Just
+                                 "slackapi/slack-github-action@70cd7be8e40a46e8b0eced40b0de447bdb42f68e"
+                           , with =
+                               Just
+                                 (StepWithEnv
+                                    (fromList
+                                       [ ( "channel-id" , "${{ env.SLACK_CHANNEL_ID }}" )
+                                       , ( "payload"
+                                         , "{\n  \"text\": \":loudspeaker: *${{ github.actor }}* is deploying *${{ env.SYSTEM_NAME }}* to *${{ env.DESTINATION }}* from *${{ steps.extract_branch.outputs.branch }}*\",\n  \"attachments\": [\n    {\n      \"fallback\": \"Deployment summary\",\n      \"color\": \"ffd966\",\n      \"fields\": [\n        {\n          \"title\": \"What is being deployed\",\n          \"value\": ${{ toJSON(env.COMMIT_CHANGES) }},\n          \"short\": false\n        },\n        {\n          \"title\": \"Status\",\n          \"short\": true,\n          \"value\": \"In Progress\"\n        }\n      ]\n    }\n  ]\n}\n"
+                                         )
+                                       ]))
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Push content to staging"
+                           , run =
+                               Just
+                                 "set -e\n# XXX: Gitapult cannot distinguish datasets, so we push all data to\n# both endpoints and allow the server to decide what to do.\n# Kafka Webhooks accepts all datasets at once\n./gitapult run \\\n  --git_root \"$GITHUB_WORKSPACE\" \\\n  --authorization_token \"$KW_STAGING_API_KEY\" \\\n  --changeset_url \"$KW_STAGING_BASE_URL/gitapult/configuration/records\" \\\n  --revision_url \"$KW_STAGING_BASE_URL/gitapult/configuration/revision\" \\\n  --api_key \\\n  --compress \\\n  --max-payload-bytes 3000000\n\n# XXX: V3 doesn't care about rendering data, but it is too large to send.\n# As at 2024-02-14 gitapult does not support selecting which data to send.\ngit -C \"$GIT_REPOSITORY_PATH\" rm feeds/rendering_data/.gitapult.json\n./gitapult run \\\n  --git_root \"$GITHUB_WORKSPACE\" \\\n  --authorization_token \"$SF_STAGING_API_KEY\" \\\n  --changeset_url \"$SF_STAGING_BASE_URL/api/v1/configuration/shipping\" \\\n  --revision_url \"$SF_STAGING_BASE_URL/api/v1/configuration/shipping/revisions\" && \\\ngit -C \"$GIT_REPOSITORY_PATH\" restore --staged feeds/rendering_data/.gitapult.json\ngit -C \"$GIT_REPOSITORY_PATH\" restore feeds/rendering_data/.gitapult.json\n"
+                           , runIf = Just "github.ref == 'refs/heads/master'"
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Tag published staging content"
+                           , run =
+                               Just
+                                 "git tag \"${{ join(steps.current-time.outputs.*, '\\n') }}_staging\" && git push --tags\n"
+                           , runIf = Just "success() && github.ref == 'refs/heads/master'"
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Open PR to stable"
+                           , run =
+                               Just
+                                 "curl -X POST -H \"Accept: application/vnd.github.v3+json\" -H \"Authorization: token ${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}\" https://api.github.com/repos/${{ github.repository }}/pulls -d '{\"head\":\"master\",\"base\":\"stable\",\"title\":\"Merge branch master into stable\",\"body\":\"This PR was automatically generated by CI.\"}'\nPR_URL=$(curl -sX GET -H \"Accept: application/vnd.github.v3+json\" -H \"Authorization: token ${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}\" https://api.github.com/repos/${{ github.repository }}/pulls\\?head\\=bellroy:master\\&base\\=stable | jq -r '.[0].html_url | select(length>0)')\necho \"PR_URL=$PR_URL\" >> $GITHUB_ENV\n"
+                           , runIf = Just "github.ref == 'refs/heads/master'"
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = True
+                           , env = fromList []
+                           , name = Just "Set assignee"
+                           , run =
+                               Just
+                                 "PR_RESPONSE=$(curl -sX GET -H \"Accept: application/vnd.github.v3+json\" -H \"Authorization: token ${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}\" https://api.github.com/repos/${{ github.repository }}/pulls\\?head\\=bellroy:master\\&base\\=stable | jq -r '.[0]')\nPR_NUM=$(echo \"$PR_RESPONSE\" | jq -r \".number\")\nPR_ASSIGNEE=$(echo \"$PR_RESPONSE\" | jq -r \".assignee\")\nCOMMITS_RESPONSE=$(curl -sX GET -H \"Accept: application/vnd.github.v3+json\" -H \"Authorization: token ${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}\" https://api.github.com/repos/${{ github.repository }}/pulls/$PR_NUM/commits)\nFIRST_COMMITTER=$(curl -sX GET -H \"Accept: application/vnd.github.v3+json\" -H \"Authorization: token ${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}\" https://api.github.com/repos/${{ github.repository }}/pulls/$PR_NUM/commits | jq -r '. | sort_by(.commit.author.date) | .[0] | .author.login')\n# special case for squashed PRs created by trike-deploy e.g. gha-workflows\nif [[ \"$FIRST_COMMITTER\" == \"trike-deploy\" ]]; then\n    FIRST_COMMITTER=$(echo \"$COMMITS_RESPONSE\" | jq -r '. | sort_by(.commit.author.date) | .[0]' | sed -En \"s/.*Co-authored-by: (.*) <.*@.*>\\\",/\\1/p\")\nfi\nif [[ \"$PR_ASSIGNEE\" == \"null\" ]]; then\n    curl -sX POST -H \"Accept: application/vnd.github.v3+json\" -H \"Authorization: token ${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}\" https://api.github.com/repos/${{ github.repository }}/issues/$PR_NUM/assignees -d \"{\\\"assignees\\\":[\\\"$FIRST_COMMITTER\\\"]}\"\nfi\n"
+                           , runIf = Just "github.ref == 'refs/heads/master'"
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Push content to production"
+                           , run =
+                               Just
+                                 "set -e\n# XXX: Gitapult cannot distinguish datasets, so we push all data to\n# both endpoints and allow the server to decide what to do.\n# Kafka Webhooks accepts all datasets at once\n./gitapult run \\\n  --git_root \"$GITHUB_WORKSPACE\" \\\n  --authorization_token \"$KW_PRODUCTION_API_KEY\" \\\n  --changeset_url \"$KW_PRODUCTION_BASE_URL/gitapult/configuration/records\" \\\n  --revision_url \"$KW_PRODUCTION_BASE_URL/gitapult/configuration/revision\" \\\n  --api_key \\\n  --compress \\\n  --max-payload-bytes 3000000\n\n# XXX: V3 doesn't care about rendering data, but it is too large to send.\n# As at 2024-02-14 gitapult does not support selecting which data to send.\ngit -C \"$GIT_REPOSITORY_PATH\" rm feeds/rendering_data/.gitapult.json\n./gitapult run \\\n  --git_root \"$GITHUB_WORKSPACE\" \\\n  --authorization_token \"$SF_PRODUCTION_API_KEY\" \\\n  --changeset_url \"$SF_PRODUCTION_BASE_URL/api/v1/configuration/shipping\" \\\n  --revision_url \"$SF_PRODUCTION_BASE_URL/api/v1/configuration/shipping/revisions\" && \\\ngit -C \"$GIT_REPOSITORY_PATH\" restore --staged feeds/rendering_data/.gitapult.json\ngit -C \"$GIT_REPOSITORY_PATH\" restore feeds/rendering_data/.gitapult.json\n"
+                           , runIf = Just "github.ref == 'refs/heads/stable'"
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Tag published production content"
+                           , run =
+                               Just
+                                 "git tag \"${{ join(steps.current-time.outputs.*, '\\n') }}_production\" && git push --tags\n"
+                           , runIf = Just "success() && github.ref == 'refs/heads/stable'"
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Set success message variable for staging"
+                           , run =
+                               Just
+                                 "echo \"SUCCESS_MESSAGE=:tada: \\nA PR has been opened against the stable branch: ${{ env.PR_URL }}\" >> $GITHUB_ENV\necho \"SUCCESS_MESSAGE_BODY=https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks\\n\\n*PR for Production:* ${{ env.PR_URL }}\" >> $GITHUB_ENV\n"
+                           , runIf = Just "github.ref == 'refs/heads/master'"
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Set success message variable for production"
+                           , run =
+                               Just
+                                 "echo \"SUCCESS_MESSAGE=:tada:\" >> $GITHUB_ENV\necho \"SUCCESS_MESSAGE_BODY=https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks\" >> $GITHUB_ENV\n"
+                           , runIf = Just "github.ref == 'refs/heads/stable'"
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env =
+                               fromList
+                                 [ ( "SLACK_BOT_TOKEN" , "${{ secrets.BELLROY_SLACK_TOKEN }}" ) ]
+                           , name = Just "Post to Slack if published successfully"
+                           , run = Nothing
+                           , runIf =
+                               Just
+                                 "success() && (github.ref == 'refs/heads/stable' || github.ref == 'refs/heads/master')"
+                           , shell = Nothing
+                           , stepId = Just (StepId "slack-deploy-success")
+                           , timeoutMinutes = Nothing
+                           , uses =
+                               Just
+                                 "slackapi/slack-github-action@70cd7be8e40a46e8b0eced40b0de447bdb42f68e"
+                           , with =
+                               Just
+                                 (StepWithEnv
+                                    (fromList
+                                       [ ( "channel-id" , "${{ env.SLACK_CHANNEL_ID }}" )
+                                       , ( "payload"
+                                         , "{\n  \"text\": \":loudspeaker: *${{ github.actor }}* has deployed *${{ env.SYSTEM_NAME }}* to *${{ env.DESTINATION }}* from *${{ steps.extract_branch.outputs.branch }}* ${{ env.SUCCESS_MESSAGE }}\",\n  \"attachments\": [\n    {\n      \"fallback\": \"Deployment summary\",\n      \"color\": \"00ff00\",\n      \"fields\": [\n        {\n          \"title\": \"What was deployed\",\n          \"value\": ${{ toJSON(env.COMMIT_CHANGES) }},\n          \"short\": false\n        },\n        {\n          \"title\": \"Status\",\n          \"short\": true,\n          \"value\": \"Deployed\"\n        },\n        {\n          \"title\": \"Output\",\n          \"short\": false,\n          \"value\": \"${{ env.SUCCESS_MESSAGE_BODY }}\"\n        }\n      ]\n    }\n  ]\n}\n"
+                                         )
+                                       , ( "update-ts" , "${{ steps.slack-deploy.outputs.ts }}" )
+                                       ]))
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env =
+                               fromList
+                                 [ ( "SLACK_BOT_TOKEN" , "${{ secrets.BELLROY_SLACK_TOKEN }}" ) ]
+                           , name = Just "Post to Slack if build/deploy fails"
+                           , run = Nothing
+                           , runIf =
+                               Just
+                                 "failure() && (github.ref == 'refs/heads/stable' || github.ref == 'refs/heads/master')"
+                           , shell = Nothing
+                           , stepId = Just (StepId "slack-deploy-failed")
+                           , timeoutMinutes = Nothing
+                           , uses =
+                               Just
+                                 "slackapi/slack-github-action@70cd7be8e40a46e8b0eced40b0de447bdb42f68e"
+                           , with =
+                               Just
+                                 (StepWithEnv
+                                    (fromList
+                                       [ ( "channel-id" , "${{ env.SLACK_CHANNEL_ID }}" )
+                                       , ( "payload"
+                                         , "{ \"text\": \":loudspeaker: *BUILD/DEPLOY FAILURE* of *${{ env.SYSTEM_NAME }}* to *${{ env.DESTINATION }}*\",\n  \"attachments\": [\n    {\n      \"fallback\": \"Deployment summary\",\n      \"color\": \"ff0000\",\n      \"fields\": [\n        {\n          \"title\": \"What was being deployed\",\n          \"value\": ${{ toJSON(env.COMMIT_CHANGES) }},\n          \"short\": false\n        },\n        {\n          \"title\": \"Status\",\n          \"short\": true,\n          \"value\": \"Failed\"\n        },\n        {\n          \"title\": \"Who broke it\",\n          \"value\": \"${{ github.actor }}\"\n        },\n        {\n          \"title\": \"Output\",\n          \"short\": false,\n          \"value\": \"https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks\"\n        }\n      ]\n    }\n  ]\n}\n"
+                                         )
+                                       , ( "update-ts" , "${{ steps.slack-deploy.outputs.ts }}" )
+                                       ]))
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Add comment to PR"
+                           , run =
+                               Just
+                                 "gh pr comment ${{ env.PR_NUMBER }} --body-file /tmp/pr-comment.txt || true"
+                           , runIf = Just "always() && env.PR_NUMBER != 0"
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Just "${{ env.GITHUB_WORKSPACE }}"
+                           }
+                       ])
+              , strategy = Nothing
+              , timeoutMinutes = Nothing
+              , uses = Nothing
+              , with = fromList []
+              }
+          )
+        , ( JobId "validateContent"
+          , Job
+              { concurrency = Nothing
+              , container = Nothing
+              , continueOnError = Nothing
+              , defaults = Nothing
+              , env = fromList []
+              , environment = Nothing
+              , jobName = Nothing
+              , needs = Nothing
+              , outputs = fromList []
+              , permissions = Nothing
+              , runIf = Nothing
+              , runsOn = Just "ubuntu-20.04"
+              , secrets = fromList []
+              , services = fromList []
+              , steps =
+                  Just
+                    (Step
+                       { continueOnError = False
+                       , env = fromList []
+                       , name = Just "checkout branch"
+                       , run = Nothing
+                       , runIf = Nothing
+                       , shell = Nothing
+                       , stepId = Just (StepId "checkout_branch")
+                       , timeoutMinutes = Nothing
+                       , uses = Just "bellroy/gha-workflows/composite/checkout@master"
+                       , with =
+                           Just
+                             (StepWithEnv
+                                (fromList
+                                   [ ( "fetch-depth" , "1" )
+                                   , ( "path" , "${{ env.GITHUB_WORKSPACE }}" )
+                                   , ( "token" , "${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}" )
+                                   ]))
+                       , workingDirectory = Nothing
+                       } :|
+                       [ Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Extract branch name"
+                           , run =
+                               Just "echo \"branch=${GITHUB_REF#refs/heads/}\" >>$GITHUB_OUTPUT\n"
+                           , runIf = Nothing
+                           , shell = Just (Bash Nothing)
+                           , stepId = Just (StepId "extract_branch")
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Install and setup nix"
+                           , run = Nothing
+                           , runIf = Nothing
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Just "bellroy/gha-workflows/composite/install-nix@master"
+                           , with =
+                               Just
+                                 (StepWithEnv
+                                    (fromList [ ( "bellroy-nix-cache-access" , "none" ) ]))
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Fetch gitapult"
+                           , run = Nothing
+                           , runIf = Nothing
+                           , shell = Nothing
+                           , stepId = Just (StepId "fetch_gitapult")
+                           , timeoutMinutes = Nothing
+                           , uses = Just "bellroy/gha-workflows/composite/get-tool@master"
+                           , with =
+                               Just
+                                 (StepWithEnv
+                                    (fromList
+                                       [ ( "token" , "${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}" )
+                                       , ( "tool-name" , "gitapult" )
+                                       , ( "tool-version" , "latest" )
+                                       ]))
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Fetch powerful-owl"
+                           , run = Nothing
+                           , runIf = Nothing
+                           , shell = Nothing
+                           , stepId = Just (StepId "fetch_powerful-owl")
+                           , timeoutMinutes = Nothing
+                           , uses = Just "bellroy/gha-workflows/composite/get-tool@master"
+                           , with =
+                               Just
+                                 (StepWithEnv
+                                    (fromList
+                                       [ ( "token" , "${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}" )
+                                       , ( "tool-name" , "powerful-owl" )
+                                       , ( "tool-version" , "latest" )
+                                       ]))
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Get Pull Request number"
+                           , run =
+                               Just
+                                 "echo \"PR_NUMBER=$(gh pr view --json number -q .number)\" >> $GITHUB_ENV\n"
+                           , runIf = Nothing
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Just "${{ env.GITHUB_WORKSPACE }}"
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Ensure tmp directory exists"
+                           , run = Just "mkdir -p tmp"
+                           , runIf = Nothing
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Just "${{ env.GITHUB_WORKSPACE }}"
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Validate with gitapult"
+                           , run =
+                               Just "./gitapult validate --git_root \"$GITHUB_WORKSPACE\"\n"
+                           , runIf = Nothing
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Validate content with powerful-owl"
+                           , run =
+                               Just
+                                 "for CONTENT_DIR in feeds products\ndo\n  if [[ \"$PR_NUMBER\" ]]\n  then\n    ./powerful-owl validate --markdown --content_path \"$GITHUB_WORKSPACE/$CONTENT_DIR\" &>>/tmp/pr-comment.txt\n  else\n    ./powerful-owl validate --content_path \"$GITHUB_WORKSPACE/$CONTENT_DIR\"\n  fi\ndone\n"
+                           , runIf = Nothing
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Nothing
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Add comment to PR"
+                           , run =
+                               Just
+                                 "gh pr comment ${{ env.PR_NUMBER }} --body-file /tmp/pr-comment.txt || true"
+                           , runIf = Just "always() && env.PR_NUMBER != 0"
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Nothing
+                           , with = Nothing
+                           , workingDirectory = Just "${{ env.GITHUB_WORKSPACE }}"
+                           }
+                       , Step
+                           { continueOnError = False
+                           , env = fromList []
+                           , name = Just "Post to slack if build fails"
+                           , run = Nothing
+                           , runIf =
+                               Just
+                                 "failure() && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/stable')"
+                           , shell = Nothing
+                           , stepId = Nothing
+                           , timeoutMinutes = Nothing
+                           , uses = Just "bellroy/gha-workflows/composite/slack-update@master"
+                           , with =
+                               Just
+                                 (StepWithEnv
+                                    (fromList
+                                       [ ( "branch"
+                                         , "${{ steps.checkout_branch.outputs.branch }}"
+                                         )
+                                       , ( "channel-id" , "${{ env.SLACK_CHANNEL_ID }}" )
+                                       , ( "message-type" , "build-fail" )
+                                       , ( "show-authors" , "true" )
+                                       , ( "slack-bot-token"
+                                         , "${{ secrets.BELLROY_SLACK_TOKEN }}"
+                                         )
+                                       ]))
+                           , workingDirectory = Nothing
+                           }
+                       ])
+              , strategy = Nothing
+              , timeoutMinutes = Just 30
+              , uses = Nothing
+              , with = fromList []
+              }
+          )
+        ]
+  , on =
+      fromList
+        [ PushTrigger
+            PushTriggerAttributes
+              { branches = Nothing
+              , branchesIgnore =
+                  Just ("refs/tags/*_staging" :| [ "refs/tags/*_production" ])
+              , paths = Nothing
+              , pathsIgnore = Nothing
+              , tags = Nothing
+              }
+        ]
+  , permissions = Nothing
+  , runName = Nothing
+  , workflowName = Just "Publish"
+  }
diff --git a/test/golden/configuration-main.yml b/test/golden/configuration-main.yml
new file mode 100644
--- /dev/null
+++ b/test/golden/configuration-main.yml
@@ -0,0 +1,549 @@
+# This file is generated by `generate.sh`.
+# Generated from https://github.com/bellroy/gha-workflows/tree/master/dhall/workflows/configuration/main.dhall
+# Think twice before editing it directly.
+concurrency:
+  cancel-in-progress: false
+  group: "${{ github.ref }}"
+env:
+  SYSTEM_NAME: Product Configuration
+jobs:
+  build:
+    needs:
+      - validateContent
+    runs-on: ubuntu-20.04
+    steps:
+      - if: "github.ref == 'refs/heads/master' || github.ref == 'refs/heads/stable'"
+        name: checkout branch
+        uses: "bellroy/gha-workflows/composite/checkout@master"
+        with:
+          fetch-depth: "0"
+          token: "${{ env.GH_TOKEN }}"
+      - if: "github.ref != 'refs/heads/master' && github.ref != 'refs/heads/stable'"
+        name: checkout branch
+        uses: "bellroy/gha-workflows/composite/checkout@master"
+        with:
+          fetch-depth: "1"
+          token: "${{ env.GH_TOKEN }}"
+      - id: extract_branch
+        name: Extract branch name
+        run: |
+          echo "branch=${GITHUB_REF#refs/heads/}" >> $GITHUB_OUTPUT
+        shell: bash
+      - if: "github.ref == 'refs/heads/master'"
+        name: Set env variables for staging
+        run: |
+          echo "DESTINATION=Staging Environment" >> $GITHUB_ENV
+          echo "ENVIRONMENT=staging" >> $GITHUB_ENV
+      - if: "github.ref == 'refs/heads/stable'"
+        name: Set env variables for production
+        run: |
+          echo "DESTINATION=Production Environment" >> $GITHUB_ENV
+          echo "ENVIRONMENT=production" >> $GITHUB_ENV
+      - name: Install and setup nix
+        uses: "bellroy/gha-workflows/composite/install-nix@master"
+        with:
+          bellroy-nix-cache-access: none
+          nix-config-access-tokens: "github.com=${{ secrets.READ_HASKELL_REPO_PAT }}"
+      - name: Install script packages
+        run: |
+          sudo apt-get update
+          sudo apt-get install -y parallel
+      - name: Get Pull Request number
+        run: |
+          echo "PR_NUMBER=$(gh pr view --json number -q .number)" >> $GITHUB_ENV
+        working-directory: "${{ env.GITHUB_WORKSPACE }}"
+      - id: changed-files
+        name: Get changed files
+        uses: "tj-actions/changed-files@48d8f15b2aaa3d255ca5af3eba4870f807ce6b3c"
+        with:
+          path: "${{ env.GIT_REPOSITORY_PATH }}"
+      - id: check_changes
+        name: Check for CSV changes
+        run: 'echo "csv_changed=${{ steps.changed-files.outputs.all_changed_files == '''' && ''0'' || contains(steps.changed-files.outputs.all_changed_files, ''.csv'') && ''1'' || ''0'' }}" >> $GITHUB_OUTPUT'
+      - if: "steps.check_changes.outputs.csv_changed == '1'"
+        name: Check that all configuration CSV files are sorted alphabetically
+        run: |
+          set +e
+          IFS= output=$(./scripts/check_configuration_csvs_sorted 2>&1)
+          status=$?
+
+          if (($status)); then
+            echo "Configuration CSV files are not sorted:" >>/tmp/pr-comment.txt
+            echo "" >>/tmp/pr-comment.txt
+            echo "\`\`\`" >>/tmp/pr-comment.txt
+            echo $output >>/tmp/pr-comment.txt
+            echo "\`\`\`" >>/tmp/pr-comment.txt
+            exit $status
+          else
+            echo "All configuration CSV files are sorted."
+          fi
+      - id: fetch_byproduct
+        name: Fetch byproduct
+        uses: "bellroy/gha-workflows/composite/get-tool@master"
+        with:
+          token: "${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}"
+          tool-name: byproduct
+          tool-version: latest
+      - id: fetch_gitapult
+        name: Fetch gitapult
+        uses: "bellroy/gha-workflows/composite/get-tool@master"
+        with:
+          token: "${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}"
+          tool-name: gitapult
+          tool-version: latest
+      - id: fetch_config-rules-cli
+        name: Fetch config-rules-cli
+        uses: "bellroy/gha-workflows/composite/get-tool@master"
+        with:
+          token: "${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}"
+          tool-name: config-rules-cli
+          tool-version: latest
+      - id: fetch_powerful-owl
+        name: Fetch powerful-owl
+        uses: "bellroy/gha-workflows/composite/get-tool@master"
+        with:
+          token: "${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}"
+          tool-name: powerful-owl
+          tool-version: latest
+      - name: Run byproduct once
+        run: |
+          find products/autogenerated/ -type f -name '*.json' | grep -v './\.' | xargs rm
+          ./byproduct execute --once --in products/configuration --out products/autogenerated
+      - name: Configure git
+        run: |
+          git config --global user.name "${{ github.actor }}"
+          git config --global user.email "${{ github.actor }}@users.noreply.github.com"
+      - name: Update autogenerated product files
+        run: |
+          git add products/autogenerated
+          if git commit -m "Update autogenerated product files"; then
+            echo "GENERATED_ANOTHER_COMMIT=true" >> $GITHUB_ENV
+          else
+            echo "No changes detected"
+          fi
+      - name: Update products autogenerated content blocks
+        run: |
+          scripts/update_products_autogenerated_content_blocks
+          git add products/content
+
+          if git commit -m "Update autogenerated products content blocks"; then
+            echo "GENERATED_ANOTHER_COMMIT=true" >> $GITHUB_ENV
+          else
+            echo "No changes detected"
+          fi
+      - name: Set merge base
+        run: |
+          if [ "${{ github.ref }}" = "refs/heads/master" ]; then
+            echo "MERGE_BASE=origin/stable" >> $GITHUB_ENV
+          else
+            echo "MERGE_BASE=$(git show-branch --merge-base origin/master ${{ steps.extract_branch.outputs.branch }})" >> $GITHUB_ENV
+          fi
+      - if: "env.PR_NUMBER != 0"
+        name: Write byproduct validate output to env variable
+        run: |
+          set +e
+          IFS= output=$(./gitapult changeset --git_root "$GITHUB_WORKSPACE" --from "${{ env.MERGE_BASE }}" | ./byproduct validate --markdown 2>&1)
+          status=$?
+
+          if (($status)); then
+            echo $output >>/tmp/pr-comment.txt
+            exit $status
+          else
+            echo "Byproduct validation succeeded."
+          fi
+      - name: Update feed autogenerated content blocks
+        run: |
+          nix run .#update_feeds_autogenerated_content_blocks
+          git add feeds/content
+
+          if git commit -m "Update autogenerated feed content blocks"; then
+            echo "GENERATED_ANOTHER_COMMIT=true" >> $GITHUB_ENV
+          else
+            echo "No changes detected"
+          fi
+      - name: Update feed autogenerated rendering data
+        run: |
+          POWERFUL_OWL_COMMAND=$GITHUB_WORKSPACE/powerful-owl POWERFUL_OWL_FOLDER=./feeds/ ./scripts/generate-rendering-data
+          git add ./feeds/page_rendering_data
+          git add ./feeds/rendering_data
+
+          if git commit -m "Update feed autogenerated rendering data"; then
+            echo "GENERATED_ANOTHER_COMMIT=true" >> $GITHUB_ENV
+          else
+            echo "No changes detected"
+          fi
+        working-directory: "${{ env.GIT_REPOSITORY_PATH }}"
+      - name: Update feed golden tests
+        run: |
+          scripts/update_feeds_golden_tests
+          git add feeds/tests/golden
+
+          if git commit -m "Update autogenerated feeds golden test outputs"; then
+            echo "GENERATED_ANOTHER_COMMIT=true" >> $GITHUB_ENV
+          else
+            echo "No changes detected"
+          fi
+      - name: Update autogenerated shipping configuration files
+        run: |
+          set +e
+          IFS= output=$(./config-rules-cli shipping-options-transform --input-csv-dir shipping/configuration --output-json-dir shipping/autogenerated)
+          status=$?
+
+          if (($status)); then
+            echo "Failed to transform shipping data" >>/tmp/pr-comment.txt
+            echo "" >>/tmp/pr-comment.txt
+            echo "\`\`\`" >>/tmp/pr-comment.txt
+            echo $output >>/tmp/pr-comment.txt
+            echo "\`\`\`" >>/tmp/pr-comment.txt
+            exit $status
+          else
+            git add shipping/autogenerated
+            if git commit -m "Update autogenerated shipping configuration files"; then
+              echo "GENERATED_ANOTHER_COMMIT=true" >> $GITHUB_ENV
+            else
+              echo "No changes detected"
+            fi
+          fi
+      - name: Generate new shipping tests
+        run: |
+          SHIPPING_TEST_DIRECTORY=shipping/tests
+            find $SHIPPING_TEST_DIRECTORY -type f -name '*.result.json' -exec rm {} \;
+              ./config-rules-cli -- shipping-options-check --repo-dir .
+      - if: "env.PR_NUMBER != 0 && github.ref != 'refs/heads/stable' && github.ref != 'refs/heads/master'"
+        name: Commit new shipping tests
+        run: |
+          if [ $(git status shipping/tests --porcelain=v1 | wc -l) -gt 0 ]
+          then
+            git add shipping/tests
+            git commit -m "Update shipping tests"
+            echo "GENERATED_ANOTHER_COMMIT=true" >> $GITHUB_ENV
+          fi
+      - name: Update autogenerated promotion configuration files
+        run: |
+          set +e
+          IFS= output=$(./config-rules-cli promotion-transform --input-csv-dir promotions/configuration --output-json-dir promotions/autogenerated)
+          status=$?
+
+          if (($status)); then
+            echo "Failed to transform promotions data" >>/tmp/pr-comment.txt
+            echo "" >>/tmp/pr-comment.txt
+            echo "\`\`\`" >>/tmp/pr-comment.txt
+            echo $output >>/tmp/pr-comment.txt
+            echo "\`\`\`" >>/tmp/pr-comment.txt
+            exit $status
+          else
+            git add promotions/autogenerated
+            if git commit -m "Update autogenerated promotions configuration files"; then
+              echo "GENERATED_ANOTHER_COMMIT=true" >> $GITHUB_ENV
+            else
+              echo "No changes detected"
+            fi
+          fi
+      - name: Generate new promotion rule tests
+        run: |
+          PROMOTIONS_TEST_DIRECTORY=promotions/tests
+            find $PROMOTIONS_TEST_DIRECTORY -type f -name '*.result.json' -exec rm {} \;
+              ./config-rules-cli -- promotion-check --repo-dir .
+      - if: "env.PR_NUMBER != 0 && github.ref != 'refs/heads/stable' && github.ref != 'refs/heads/master'"
+        name: Commit new promotion tests
+        run: |
+          if [ $(git status promotions/tests --porcelain=v1 | wc -l) -gt 0 ]
+          then
+            git add promotions/tests
+            git commit -m "Update promotion tests"
+            echo "GENERATED_ANOTHER_COMMIT=true" >> $GITHUB_ENV
+          fi
+      - if: "env.GENERATED_ANOTHER_COMMIT == 'true'"
+        name: Push any new commits and exit workflow if commits pushed
+        run: |
+          git push --set-upstream origin HEAD
+
+          gh run cancel ${{ github.run_id }}
+          gh run watch ${{ github.run_id }}
+      - id: current-time
+        name: Get current time
+        run: 'echo "timestamp=$(date -u +"%y%m%d%H%M%S")" >> $GITHUB_OUTPUT'
+      - id: extract_changes
+        if: "github.ref == 'refs/heads/stable' || github.ref == 'refs/heads/master'"
+        name: Set changes variable
+        run: |
+          echo "COMMIT_CHANGES<<EOF" >> $GITHUB_ENV
+          echo "$(git log --oneline --format=\"%s\" $(git tag -l [0-9]*_${{ env.ENVIRONMENT }} | tail -n 1)..${{ steps.extract_branch.outputs.branch }} | grep -v 'Merge pull request' | grep -v "Merge branch 'master' into" | sed -e 's/^"//' -e 's/"$//' | cat)" >> $GITHUB_ENV
+          echo "EOF" >> $GITHUB_ENV
+        shell: bash
+        working-directory: "${{ env.GITHUB_WORKSPACE }}"
+      - env:
+          SLACK_BOT_TOKEN: "${{ secrets.BELLROY_SLACK_TOKEN }}"
+        id: slack-deploy
+        if: "github.ref == 'refs/heads/stable' || github.ref == 'refs/heads/master'"
+        name: Slack - Notifications - Post to slack
+        uses: "slackapi/slack-github-action@70cd7be8e40a46e8b0eced40b0de447bdb42f68e"
+        with:
+          channel-id: "${{ env.SLACK_CHANNEL_ID }}"
+          payload: |
+            {
+              "text": ":loudspeaker: *${{ github.actor }}* is deploying *${{ env.SYSTEM_NAME }}* to *${{ env.DESTINATION }}* from *${{ steps.extract_branch.outputs.branch }}*",
+              "attachments": [
+                {
+                  "fallback": "Deployment summary",
+                  "color": "ffd966",
+                  "fields": [
+                    {
+                      "title": "What is being deployed",
+                      "value": ${{ toJSON(env.COMMIT_CHANGES) }},
+                      "short": false
+                    },
+                    {
+                      "title": "Status",
+                      "short": true,
+                      "value": "In Progress"
+                    }
+                  ]
+                }
+              ]
+            }
+      - if: "github.ref == 'refs/heads/master'"
+        name: Push content to staging
+        run: |
+          set -e
+          # XXX: Gitapult cannot distinguish datasets, so we push all data to
+          # both endpoints and allow the server to decide what to do.
+          # Kafka Webhooks accepts all datasets at once
+          ./gitapult run \
+            --git_root "$GITHUB_WORKSPACE" \
+            --authorization_token "$KW_STAGING_API_KEY" \
+            --changeset_url "$KW_STAGING_BASE_URL/gitapult/configuration/records" \
+            --revision_url "$KW_STAGING_BASE_URL/gitapult/configuration/revision" \
+            --api_key \
+            --compress \
+            --max-payload-bytes 3000000
+
+          # XXX: V3 doesn't care about rendering data, but it is too large to send.
+          # As at 2024-02-14 gitapult does not support selecting which data to send.
+          git -C "$GIT_REPOSITORY_PATH" rm feeds/rendering_data/.gitapult.json
+          ./gitapult run \
+            --git_root "$GITHUB_WORKSPACE" \
+            --authorization_token "$SF_STAGING_API_KEY" \
+            --changeset_url "$SF_STAGING_BASE_URL/api/v1/configuration/shipping" \
+            --revision_url "$SF_STAGING_BASE_URL/api/v1/configuration/shipping/revisions" && \
+          git -C "$GIT_REPOSITORY_PATH" restore --staged feeds/rendering_data/.gitapult.json
+          git -C "$GIT_REPOSITORY_PATH" restore feeds/rendering_data/.gitapult.json
+      - if: "success() && github.ref == 'refs/heads/master'"
+        name: Tag published staging content
+        run: |
+          git tag "${{ join(steps.current-time.outputs.*, '\n') }}_staging" && git push --tags
+      - if: "github.ref == 'refs/heads/master'"
+        name: Open PR to stable
+        run: |
+          curl -X POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: token ${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}" https://api.github.com/repos/${{ github.repository }}/pulls -d '{"head":"master","base":"stable","title":"Merge branch master into stable","body":"This PR was automatically generated by CI."}'
+          PR_URL=$(curl -sX GET -H "Accept: application/vnd.github.v3+json" -H "Authorization: token ${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}" https://api.github.com/repos/${{ github.repository }}/pulls\?head\=bellroy:master\&base\=stable | jq -r '.[0].html_url | select(length>0)')
+          echo "PR_URL=$PR_URL" >> $GITHUB_ENV
+      - continue-on-error: true
+        if: "github.ref == 'refs/heads/master'"
+        name: Set assignee
+        run: |
+          PR_RESPONSE=$(curl -sX GET -H "Accept: application/vnd.github.v3+json" -H "Authorization: token ${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}" https://api.github.com/repos/${{ github.repository }}/pulls\?head\=bellroy:master\&base\=stable | jq -r '.[0]')
+          PR_NUM=$(echo "$PR_RESPONSE" | jq -r ".number")
+          PR_ASSIGNEE=$(echo "$PR_RESPONSE" | jq -r ".assignee")
+          COMMITS_RESPONSE=$(curl -sX GET -H "Accept: application/vnd.github.v3+json" -H "Authorization: token ${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}" https://api.github.com/repos/${{ github.repository }}/pulls/$PR_NUM/commits)
+          FIRST_COMMITTER=$(curl -sX GET -H "Accept: application/vnd.github.v3+json" -H "Authorization: token ${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}" https://api.github.com/repos/${{ github.repository }}/pulls/$PR_NUM/commits | jq -r '. | sort_by(.commit.author.date) | .[0] | .author.login')
+          # special case for squashed PRs created by trike-deploy e.g. gha-workflows
+          if [[ "$FIRST_COMMITTER" == "trike-deploy" ]]; then
+              FIRST_COMMITTER=$(echo "$COMMITS_RESPONSE" | jq -r '. | sort_by(.commit.author.date) | .[0]' | sed -En "s/.*Co-authored-by: (.*) <.*@.*>\",/\1/p")
+          fi
+          if [[ "$PR_ASSIGNEE" == "null" ]]; then
+              curl -sX POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: token ${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}" https://api.github.com/repos/${{ github.repository }}/issues/$PR_NUM/assignees -d "{\"assignees\":[\"$FIRST_COMMITTER\"]}"
+          fi
+      - if: "github.ref == 'refs/heads/stable'"
+        name: Push content to production
+        run: |
+          set -e
+          # XXX: Gitapult cannot distinguish datasets, so we push all data to
+          # both endpoints and allow the server to decide what to do.
+          # Kafka Webhooks accepts all datasets at once
+          ./gitapult run \
+            --git_root "$GITHUB_WORKSPACE" \
+            --authorization_token "$KW_PRODUCTION_API_KEY" \
+            --changeset_url "$KW_PRODUCTION_BASE_URL/gitapult/configuration/records" \
+            --revision_url "$KW_PRODUCTION_BASE_URL/gitapult/configuration/revision" \
+            --api_key \
+            --compress \
+            --max-payload-bytes 3000000
+
+          # XXX: V3 doesn't care about rendering data, but it is too large to send.
+          # As at 2024-02-14 gitapult does not support selecting which data to send.
+          git -C "$GIT_REPOSITORY_PATH" rm feeds/rendering_data/.gitapult.json
+          ./gitapult run \
+            --git_root "$GITHUB_WORKSPACE" \
+            --authorization_token "$SF_PRODUCTION_API_KEY" \
+            --changeset_url "$SF_PRODUCTION_BASE_URL/api/v1/configuration/shipping" \
+            --revision_url "$SF_PRODUCTION_BASE_URL/api/v1/configuration/shipping/revisions" && \
+          git -C "$GIT_REPOSITORY_PATH" restore --staged feeds/rendering_data/.gitapult.json
+          git -C "$GIT_REPOSITORY_PATH" restore feeds/rendering_data/.gitapult.json
+      - if: "success() && github.ref == 'refs/heads/stable'"
+        name: Tag published production content
+        run: |
+          git tag "${{ join(steps.current-time.outputs.*, '\n') }}_production" && git push --tags
+      - if: "github.ref == 'refs/heads/master'"
+        name: Set success message variable for staging
+        run: |
+          echo "SUCCESS_MESSAGE=:tada: \nA PR has been opened against the stable branch: ${{ env.PR_URL }}" >> $GITHUB_ENV
+          echo "SUCCESS_MESSAGE_BODY=https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks\n\n*PR for Production:* ${{ env.PR_URL }}" >> $GITHUB_ENV
+      - if: "github.ref == 'refs/heads/stable'"
+        name: Set success message variable for production
+        run: |
+          echo "SUCCESS_MESSAGE=:tada:" >> $GITHUB_ENV
+          echo "SUCCESS_MESSAGE_BODY=https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks" >> $GITHUB_ENV
+      - env:
+          SLACK_BOT_TOKEN: "${{ secrets.BELLROY_SLACK_TOKEN }}"
+        id: slack-deploy-success
+        if: "success() && (github.ref == 'refs/heads/stable' || github.ref == 'refs/heads/master')"
+        name: Post to Slack if published successfully
+        uses: "slackapi/slack-github-action@70cd7be8e40a46e8b0eced40b0de447bdb42f68e"
+        with:
+          channel-id: "${{ env.SLACK_CHANNEL_ID }}"
+          payload: |
+            {
+              "text": ":loudspeaker: *${{ github.actor }}* has deployed *${{ env.SYSTEM_NAME }}* to *${{ env.DESTINATION }}* from *${{ steps.extract_branch.outputs.branch }}* ${{ env.SUCCESS_MESSAGE }}",
+              "attachments": [
+                {
+                  "fallback": "Deployment summary",
+                  "color": "00ff00",
+                  "fields": [
+                    {
+                      "title": "What was deployed",
+                      "value": ${{ toJSON(env.COMMIT_CHANGES) }},
+                      "short": false
+                    },
+                    {
+                      "title": "Status",
+                      "short": true,
+                      "value": "Deployed"
+                    },
+                    {
+                      "title": "Output",
+                      "short": false,
+                      "value": "${{ env.SUCCESS_MESSAGE_BODY }}"
+                    }
+                  ]
+                }
+              ]
+            }
+          update-ts: "${{ steps.slack-deploy.outputs.ts }}"
+      - env:
+          SLACK_BOT_TOKEN: "${{ secrets.BELLROY_SLACK_TOKEN }}"
+        id: slack-deploy-failed
+        if: "failure() && (github.ref == 'refs/heads/stable' || github.ref == 'refs/heads/master')"
+        name: Post to Slack if build/deploy fails
+        uses: "slackapi/slack-github-action@70cd7be8e40a46e8b0eced40b0de447bdb42f68e"
+        with:
+          channel-id: "${{ env.SLACK_CHANNEL_ID }}"
+          payload: |
+            { "text": ":loudspeaker: *BUILD/DEPLOY FAILURE* of *${{ env.SYSTEM_NAME }}* to *${{ env.DESTINATION }}*",
+              "attachments": [
+                {
+                  "fallback": "Deployment summary",
+                  "color": "ff0000",
+                  "fields": [
+                    {
+                      "title": "What was being deployed",
+                      "value": ${{ toJSON(env.COMMIT_CHANGES) }},
+                      "short": false
+                    },
+                    {
+                      "title": "Status",
+                      "short": true,
+                      "value": "Failed"
+                    },
+                    {
+                      "title": "Who broke it",
+                      "value": "${{ github.actor }}"
+                    },
+                    {
+                      "title": "Output",
+                      "short": false,
+                      "value": "https://github.com/${{ github.repository }}/commit/${{ github.sha }}/checks"
+                    }
+                  ]
+                }
+              ]
+            }
+          update-ts: "${{ steps.slack-deploy.outputs.ts }}"
+      - if: "always() && env.PR_NUMBER != 0"
+        name: Add comment to PR
+        run: "gh pr comment ${{ env.PR_NUMBER }} --body-file /tmp/pr-comment.txt || true"
+        working-directory: "${{ env.GITHUB_WORKSPACE }}"
+  validateContent:
+    runs-on: ubuntu-20.04
+    steps:
+      - id: checkout_branch
+        name: checkout branch
+        uses: "bellroy/gha-workflows/composite/checkout@master"
+        with:
+          fetch-depth: "1"
+          path: "${{ env.GITHUB_WORKSPACE }}"
+          token: "${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}"
+      - id: extract_branch
+        name: Extract branch name
+        run: |
+          echo "branch=${GITHUB_REF#refs/heads/}" >>$GITHUB_OUTPUT
+        shell: bash
+      - name: Install and setup nix
+        uses: "bellroy/gha-workflows/composite/install-nix@master"
+        with:
+          bellroy-nix-cache-access: none
+      - id: fetch_gitapult
+        name: Fetch gitapult
+        uses: "bellroy/gha-workflows/composite/get-tool@master"
+        with:
+          token: "${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}"
+          tool-name: gitapult
+          tool-version: latest
+      - id: fetch_powerful-owl
+        name: Fetch powerful-owl
+        uses: "bellroy/gha-workflows/composite/get-tool@master"
+        with:
+          token: "${{ secrets.BELLROY_DEPLOY_USER_TOKEN }}"
+          tool-name: powerful-owl
+          tool-version: latest
+      - name: Get Pull Request number
+        run: |
+          echo "PR_NUMBER=$(gh pr view --json number -q .number)" >> $GITHUB_ENV
+        working-directory: "${{ env.GITHUB_WORKSPACE }}"
+      - name: Ensure tmp directory exists
+        run: mkdir -p tmp
+        working-directory: "${{ env.GITHUB_WORKSPACE }}"
+      - name: Validate with gitapult
+        run: |
+          ./gitapult validate --git_root "$GITHUB_WORKSPACE"
+      - name: Validate content with powerful-owl
+        run: |
+          for CONTENT_DIR in feeds products
+          do
+            if [[ "$PR_NUMBER" ]]
+            then
+              ./powerful-owl validate --markdown --content_path "$GITHUB_WORKSPACE/$CONTENT_DIR" &>>/tmp/pr-comment.txt
+            else
+              ./powerful-owl validate --content_path "$GITHUB_WORKSPACE/$CONTENT_DIR"
+            fi
+          done
+      - if: "always() && env.PR_NUMBER != 0"
+        name: Add comment to PR
+        run: "gh pr comment ${{ env.PR_NUMBER }} --body-file /tmp/pr-comment.txt || true"
+        working-directory: "${{ env.GITHUB_WORKSPACE }}"
+      - if: "failure() && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/stable')"
+        name: Post to slack if build fails
+        uses: "bellroy/gha-workflows/composite/slack-update@master"
+        with:
+          branch: "${{ steps.checkout_branch.outputs.branch }}"
+          channel-id: "${{ env.SLACK_CHANNEL_ID }}"
+          message-type: build-fail
+          show-authors: "true"
+          slack-bot-token: "${{ secrets.BELLROY_SLACK_TOKEN }}"
+    timeout-minutes: 30
+name: Publish
+on:
+  push:
+    branches-ignore:
+      - "refs/tags/*_staging"
+      - "refs/tags/*_production"
