-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathaction.yml
More file actions
158 lines (143 loc) · 6.85 KB
/
action.yml
File metadata and controls
158 lines (143 loc) · 6.85 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
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
name: Pipelines Credentials
description: Fetch Pipelines Credentials
inputs:
PIPELINES_TOKEN_PATH:
required: true
FALLBACK_TOKEN:
required: true
api_base_url:
default: "https://api.prod.app.gruntwork.io/api/v1"
outputs:
PIPELINES_TOKEN:
value: ${{ steps.get_token.outputs.PIPELINES_TOKEN }}
runs:
using: composite
steps:
- name: Fetch Pipelines Token
id: get_token
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
FALLBACK_TOKEN: ${{ inputs.FALLBACK_TOKEN }}
PIPELINES_TOKEN_PATH: ${{ inputs.PIPELINES_TOKEN_PATH }}
API_BASE_URL: ${{ inputs.api_base_url }}
with:
script: |
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
const isRetryableError = (error) => {
const msg = String(error)
// Network / transient errors
if (/TypeError|ECONN|EPIPE|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|ECONNABORTED|socket disconnected|Request timeout|AbortError|TimeoutError|TLS/.test(msg)) return true
// HTTP 5xx or 429 (rate limit)
const statusMatch = msg.match(/HTTP (\d{3})/) || msg.match(/: (\d{3})$/)
if (statusMatch) {
const status = parseInt(statusMatch[1])
return status >= 500 || status === 429
}
return false
}
const formatError = (error) => {
let msg = `${error.name}: ${error.message}`
if (error.cause) {
msg += `\n Cause: ${error.cause}`
if (error.cause.code) msg += ` (code: ${error.cause.code})`
if (error.cause.syscall) msg += ` (syscall: ${error.cause.syscall})`
if (error.cause.hostname) msg += ` (host: ${error.cause.hostname})`
}
if (error.code) msg += `\n Code: ${error.code}`
return msg
}
const withRetry = async (fn, stepName, retries = 5) => {
for (let i = 1; i <= retries; i++) {
try {
return await fn()
} catch (error) {
const isRetryable = isRetryableError(error)
console.log(`[${stepName}] Attempt ${i}/${retries} failed (retryable: ${isRetryable})`)
console.log(` ${formatError(error)}`)
if (i < retries && isRetryable) {
// Exponential backoff: 2^i seconds (2s, 4s, 8s, 16s) + jitter
const backoff = Math.pow(2, i) * 1000 + Math.random() * 1000
console.log(` Retrying in ${Math.round(backoff / 1000)}s...`)
await sleep(backoff)
} else {
throw error
}
}
}
}
const handleLimitExceeded = async (body) => {
const limit = body?.detail?.limits?.[0]?.limit
const used = body?.detail?.limits?.[0]?.used
const excess = used - limit
const devPortalUrl = 'https://app.gruntwork.io'
const salesEmail = 'sales@gruntwork.io'
const message = `You're now using **${used} of ${limit}** infrastructure units included in your plan—exceeding the limit by **${excess} units**. Effective immediately, **your pipelines have been paused** for all of your repositories.\n\nTo continue using Pipelines, [contact sales](mailto:${salesEmail}) to upgrade your plan or request a one-time restoration of service during which you may reduce your usage. You can view your current usage in the [Gruntwork Developer Portal](${devPortalUrl}).`
await core.summary
.addHeading('Your Pipelines have been paused')
.addEOL()
.addRaw(message)
.write({ overwrite: true })
if (context.payload.pull_request) {
try {
const marker = '<!-- pipelines-limit-exceeded -->'
const commentBody = `${marker}\n## Your Gruntwork Pipelines have been paused\n\n${message}`
const { owner, repo } = context.repo
const issue_number = context.payload.pull_request.number
const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number })
const existing = comments.find(c => c.body.startsWith(marker))
if (existing) {
const { data } = await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: commentBody })
console.log(`Updated comment: ${data.html_url}`)
} else {
const { data } = await github.rest.issues.createComment({ owner, repo, issue_number, body: commentBody })
console.log(`Created comment: ${data.html_url}`)
}
} catch (e) {
console.log(`Failed to post PR comment: ${e.message}`)
}
}
}
try {
const apiBaseURL = process.env.API_BASE_URL
const idToken = await withRetry(
() => core.getIDToken("https://api.prod.app.gruntwork.io"),
"getIDToken"
)
const providerToken = await withRetry(async () => {
const res = await fetch(`${apiBaseURL}/tokens/auth/login`, {
method: "POST",
headers: { "Authorization": `Bearer ${idToken}` }
})
if (!res.ok) {
if (res.status === 403) {
try {
const body = await res.json()
if (body.error === "LIMIT_EXCEEDED") {
await handleLimitExceeded(body)
}
} catch (_) {}
}
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
}
return (await res.json()).token
}, "login")
const token = await withRetry(async () => {
const res = await fetch(`${apiBaseURL}/tokens/pat/${process.env.PIPELINES_TOKEN_PATH}`, {
headers: { "Authorization": `Bearer ${providerToken}` }
})
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`)
return (await res.json()).token
}, "fetchToken")
console.log("Setting PIPELINES_TOKEN to GitHubApp token")
core.setOutput('PIPELINES_TOKEN', token)
return
} catch (error) {
console.log(`Failed to get pipelines token after all retries:`)
console.log(` ${formatError(error)}`)
}
if (!process.env.FALLBACK_TOKEN) {
core.setFailed("Unable to fetch credentials via Gruntwork.io GitHub App and no FALLBACK_TOKEN provided.")
return
}
console.log("Setting PIPELINES_TOKEN to fallback token")
core.setOutput('PIPELINES_TOKEN', process.env.FALLBACK_TOKEN.trim())