-
Notifications
You must be signed in to change notification settings - Fork 0
Support undocumented Github Actions values, and preserve roundtripping #10
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
26cd6cc
15432ad
32dca66
66044bf
53f6b8b
e5684d3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| {-# LANGUAGE DeriveGeneric #-} | ||
| {-# LANGUAGE DerivingStrategies #-} | ||
|
|
||
| -- | | ||
| -- Module : Language.Github.Actions.Job.Needs | ||
| -- Description : Job dependency specification for GitHub Actions | ||
| -- Copyright : (c) 2025 Bellroy Pty Ltd | ||
| -- License : BSD-3-Clause | ||
| -- Maintainer : Bellroy Tech Team <haskell@bellroy.com> | ||
| -- | ||
| -- This module provides the 'JobNeeds' type for representing job dependencies | ||
| -- in GitHub Actions workflows. GitHub Actions allows both strings and | ||
| -- lists of strings for the 'needs' field. | ||
| -- | ||
| -- Examples of valid 'needs' specifications: | ||
| -- * @needs: build@ - Single job specified as a string | ||
| -- * @needs: [build]@ - Single job specified as a list of strings | ||
| -- * @needs: [build, test]@ - Multiple job dependencies specified as a list of strings | ||
| -- | ||
| -- For more information about GitHub Actions job dependencies, see: | ||
| -- <https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idneeds> | ||
| module Language.Github.Actions.Job.Needs | ||
| ( JobNeeds (..), | ||
| gen, | ||
| ) | ||
| where | ||
|
|
||
| import Data.Aeson (FromJSON, ToJSON (..), Value (..)) | ||
| import qualified Data.Aeson as Aeson | ||
| import Data.List.NonEmpty (NonEmpty) | ||
| import GHC.Generics (Generic) | ||
| import Hedgehog (MonadGen) | ||
| import qualified Hedgehog.Gen as Gen | ||
| import qualified Hedgehog.Range as Range | ||
| import Language.Github.Actions.Job.Id (JobId) | ||
| import qualified Language.Github.Actions.Job.Id as JobId | ||
|
|
||
| -- | Job dependency specification that preserves YAML representation. | ||
| -- | ||
| -- GitHub Actions supports flexible job dependency specification: | ||
| -- | ||
| -- * 'JobNeedsString' - Single job dependency as string like @needs: build@ | ||
| -- * 'JobNeedsArray' - Multiple job dependencies as array like @needs: [build, test]@ | ||
| -- | ||
| -- Examples: | ||
| -- | ||
| -- @ | ||
| -- -- Single job dependency (string form) | ||
| -- stringDep :: JobNeeds | ||
| -- stringDep = JobNeedsString (JobId "build") | ||
| -- | ||
| -- -- Multiple job dependencies (array form) | ||
| -- arrayDeps :: JobNeeds | ||
| -- arrayDeps = JobNeedsArray (JobId "build" :| [JobId "test", JobId "lint"]) | ||
| -- @ | ||
| -- | ||
| -- The type preserves the original YAML format during round-trip serialization. | ||
| -- A string input will serialize back to a string, and an array input will | ||
| -- serialize back to an array, preventing information loss. | ||
| data JobNeeds | ||
| = JobNeedsString JobId | ||
| | JobNeedsArray (NonEmpty JobId) | ||
| deriving stock (Eq, Generic, Ord, Show) | ||
|
|
||
| instance FromJSON JobNeeds where | ||
| parseJSON v@(Array _) = JobNeedsArray <$> Aeson.parseJSON v | ||
| parseJSON v = JobNeedsString <$> Aeson.parseJSON v | ||
|
|
||
| instance ToJSON JobNeeds where | ||
| toJSON (JobNeedsString jobId) = toJSON jobId | ||
| toJSON (JobNeedsArray jobIds) = toJSON jobIds | ||
|
|
||
| gen :: (MonadGen m) => m JobNeeds | ||
| gen = | ||
| Gen.choice | ||
| [ JobNeedsString <$> JobId.gen, | ||
| JobNeedsArray <$> Gen.nonEmpty (Range.linear 1 5) JobId.gen | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| {-# LANGUAGE DeriveGeneric #-} | ||
| {-# LANGUAGE DerivingStrategies #-} | ||
| {-# LANGUAGE OverloadedStrings #-} | ||
|
|
||
| -- | | ||
| -- Module : Language.Github.Actions.RunIf | ||
| -- Description : Flexible conditional expressions for GitHub Actions | ||
| -- Copyright : (c) 2025 Bellroy Pty Ltd | ||
| -- License : BSD-3-Clause | ||
| -- Maintainer : Bellroy Tech Team <haskell@bellroy.com> | ||
| -- | ||
| -- This module provides the 'RunIf' type for representing conditional expressions | ||
| -- in GitHub Actions workflows. GitHub Actions allows both boolean and string | ||
| -- expressions in 'if' conditions for jobs and steps. | ||
| -- | ||
| -- Examples of valid 'if' conditions: | ||
| -- * @if: false@ - Boolean | ||
| -- * @if: "github.ref == 'refs/heads/main'"@ - String | ||
| -- | ||
| -- For more information about GitHub Actions conditional expressions, see: | ||
| -- <https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idif> | ||
| module Language.Github.Actions.RunIf | ||
| ( RunIf (..), | ||
| gen, | ||
| ) | ||
| where | ||
|
|
||
| import Data.Aeson (FromJSON, ToJSON (..), Value (..)) | ||
| 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 | ||
|
|
||
| -- | A conditional expression that can be either a boolean or string. | ||
| -- | ||
| -- GitHub Actions supports flexible 'if' conditions: | ||
| -- | ||
| -- * 'RunIfBool' - Simple boolean values like @true@ or @false@ | ||
| -- * 'RunIfString' - GitHub expressions like @"github.ref == 'refs/heads/main'"@ | ||
| -- | ||
| -- Examples: | ||
| -- | ||
| -- @ | ||
| -- -- Simple boolean condition | ||
| -- simpleFalse :: RunIf | ||
| -- simpleFalse = RunIfBool False | ||
| -- | ||
| -- -- GitHub expression condition | ||
| -- branchCheck :: RunIf | ||
| -- branchCheck = RunIfString "github.ref == 'refs/heads/main'" | ||
| -- @ | ||
| -- | ||
| -- The type preserves the original format during round-trip serialization, | ||
| -- so a boolean input remains a boolean in the output YAML. | ||
| data RunIf | ||
| = RunIfBool Bool | ||
| | RunIfString Text | ||
| deriving stock (Eq, Generic, Ord, Show) | ||
|
|
||
| instance FromJSON RunIf where | ||
| parseJSON (Bool b) = pure $ RunIfBool b | ||
| parseJSON (String s) = pure $ RunIfString s | ||
| parseJSON v = fail $ "Expected Bool or String for RunIf, got: " ++ show v | ||
|
|
||
| instance ToJSON RunIf where | ||
| toJSON (RunIfBool b) = Bool b | ||
| toJSON (RunIfString s) = String s | ||
|
|
||
| gen :: (MonadGen m) => m RunIf | ||
| gen = | ||
| Gen.choice | ||
| [ RunIfBool <$> Gen.bool, | ||
| RunIfString <$> Gen.text (Range.linear 5 50) Gen.alphaNum | ||
| ] | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| {-# LANGUAGE DeriveGeneric #-} | ||
| {-# LANGUAGE DerivingStrategies #-} | ||
| {-# LANGUAGE GeneralizedNewtypeDeriving #-} | ||
| {-# LANGUAGE OverloadedStrings #-} | ||
|
|
||
| -- | | ||
| -- Module : Language.Github.Actions.UnstructuredMap | ||
| -- Description : Flexible value types for GitHub Actions YAML parsing | ||
| -- Copyright : (c) 2025 Bellroy Pty Ltd | ||
| -- License : BSD-3-Clause | ||
| -- Maintainer : Bellroy Tech Team <haskell@bellroy.com> | ||
| -- | ||
| -- This module provides the 'UnstructuredMap' type for representing a map of | ||
| -- values that can be strings, numbers, or booleans in GitHub Actions YAML files. | ||
| -- | ||
| -- GitHub Actions allows flexible typing in many contexts: | ||
| -- * @retention-days: 1@ (number) | ||
| -- * @retention-days: "1"@ (string) | ||
| -- * @should-retain: false@ (boolean) | ||
| -- | ||
| -- This type preserves the original YAML type during round-trip parsing, | ||
| -- ensuring that numeric values remain numeric and strings remain strings. | ||
| module Language.Github.Actions.UnstructuredMap | ||
| ( UnstructuredValue (..), | ||
| UnstructuredMap (..), | ||
| renderUnstructuredValue, | ||
| gen, | ||
| ) | ||
| where | ||
|
|
||
| import Data.Aeson (FromJSON, ToJSON (..), Value (..)) | ||
| import qualified Data.Aeson as Aeson | ||
| import Data.Map (Map) | ||
| 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 | ||
|
|
||
| -- | A map that can have values of string, number, or boolean. | ||
| -- | ||
| -- This type is designed to handle the flexible typing that GitHub Actions | ||
| -- allows in YAML files. | ||
| -- | ||
| -- The type preserves the original format during round-trip serialization, | ||
| -- so numeric inputs remain numeric in the output YAML. | ||
| data UnstructuredValue | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can GHA YAML use nulls here, and if so, should we represent them?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hrm hypothetically a null can be represented in the YAML, but I cannot think of any instance where having a null in a GHA workflow would be valid There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wonder if It's still debatable, of course, whether a |
||
| = UnstructuredValueString Text | ||
| | UnstructuredValueNumber Double | ||
| | UnstructuredValueBool Bool | ||
| deriving stock (Eq, Generic, Ord, Show) | ||
|
|
||
| instance FromJSON UnstructuredValue where | ||
| parseJSON (String s) = pure $ UnstructuredValueString s | ||
| parseJSON (Number n) = pure $ UnstructuredValueNumber (realToFrac n) | ||
| parseJSON (Bool b) = pure $ UnstructuredValueBool b | ||
| parseJSON v = fail $ "Expected String, Number, or Bool for UnstructuredValue, got: " ++ show v | ||
|
|
||
| instance ToJSON UnstructuredValue where | ||
| toJSON (UnstructuredValueString s) = String s | ||
| toJSON (UnstructuredValueNumber n) = Number (fromRational (toRational n)) | ||
| toJSON (UnstructuredValueBool b) = Bool b | ||
|
|
||
| renderUnstructuredValue :: UnstructuredValue -> Text | ||
| renderUnstructuredValue (UnstructuredValueString s) = s | ||
| renderUnstructuredValue (UnstructuredValueNumber n) = | ||
| -- Format numbers nicely, avoiding unnecessary decimal places for integers | ||
| if n == fromInteger (round n) | ||
| then Text.pack (show (round n :: Integer)) | ||
| else Text.pack (show n) | ||
| renderUnstructuredValue (UnstructuredValueBool b) = if b then "true" else "false" | ||
|
|
||
| newtype UnstructuredMap = UnstructuredMap (Map Text UnstructuredValue) | ||
| deriving stock (Eq, Generic, Ord, Show) | ||
| deriving newtype (FromJSON, ToJSON) | ||
|
|
||
| genUnstructuredValue :: (MonadGen m) => m UnstructuredValue | ||
| genUnstructuredValue = | ||
| Gen.choice | ||
| [ UnstructuredValueString <$> Gen.text (Range.linear 1 20) Gen.alphaNum, | ||
| UnstructuredValueNumber <$> Gen.realFloat (Range.linearFrac 0 1000), | ||
| UnstructuredValueBool <$> Gen.bool | ||
| ] | ||
|
|
||
| gen :: (MonadGen m) => m UnstructuredMap | ||
| gen = UnstructuredMap <$> Gen.map (Range.linear 0 10) genKeyValue | ||
| where | ||
| genKeyValue = do | ||
| key <- Gen.text (Range.linear 1 20) Gen.alphaNum | ||
| value <- genUnstructuredValue | ||
| pure (key, value) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Possible future work: capture a proper AST here?