cuelang.org/go@v0.13.0/internal/ci/base/github.cue (about)

     1  package base
     2  
     3  // This file contains aspects principally related to GitHub workflows
     4  
     5  import (
     6  	"encoding/json"
     7  	"list"
     8  	"strings"
     9  	"strconv"
    10  	"cue.dev/x/githubactions"
    11  )
    12  
    13  bashWorkflow: githubactions.#Workflow & {
    14  	// Use a custom default shell that extends the GitHub default to also fail
    15  	// on access to unset variables.
    16  	//
    17  	// https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#defaultsrunshell
    18  	jobs: [string]: defaults: run: shell: "bash --noprofile --norc -euo pipefail {0}"
    19  }
    20  
    21  installGo: {
    22  	#setupGo: githubactions.#Step & {
    23  		name: "Install Go"
    24  		uses: "actions/setup-go@v5"
    25  		with: {
    26  			// We do our own caching in setupGoActionsCaches.
    27  			cache:        false
    28  			"go-version": string
    29  		}
    30  	}
    31  
    32  	// Why set GOTOOLCHAIN here? As opposed to an environment variable
    33  	// elsewhere? No perfect answer to this question but here is the thinking:
    34  	//
    35  	// Setting the variable here localises it with the installation of Go. Doing
    36  	// it elsewhere creates distance between the two steps which are
    37  	// intrinsically related. And it's also hard to do: "when we use this step,
    38  	// also ensure that we establish an environment variable in the job for
    39  	// GOTOOLCHAIN".
    40  	//
    41  	// Environment variables can only be set at a workflow, job or step level.
    42  	// Given we currently use a matrix strategy which varies the Go version,
    43  	// that rules out using an environment variable based approach, because the
    44  	// Go version is only available at runtime via GitHub actions provided
    45  	// context. Whether we should instead be templating multiple workflows (i.e.
    46  	// exploding the matrix ourselves) is a different question, but one that
    47  	// has performance implications.
    48  	//
    49  	// So as clumsy as it is to use a step "template" that includes more than
    50  	// one step, it's the best option available to us for now.
    51  	[
    52  		#setupGo,
    53  
    54  		{
    55  			githubactions.#Step & {
    56  				name: "Set common go env vars"
    57  				run: """
    58  					go env -w GOTOOLCHAIN=local
    59  
    60  					# Dump env for good measure
    61  					go env
    62  					"""
    63  			}
    64  		},
    65  	]
    66  }
    67  
    68  checkoutCode: {
    69  	#actionsCheckout: githubactions.#Step & {
    70  		name: "Checkout code"
    71  		uses: "actions/checkout@v4"
    72  
    73  		// "pull_request_target" builds will by default use a merge commit,
    74  		// testing the PR's HEAD merged on top of the master branch.
    75  		// For consistency with Gerrit, avoid that merge commit entirely.
    76  		// This doesn't affect builds by other events like "push",
    77  		// since github.event.pull_request is unset so ref remains empty.
    78  		with: {
    79  			ref:           "${{ github.event.pull_request.head.sha }}"
    80  			"fetch-depth": 0 // see the docs below
    81  		}
    82  	}
    83  
    84  	[
    85  		#actionsCheckout,
    86  
    87  		// Restore modified times to work around https://go.dev/issues/58571,
    88  		// as otherwise we would get lots of unnecessary Go test cache misses.
    89  		// Note that this action requires actions/checkout to use a fetch-depth of 0.
    90  		// Since this is a third-party action which runs arbitrary code,
    91  		// we pin a commit hash for v2 to be in control of code updates.
    92  		// Also note that git-restore-mtime does not update all directories,
    93  		// per the bug report at https://github.com/MestreLion/git-tools/issues/47,
    94  		// so we first reset all directory timestamps to a static time as a fallback.
    95  		// TODO(mvdan): May be unnecessary once the Go bug above is fixed.
    96  		githubactions.#Step & {
    97  			name: "Reset git directory modification times"
    98  			run:  "touch -t 202211302355 $(find * -type d)"
    99  		},
   100  		githubactions.#Step & {
   101  			name: "Restore git file modification times"
   102  			uses: "chetan/git-restore-mtime-action@075f9bc9d159805603419d50f794bd9f33252ebe"
   103  		},
   104  
   105  		{
   106  			githubactions.#Step & {
   107  				name: "Try to extract \(dispatchTrailer)"
   108  				id:   dispatchTrailerStepID
   109  				run:  """
   110  					x="$(git log -1 --pretty='%(trailers:key=\(dispatchTrailer),valueonly)')"
   111  					if [[ "$x" == "" ]]
   112  					then
   113  					   # Some steps rely on the presence or otherwise of the Dispatch-Trailer.
   114  					   # We know that we don't have a Dispatch-Trailer in this situation,
   115  					   # hence we use the JSON value null in order to represent that state.
   116  					   # This means that GitHub expressions can determine whether a Dispatch-Trailer
   117  					   # is present or not by checking whether the fromJSON() result of the
   118  					   # output from this step is the JSON value null or not.
   119  					   x=null
   120  					fi
   121  					echo "\(_dispatchTrailerDecodeStepOutputVar)<<EOD" >> $GITHUB_OUTPUT
   122  					echo "$x" >> $GITHUB_OUTPUT
   123  					echo "EOD" >> $GITHUB_OUTPUT
   124  					"""
   125  			}
   126  		},
   127  
   128  		// Safety nets to flag if we ever have a Dispatch-Trailer slip through the
   129  		// net and make it to master
   130  		githubactions.#Step & {
   131  			name: "Check we don't have \(dispatchTrailer) on a protected branch"
   132  			if:   "\(isProtectedBranch) && \(containsDispatchTrailer)"
   133  			run:  """
   134  				echo "\(_dispatchTrailerVariable) contains \(dispatchTrailer) but we are on a protected branch"
   135  				false
   136  				"""
   137  		},
   138  	]
   139  }
   140  
   141  earlyChecks: githubactions.#Step & {
   142  	name: "Early git and code sanity checks"
   143  	run:  *"go run cuelang.org/go/internal/ci/checks@v0.11.0-0.dev.0.20240903133435-46fb300df650" | string
   144  }
   145  
   146  curlGitHubAPI: {
   147  	#tokenSecretsKey: *botGitHubUserTokenSecretsKey | string
   148  
   149  	#"""
   150  	curl -s -L -H "Accept: application/vnd.github+json" -H "Authorization: Bearer ${{ secrets.\#(#tokenSecretsKey) }}" -H "X-GitHub-Api-Version: 2022-11-28"
   151  	"""#
   152  }
   153  
   154  setupGoActionsCaches: {
   155  	// #readonly determines whether we ever want to write the cache back. The
   156  	// writing of a cache back (for any given cache key) should only happen on a
   157  	// protected branch. But running a workflow on a protected branch often
   158  	// implies that we want to skip the cache to ensure we catch flakes early.
   159  	// Hence the concept of clearing the testcache to ensure we catch flakes
   160  	// early can be defaulted based on #readonly. In the general case the two
   161  	// concepts are orthogonal, hence they are kept as two parameters, even
   162  	// though in our case we could get away with a single parameter that
   163  	// encapsulates our needs.
   164  	#readonly:       *false | bool
   165  	#cleanTestCache: *!#readonly | bool
   166  	#goVersion:      string
   167  	#additionalCacheDirs: [...string]
   168  	#os: string
   169  
   170  	let goModCacheDirID = "go-mod-cache-dir"
   171  	let goCacheDirID = "go-cache-dir"
   172  
   173  	// cacheDirs is a convenience variable that includes
   174  	// GitHub expressions that represent the directories
   175  	// that participate in Go caching.
   176  	let cacheDirs = list.Concat([[
   177  		"${{ steps.\(goModCacheDirID).outputs.dir }}/cache/download",
   178  		"${{ steps.\(goCacheDirID).outputs.dir }}",
   179  	], #additionalCacheDirs])
   180  
   181  	let cacheRestoreKeys = "\(#os)-\(#goVersion)"
   182  
   183  	let cacheStep = githubactions.#Step & {
   184  		with: {
   185  			path: strings.Join(cacheDirs, "\n")
   186  
   187  			// GitHub actions caches are immutable. Therefore, use a key which is
   188  			// unique, but allow the restore to fallback to the most recent cache.
   189  			// The result is then saved under the new key which will benefit the
   190  			// next build. Restore keys are only set if the step is restore.
   191  			key:            "\(cacheRestoreKeys)-${{ github.run_id }}"
   192  			"restore-keys": cacheRestoreKeys
   193  		}
   194  	}
   195  
   196  	let readWriteCacheExpr = "(\(isProtectedBranch) || \(isTestDefaultBranch))"
   197  
   198  	// pre is the list of steps required to establish and initialise the correct
   199  	// caches for Go-based workflows.
   200  	[
   201  		// TODO: once https://github.com/actions/setup-go/issues/54 is fixed,
   202  		// we could use `go env` outputs from the setup-go step.
   203  		githubactions.#Step & {
   204  			name: "Get go mod cache directory"
   205  			id:   goModCacheDirID
   206  			run:  #"echo "dir=$(go env GOMODCACHE)" >> ${GITHUB_OUTPUT}"#
   207  		},
   208  		githubactions.#Step & {
   209  			name: "Get go build/test cache directory"
   210  			id:   goCacheDirID
   211  			run:  #"echo "dir=$(go env GOCACHE)" >> ${GITHUB_OUTPUT}"#
   212  		},
   213  
   214  		// Only if we are not running in readonly mode do we want a step that
   215  		// uses actions/cache (read and write). Even then, the use of the write
   216  		// step should be predicated on us running on a protected branch. Because
   217  		// it's impossible for anything else to write such a cache.
   218  		if !#readonly {
   219  			cacheStep & {
   220  				if:   readWriteCacheExpr
   221  				uses: "actions/cache@v4"
   222  			}
   223  		},
   224  
   225  		cacheStep & {
   226  			// If we are readonly, there is no condition on when we run this step.
   227  			// It should always be run, becase there is no alternative. But if we
   228  			// are not readonly, then we need to predicate this step on us not
   229  			// being on a protected branch.
   230  			if !#readonly {
   231  				if: "! \(readWriteCacheExpr)"
   232  			}
   233  
   234  			uses: "actions/cache/restore@v4"
   235  		},
   236  
   237  		if #cleanTestCache {
   238  			// All tests on protected branches should skip the test cache.  The
   239  			// canonical way to do this is with -count=1. However, we want the
   240  			// resulting test cache to be valid and current so that subsequent CLs
   241  			// in the trybot repo can leverage the updated cache. Therefore, we
   242  			// instead perform a clean of the testcache.
   243  			//
   244  			// Critically we only want to do this in the main repo, not the trybot
   245  			// repo.
   246  			githubactions.#Step & {
   247  				if:  "github.repository == '\(githubRepositoryPath)' && (\(isProtectedBranch) || github.ref == 'refs/heads/\(testDefaultBranch)')"
   248  				run: "go clean -testcache"
   249  			}
   250  		},
   251  	]
   252  }
   253  
   254  // isProtectedBranch is an expression that evaluates to true if the
   255  // job is running as a result of pushing to one of protectedBranchPatterns.
   256  // It would be nice to use the "contains" builtin for simplicity,
   257  // but array literals are not yet supported in expressions.
   258  isProtectedBranch: {
   259  	#trailers: [...string]
   260  	"((" + strings.Join([for branch in protectedBranchPatterns {
   261  		(_matchPattern & {variable: "github.ref", pattern: "refs/heads/\(branch)"}).expr
   262  	}], " || ") + ") && (! \(containsDispatchTrailer)))"
   263  }
   264  
   265  // isTestDefaultBranch is an expression that evaluates to true if
   266  // the job is running on the testDefaultBranch
   267  isTestDefaultBranch: "(github.ref == 'refs/heads/\(testDefaultBranch)')"
   268  
   269  // #isReleaseTag creates a GitHub expression, based on the given release tag
   270  // pattern, that evaluates to true if called in the context of a workflow that
   271  // is part of a release.
   272  isReleaseTag: {
   273  	(_matchPattern & {variable: "github.ref", pattern: "refs/tags/\(releaseTagPattern)"}).expr
   274  }
   275  
   276  checkGitClean: githubactions.#Step & {
   277  	name: "Check that git is clean at the end of the job"
   278  	if:   "always()"
   279  	run:  "test -z \"$(git status --porcelain)\" || (git status; git diff; false)"
   280  }
   281  
   282  repositoryDispatch: githubactions.#Step & {
   283  	#githubRepositoryPath:         *githubRepositoryPath | string
   284  	#botGitHubUserTokenSecretsKey: *botGitHubUserTokenSecretsKey | string
   285  	#arg:                          _
   286  
   287  	_curlGitHubAPI: curlGitHubAPI & {#tokenSecretsKey: #botGitHubUserTokenSecretsKey, _}
   288  
   289  	name: string
   290  	run:  #"""
   291  			\#(_curlGitHubAPI) --fail --request POST --data-binary \#(strconv.Quote(json.Marshal(#arg))) https://api.github.com/repos/\#(#githubRepositoryPath)/dispatches
   292  			"""#
   293  }
   294  
   295  workflowDispatch: githubactions.#Step & {
   296  	#githubRepositoryPath:         *githubRepositoryPath | string
   297  	#botGitHubUserTokenSecretsKey: *botGitHubUserTokenSecretsKey | string
   298  	#workflowID:                   string
   299  
   300  	// params are defined per https://docs.github.com/en/rest/actions/workflows?apiVersion=2022-11-28#create-a-workflow-dispatch-event
   301  	#params: *{
   302  		ref: defaultBranch
   303  	} | _
   304  
   305  	_curlGitHubAPI: curlGitHubAPI & {#tokenSecretsKey: #botGitHubUserTokenSecretsKey, _}
   306  
   307  	name: string
   308  	run:  #"""
   309  			\#(_curlGitHubAPI) --fail --request POST --data-binary \#(strconv.Quote(json.Marshal(#params))) https://api.github.com/repos/\#(#githubRepositoryPath)/actions/workflows/\#(#workflowID)/dispatches
   310  			"""#
   311  }
   312  
   313  // dispatchTrailer is the trailer that we use to pass information in a commit
   314  // when triggering workflow events in other GitHub repos.
   315  //
   316  // NOTE: keep this consistent with gerritstatusupdater parsing logic.
   317  dispatchTrailer: "Dispatch-Trailer"
   318  
   319  // dispatchTrailerStepID is the ID of the step that attempts
   320  // to extract a Dispatch-Trailer value from the commit at HEAD
   321  dispatchTrailerStepID: strings.Replace(dispatchTrailer, "-", "", -1)
   322  
   323  // _dispatchTrailerDecodeStepOutputVar is the name of the output
   324  // variable int he dispatchTrailerStepID step
   325  _dispatchTrailerDecodeStepOutputVar: "value"
   326  
   327  // dispatchTrailerExpr is a GitHub expression that can be dereferenced
   328  // to get values from the JSON-decded Dispatch-Trailer value that
   329  // is extracted during the dispatchTrailerStepID step.
   330  dispatchTrailerExpr: "fromJSON(steps.\(dispatchTrailerStepID).outputs.\(_dispatchTrailerDecodeStepOutputVar))"
   331  
   332  // containsDispatchTrailer returns a GitHub expression that looks at the commit
   333  // message of the head commit of the event that triggered the workflow, an
   334  // expression that returns true if the commit message associated with that head
   335  // commit contains dispatchTrailer.
   336  //
   337  // Note that this logic does not 100% match the answer that would be returned by:
   338  //
   339  //      git log --pretty=%(trailers:key=Dispatch-Trailer,valueonly)
   340  //
   341  // GitHub expressions are incredibly limited in their capabilities:
   342  //
   343  //     https://docs.github.com/en/actions/learn-github-actions/expressions
   344  //
   345  // There is not even a regular expression matcher. Hence the logic is a best-efforts
   346  // approximation of the logic employed by git log.
   347  containsDispatchTrailer: {
   348  	#type?: string
   349  
   350  	// If we have a value for #type, then match against that value.
   351  	// Otherwise the best we can do is match against:
   352  	//
   353  	//     Dispatch-Trailer: {"type:}
   354  	//
   355  	let _typeCheck = [if #type != _|_ {#type + "\""}, ""][0]
   356  	"""
   357  	(contains(\(_dispatchTrailerVariable), '\n\(dispatchTrailer): {"type":"\(_typeCheck)'))
   358  	"""
   359  }
   360  
   361  containsTrybotTrailer: containsDispatchTrailer & {
   362  	#type: trybot.key
   363  	_
   364  }
   365  
   366  containsUnityTrailer: containsDispatchTrailer & {
   367  	#type: unity.key
   368  	_
   369  }
   370  
   371  _dispatchTrailerVariable: "github.event.head_commit.message"
   372  
   373  loginCentralRegistry: githubactions.#Step & {
   374  	#cueCommand:      *cueCommand | string
   375  	#tokenExpression: *"${{ secrets.\(unprivilegedBotGitHubUserCentralRegistryTokenSecretsKey) }}" | string
   376  	run:              "\(#cueCommand) login --token=\(#tokenExpression)"
   377  }