-
Notifications
You must be signed in to change notification settings - Fork 67k
Expand file tree
/
Copy pathactions-workflows.ts
More file actions
142 lines (126 loc) · 4.61 KB
/
actions-workflows.ts
File metadata and controls
142 lines (126 loc) · 4.61 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
import { fileURLToPath } from 'url'
import path from 'path'
import fs from 'fs'
import { describe, expect, test } from 'vitest'
import yaml from 'js-yaml'
import { flatten } from 'flat'
import { chain, get } from 'lodash-es'
const githubOwnedActionsRegex =
/^(actions\/(cache|checkout|download-artifact|upload-artifact)@v\d+(\.\d+)*)$/
const actionHashRegexp = /^[A-Za-z0-9-/]+@[0-9a-f]{40}$/
const checkoutRegexp = /^[actions/checkout]+@(v\d+(\.\d+)*|[0-9a-f]{40})$/
const permissionsRegexp = /(read|write)/
type WorkflowMeta = {
filename: string
fullpath: string
data: {
name: string
on: Record<string, any>
permissions: Record<string, any>
jobs: Record<string, any>
}
}
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const workflowsDir = path.join(__dirname, '../../../.github/workflows')
const workflows: WorkflowMeta[] = fs
.readdirSync(workflowsDir)
.filter((filename) => filename.endsWith('.yml') || filename.endsWith('.yaml'))
.filter((filename) => filename !== 'moda-ci.yaml') // Skip moda-ci
.map((filename) => {
const fullpath = path.join(workflowsDir, filename)
const data = yaml.load(fs.readFileSync(fullpath, 'utf8')) as WorkflowMeta['data']
return { filename, fullpath, data }
})
function actionsUsedInWorkflow(workflow: WorkflowMeta) {
return Object.keys(flatten(workflow))
.filter((key) => key.endsWith('.uses'))
.map((key) => get(workflow, key))
}
const allUsedActions = chain(workflows)
.map(actionsUsedInWorkflow)
.flatten()
.uniq()
.filter((use) => !use.startsWith('.'))
.sort()
.value()
const scheduledWorkflows = workflows.filter(({ data }) => data.on.schedule)
const alertWorkflows = workflows
// Only include jobs running on docs-internal
.filter(({ data }) =>
Object.values(data.jobs)
.map((job) => job.if)
.toString()
.includes('docs-internal'),
)
// Require slack alerts on workflows that aren't actively watched at time of run
.filter(({ data }) => data.on.schedule || data.on.push || data.on.issues || data.on.issue_comment)
// Not including
// - premerge workflows: pull_request, pull_request_target, pull_request_review, merge_group
// - adhoc workflows: workflow_dispatch, workflow_run, workflow_call, repository_dispatch
// to generate list, console.log(new Set(workflows.map(({ data }) => Object.keys(data.on)).flat()))
const dailyWorkflows = scheduledWorkflows.filter(({ data }) =>
data.on.schedule.find(({ cron }: { cron: string }) => /^20 [^*]/.test(cron)),
)
describe('GitHub Actions workflows', () => {
test.each(allUsedActions)('requires specific hash: %p', (actionName) => {
const matchesGitHubOwnedActions = githubOwnedActionsRegex.test(actionName)
const matchesActionHash = actionHashRegexp.test(actionName)
expect(matchesGitHubOwnedActions || matchesActionHash).toBe(true)
})
test.each(scheduledWorkflows)(
'schedule workflow runs at 20 minutes past $filename',
({ data }) => {
for (const { cron } of data.on.schedule) {
expect(cron).toMatch(/^20/)
}
},
)
test.each(dailyWorkflows)(
'daily scheduled workflows run at 16:20 UTC / 8:20 PST $filename',
({ data }) => {
for (const { cron } of data.on.schedule) {
const hour = cron.match(/^20 ([^*\s]+)/)[1]
expect(hour).toEqual('16')
}
},
)
test.each(workflows)(
'contains contents:read permissions when permissions are used $filename',
({ data }) => {
if (data.permissions) {
expect(permissionsRegexp.test(data.permissions.contents)).toBe(true)
}
},
)
test.each(workflows)('limits repository scope $filename', ({ data }) => {
for (const condition of Object.values(data.jobs).map((job) => job.if)) {
expect(condition).toContain('github.repository')
}
})
test.each(alertWorkflows)(
'scheduled workflows slack alert on fail $filename',
({ filename, data }) => {
for (const [name, job] of Object.entries(data.jobs)) {
if (
!job.steps.find(
(step: Record<string, any>) => step.uses === './.github/actions/slack-alert',
)
) {
throw new Error(`Job ${filename} # ${name} missing slack alert on fail`)
}
}
},
)
test.each(alertWorkflows)(
'performs a checkout before calling composite action $filename',
({ filename, data }) => {
for (const [name, job] of Object.entries(data.jobs)) {
if (!job.steps.find((step: Record<string, any>) => checkoutRegexp.test(step.uses))) {
throw new Error(
`Job ${filename} # ${name} missing a checkout before calling the composite action`,
)
}
}
},
)
})