diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d94ffd..f4f55e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # CHANGELOG +## Version 0.3.0.0 + +- Changed `runsOn` from `Maybe Text` to `Maybe RunsOn` to support GitHub Actions + runner label lists (e.g. `runs-on: [self-hosted, linux]`), while preserving + the original YAML representation during round-trip serialization + ## Version 0.2.0 (2025-07-21) - Changed `cancelInProgress` from `Maybe Bool` to `Maybe Text` to support GitHub Actions expressions diff --git a/README.md b/README.md index 3750b7c..e667928 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ 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.Job.RunsOn as RunsOn import qualified Language.Github.Actions.Step as Step import qualified Language.Github.Actions.Workflow.Trigger as Trigger @@ -38,7 +39,7 @@ myWorkflow = new buildJob :: Job.Job buildJob = Job.new { Job.jobName = Just "Build and Test" - , Job.runsOn = Just "ubuntu-latest" + , Job.runsOn = Just (RunsOn.RunsOnString "ubuntu-latest") , Job.steps = Just $ checkoutStep :| [buildStep, testStep] } diff --git a/github-actions.cabal b/github-actions.cabal index 2c5d50f..f49fd48 100644 --- a/github-actions.cabal +++ b/github-actions.cabal @@ -1,6 +1,6 @@ cabal-version: 2.2 name: github-actions -version: 0.2.0.0 +version: 0.3.0.0 synopsis: Github Actions description: This library provides types and instances for serializing and deserializing @@ -60,6 +60,7 @@ library Language.Github.Actions.Job.Environment Language.Github.Actions.Job.Id Language.Github.Actions.Job.Needs + Language.Github.Actions.Job.RunsOn Language.Github.Actions.Job.Strategy Language.Github.Actions.Permissions Language.Github.Actions.RunIf diff --git a/src/Language/Github/Actions/Job.hs b/src/Language/Github/Actions/Job.hs index 9960981..c5df01d 100644 --- a/src/Language/Github/Actions/Job.hs +++ b/src/Language/Github/Actions/Job.hs @@ -45,6 +45,8 @@ import Language.Github.Actions.Job.Environment (JobEnvironment) import qualified Language.Github.Actions.Job.Environment as JobEnvironment import Language.Github.Actions.Job.Needs (JobNeeds) import qualified Language.Github.Actions.Job.Needs as JobNeeds +import Language.Github.Actions.Job.RunsOn (RunsOn) +import qualified Language.Github.Actions.Job.RunsOn as RunsOn import Language.Github.Actions.Job.Strategy (JobStrategy) import qualified Language.Github.Actions.Job.Strategy as JobStrategy import Language.Github.Actions.Permissions (Permissions) @@ -73,7 +75,7 @@ import qualified Language.Github.Actions.Step as Step -- myJob :: Job -- myJob = new -- { jobName = Just "Build and Test" --- , runsOn = Just "ubuntu-latest" +-- , runsOn = Just (RunsOn.RunsOnString "ubuntu-latest") -- , steps = Just $ Step.new :| [] -- } -- @ @@ -102,8 +104,8 @@ data Job = Job permissions :: Maybe Permissions, -- | Condition for running this job runIf :: Maybe RunIf, - -- | Runner type (e.g., "ubuntu-latest") - runsOn :: Maybe Text, + -- | Runner type (e.g., "ubuntu-latest") or a list of runner labels + runsOn :: Maybe RunsOn, -- | Secrets available to this job secrets :: Map Text Text, -- | Services to run alongside this job @@ -185,7 +187,7 @@ gen = do outputs <- genTextMap permissions <- Gen.maybe Permissions.gen runIf <- Gen.maybe RunIf.gen - runsOn <- Gen.maybe genText + runsOn <- Gen.maybe RunsOn.gen 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) @@ -208,7 +210,7 @@ gen = do -- @ -- buildJob = new -- { jobName = Just "Build" --- , runsOn = Just "ubuntu-latest" +-- , runsOn = Just (RunsOn.RunsOnString "ubuntu-latest") -- , steps = Just $ checkoutStep :| [buildStep] -- } -- @ diff --git a/src/Language/Github/Actions/Job/RunsOn.hs b/src/Language/Github/Actions/Job/RunsOn.hs new file mode 100644 index 0000000..4a07e03 --- /dev/null +++ b/src/Language/Github/Actions/Job/RunsOn.hs @@ -0,0 +1,79 @@ +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE DerivingStrategies #-} + +-- | +-- Module : Language.Github.Actions.Job.RunsOn +-- Description : Runner specification for GitHub Actions jobs +-- Copyright : (c) 2025 Bellroy Pty Ltd +-- License : BSD-3-Clause +-- Maintainer : Bellroy Tech Team +-- +-- This module provides the 'RunsOn' type for representing the runner +-- configuration of a GitHub Actions job. GitHub Actions allows both strings +-- and lists of strings for the 'runs-on' field. +-- +-- Examples of valid 'runs-on' specifications: +-- * @runs-on: ubuntu-latest@ - A single runner specified as a string +-- * @runs-on: [self-hosted, linux]@ - A runner specified as a list of labels +-- * @runs-on: [self-hosted, linux, x64]@ - A runner specified as a list of labels +-- +-- For more information about GitHub Actions runner selection, see: +-- +module Language.Github.Actions.Job.RunsOn + ( RunsOn (..), + gen, + ) +where + +import Data.Aeson (FromJSON, ToJSON (..), Value (..)) +import qualified Data.Aeson as Aeson +import Data.List.NonEmpty (NonEmpty) +import Data.Text (Text) +import GHC.Generics (Generic) +import Hedgehog (MonadGen) +import qualified Hedgehog.Gen as Gen +import qualified Hedgehog.Range as Range + +-- | Runner specification that preserves YAML representation. +-- +-- GitHub Actions supports flexible runner specification: +-- +-- * 'RunsOnString' - A single runner label as a string like @runs-on: ubuntu-latest@ +-- * 'RunsOnArray' - A list of runner labels like @runs-on: [self-hosted, linux]@ +-- +-- Examples: +-- +-- @ +-- -- Single runner label (string form) +-- stringRunner :: RunsOn +-- stringRunner = RunsOnString "ubuntu-latest" +-- +-- -- Multiple runner labels (array form) +-- arrayRunner :: RunsOn +-- arrayRunner = RunsOnArray ("self-hosted" :| ["linux", "x64"]) +-- @ +-- +-- 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 RunsOn + = RunsOnString Text + | RunsOnArray (NonEmpty Text) + deriving stock (Eq, Generic, Ord, Show) + +instance FromJSON RunsOn where + parseJSON v@(Array _) = RunsOnArray <$> Aeson.parseJSON v + parseJSON v = RunsOnString <$> Aeson.parseJSON v + +instance ToJSON RunsOn where + toJSON (RunsOnString label) = toJSON label + toJSON (RunsOnArray labels) = toJSON labels + +gen :: (MonadGen m) => m RunsOn +gen = + Gen.choice + [ RunsOnString <$> genText, + RunsOnArray <$> Gen.nonEmpty (Range.linear 1 5) genText + ] + where + genText = Gen.text (Range.linear 1 5) Gen.alphaNum diff --git a/test/golden/configuration-main.hs.txt b/test/golden/configuration-main.hs.txt index 627bf62..251de68 100644 --- a/test/golden/configuration-main.hs.txt +++ b/test/golden/configuration-main.hs.txt @@ -22,7 +22,7 @@ Workflow , outputs = fromList [] , permissions = Nothing , runIf = Nothing - , runsOn = Just "ubuntu-20.04" + , runsOn = Just (RunsOnString "ubuntu-20.04") , secrets = fromList [] , services = fromList [] , steps = @@ -871,7 +871,7 @@ Workflow , outputs = fromList [] , permissions = Nothing , runIf = Nothing - , runsOn = Just "ubuntu-20.04" + , runsOn = Just (RunsOnString "ubuntu-20.04") , secrets = fromList [] , services = fromList [] , steps = diff --git a/test/golden/issue-8-boolean-if.hs.txt b/test/golden/issue-8-boolean-if.hs.txt index 40097cb..3da6129 100644 --- a/test/golden/issue-8-boolean-if.hs.txt +++ b/test/golden/issue-8-boolean-if.hs.txt @@ -17,7 +17,7 @@ Workflow , outputs = fromList [] , permissions = Nothing , runIf = Nothing - , runsOn = Just "ubuntu-latest" + , runsOn = Just (RunsOnString "ubuntu-latest") , secrets = fromList [] , services = fromList [] , steps = diff --git a/test/golden/issue-8-numeric-retention.hs.txt b/test/golden/issue-8-numeric-retention.hs.txt index 84287d7..4d76a2b 100644 --- a/test/golden/issue-8-numeric-retention.hs.txt +++ b/test/golden/issue-8-numeric-retention.hs.txt @@ -17,7 +17,7 @@ Workflow , outputs = fromList [] , permissions = Nothing , runIf = Nothing - , runsOn = Just "ubuntu-latest" + , runsOn = Just (RunsOnString "ubuntu-latest") , secrets = fromList [] , services = fromList [] , steps = diff --git a/test/golden/issue-8-runs-on-list.golden.yml b/test/golden/issue-8-runs-on-list.golden.yml new file mode 100644 index 0000000..f69535c --- /dev/null +++ b/test/golden/issue-8-runs-on-list.golden.yml @@ -0,0 +1,18 @@ +jobs: + array-runner: + runs-on: + - self-hosted + - linux + - x64 + steps: + - run: echo "array" + string-runner: + runs-on: ubuntu-latest + steps: + - run: echo "string" +name: Test RunsOn +'on': + push: + branches-ignore: + - refs/tags/*_staging + - refs/tags/*_production diff --git a/test/golden/issue-8-runs-on-list.hs.txt b/test/golden/issue-8-runs-on-list.hs.txt new file mode 100644 index 0000000..6caceca --- /dev/null +++ b/test/golden/issue-8-runs-on-list.hs.txt @@ -0,0 +1,100 @@ +Workflow + { concurrency = Nothing + , defaults = Nothing + , env = fromList [] + , jobs = + fromList + [ ( JobId "array-runner" + , 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 (RunsOnArray ("self-hosted" :| [ "linux" , "x64" ])) + , secrets = fromList [] + , services = fromList [] + , steps = + Just + (Step + { continueOnError = False + , env = fromList [] + , name = Nothing + , run = Just "echo \"array\"" + , runIf = Nothing + , shell = Nothing + , stepId = Nothing + , timeoutMinutes = Nothing + , uses = Nothing + , with = Nothing + , workingDirectory = Nothing + } :| + []) + , strategy = Nothing + , timeoutMinutes = Nothing + , uses = Nothing + , with = fromList [] + } + ) + , ( JobId "string-runner" + , 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 (RunsOnString "ubuntu-latest") + , secrets = fromList [] + , services = fromList [] + , steps = + Just + (Step + { continueOnError = False + , env = fromList [] + , name = Nothing + , run = Just "echo \"string\"" + , runIf = Nothing + , shell = Nothing + , stepId = Nothing + , timeoutMinutes = Nothing + , uses = Nothing + , with = Nothing + , workingDirectory = Nothing + } :| + []) + , strategy = Nothing + , timeoutMinutes = Nothing + , 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 "Test RunsOn" + } \ No newline at end of file diff --git a/test/golden/issue-8-runs-on-list.yml b/test/golden/issue-8-runs-on-list.yml new file mode 100644 index 0000000..38994c6 --- /dev/null +++ b/test/golden/issue-8-runs-on-list.yml @@ -0,0 +1,16 @@ +name: Test RunsOn +on: + push: + branches-ignore: + - "refs/tags/*_staging" + - "refs/tags/*_production" + +jobs: + string-runner: + runs-on: ubuntu-latest + steps: + - run: echo "string" + array-runner: + runs-on: [self-hosted, linux, x64] + steps: + - run: echo "array" diff --git a/test/golden/issue-8-string-needs.hs.txt b/test/golden/issue-8-string-needs.hs.txt index 73e5bb4..07eccbe 100644 --- a/test/golden/issue-8-string-needs.hs.txt +++ b/test/golden/issue-8-string-needs.hs.txt @@ -17,7 +17,7 @@ Workflow , outputs = fromList [] , permissions = Nothing , runIf = Nothing - , runsOn = Just "ubuntu-latest" + , runsOn = Just (RunsOnString "ubuntu-latest") , secrets = fromList [] , services = fromList [] , steps = @@ -55,7 +55,7 @@ Workflow , outputs = fromList [] , permissions = Nothing , runIf = Nothing - , runsOn = Just "ubuntu-latest" + , runsOn = Just (RunsOnString "ubuntu-latest") , secrets = fromList [] , services = fromList [] , steps =