feat(action): add retries_output_action and retries_delay_output_action (#736)

This commit is contained in:
Simon Lecoq
2022-01-02 16:10:03 +01:00
committed by GitHub
parent b85f2f3178
commit edb67f4168
3 changed files with 165 additions and 115 deletions

View File

@@ -42,6 +42,28 @@ async function wait(seconds) {
await new Promise(solve => setTimeout(solve, seconds * 1000)) await new Promise(solve => setTimeout(solve, seconds * 1000))
} }
//Retry wrapper
async function retry(func, {retries = 1, delay = 0} = {}) {
let error = null
for (let attempt = 1; attempt <= retries; attempt++) {
try {
console.debug(`::group::Attempt ${attempt}/${retries}`)
const result = await func()
console.debug("::endgroup::")
return result
}
catch (_error) {
error = _error
console.debug("::endgroup::")
console.debug(`::warning::${error.message}`)
await wait(delay)
}
}
if (error)
throw error
return null
}
//Runner //Runner
(async function() { (async function() {
try { try {
@@ -92,6 +114,8 @@ async function wait(seconds) {
"use.prebuilt.image":_image, "use.prebuilt.image":_image,
retries, retries,
"retries.delay":retries_delay, "retries.delay":retries_delay,
"retries.output.action":retries_output_action,
"retries.delay.output.action":retries_delay_output_action,
"output.action":_action, "output.action":_action,
"output.condition":_output_condition, "output.condition":_output_condition,
delay, delay,
@@ -303,23 +327,12 @@ async function wait(seconds) {
//Render metrics //Render metrics
info.break() info.break()
info.section("Rendering") info.section("Rendering")
let error = null, rendered = null let rendered = await retry(async () => {
for (let attempt = 1; attempt <= retries; attempt++) { const {rendered} = await metrics({login:user, q}, {graphql, rest, plugins, conf, die, verify, convert}, {Plugins, Templates})
try { return rendered
console.debug(`::group::Attempt ${attempt}/${retries}`) }, {retries, delay:retries_delay})
;({rendered} = await metrics({login:user, q}, {graphql, rest, plugins, conf, die, verify, convert}, {Plugins, Templates}))
console.debug("::endgroup::")
break
}
catch (_error) {
error = _error
console.debug("::endgroup::")
console.debug(`::warning::rendering failed (${error.message})`)
await wait(retries_delay)
}
}
if (!rendered) if (!rendered)
throw error ?? new Error("Could not render metrics") throw new Error("Could not render metrics")
info("Status", "complete") info("Status", "complete")
//Output condition //Output condition
@@ -329,13 +342,15 @@ async function wait(seconds) {
if ((_output_condition === "data-changed")&&((committer.commit) || (committer.pr))) { if ((_output_condition === "data-changed")&&((committer.commit) || (committer.pr))) {
const {svg} = await import("../metrics/utils.mjs") const {svg} = await import("../metrics/utils.mjs")
let data = "" let data = ""
try { await retry(async () => {
data = `${Buffer.from((await committer.rest.repos.getContent({...github.context.repo, ref:`heads/${committer.head}`, path:filename})).data.content, "base64")}` try {
} data = `${Buffer.from((await committer.rest.repos.getContent({...github.context.repo, ref:`heads/${committer.head}`, path:filename})).data.content, "base64")}`
catch (error) { }
if (error.response.status !== 404) catch (error) {
throw error if (error.response.status !== 404)
} throw error
}
}, {retries:retries_output_action, delay:retries_delay_output_action})
const previous = await svg.hash(data) const previous = await svg.hash(data)
info("Previous hash", previous) info("Previous hash", previous)
const current = await svg.hash(rendered) const current = await svg.hash(rendered)
@@ -363,43 +378,45 @@ async function wait(seconds) {
process.exit(0) process.exit(0)
} }
//Cache //Cache embed svg for markdown outputs
if (/markdown/.test(convert)) { if (/markdown/.test(convert)) {
const regex = /(?<match><img class="metrics-cachable" data-name="(?<name>[\s\S]+?)" src="data:image[/](?<format>(?:svg[+]xml)|jpeg|png);base64,(?<content>[/+=\w]+?)">)/ const regex = /(?<match><img class="metrics-cachable" data-name="(?<name>[\s\S]+?)" src="data:image[/](?<format>(?:svg[+]xml)|jpeg|png);base64,(?<content>[/+=\w]+?)">)/
let matched = null let matched = null
while (matched = regex.exec(rendered)?.groups) { //eslint-disable-line no-cond-assign while (matched = regex.exec(rendered)?.groups) { //eslint-disable-line no-cond-assign
const {match, name, format, content} = matched await retry(async () => {
let path = `${_markdown_cache}/${name}.${format.replace(/[+].*$/g, "")}` const {match, name, format, content} = matched
console.debug(`Processing ${path}`) let path = `${_markdown_cache}/${name}.${format.replace(/[+].*$/g, "")}`
let sha = null console.debug(`Processing ${path}`)
try { let sha = null
const {repository:{object:{oid}}} = await graphql( try {
` const {repository:{object:{oid}}} = await graphql(
query Sha { `
repository(owner: "${github.context.repo.owner}", name: "${github.context.repo.repo}") { query Sha {
object(expression: "${committer.head}:${path}") { ... on Blob { oid } } repository(owner: "${github.context.repo.owner}", name: "${github.context.repo.repo}") {
object(expression: "${committer.head}:${path}") { ... on Blob { oid } }
}
} }
} `,
`, {headers:{authorization:`token ${committer.token}`}},
{headers:{authorization:`token ${committer.token}`}}, )
) sha = oid
sha = oid }
} catch (error) {
catch (error) { console.debug(error)
console.debug(error) }
} finally {
finally { await committer.rest.repos.createOrUpdateFileContents({
await committer.rest.repos.createOrUpdateFileContents({ ...github.context.repo,
...github.context.repo, path,
path, content,
content, message:`${committer.message} (cache)`,
message:`${committer.message} (cache)`, ...(sha ? {sha} : {}),
...(sha ? {sha} : {}), branch:committer.pr ? committer.head : committer.branch,
branch:committer.pr ? committer.head : committer.branch, })
}) rendered = rendered.replace(match, `<img src="https://github.com/${github.context.repo.owner}/${github.context.repo.repo}/blob/${committer.branch}/${path}">`)
rendered = rendered.replace(match, `<img src="https://github.com/${github.context.repo.owner}/${github.context.repo.repo}/blob/${committer.branch}/${path}">`) info(`Saving ${path}`, "ok")
info(`Saving ${path}`, "ok") }
} }, {retries:retries_output_action, delay:retries_delay_output_action})
} }
} }
@@ -416,84 +433,96 @@ async function wait(seconds) {
//Upload to gist (this is done as user since committer_token may not have gist rights) //Upload to gist (this is done as user since committer_token may not have gist rights)
if (committer.gist) { if (committer.gist) {
await rest.gists.update({gist_id:committer.gist, files:{[filename]:{content:rendered}}}) await retry(async () => {
info(`Upload to gist ${committer.gist}`, "ok") await rest.gists.update({gist_id:committer.gist, files:{[filename]:{content:rendered}}})
committer.commit = false info(`Upload to gist ${committer.gist}`, "ok")
committer.commit = false
}, {retries:retries_output_action, delay:retries_delay_output_action})
} }
//Commit metrics //Commit metrics
if (committer.commit) { if (committer.commit) {
await committer.rest.repos.createOrUpdateFileContents({ await retry(async () => {
...github.context.repo, await committer.rest.repos.createOrUpdateFileContents({
path:filename, ...github.context.repo,
message:committer.message, path:filename,
content:Buffer.from(typeof rendered === "object" ? JSON.stringify(rendered) : `${rendered}`).toString("base64"), message:committer.message,
branch:committer.pr ? committer.head : committer.branch, content:Buffer.from(typeof rendered === "object" ? JSON.stringify(rendered) : `${rendered}`).toString("base64"),
...(committer.sha ? {sha:committer.sha} : {}), branch:committer.pr ? committer.head : committer.branch,
}) ...(committer.sha ? {sha:committer.sha} : {}),
info(`Commit to branch ${committer.branch}`, "ok") })
info(`Commit to branch ${committer.branch}`, "ok")
}, {retries:retries_output_action, delay:retries_delay_output_action})
} }
//Pull request //Pull request
if (committer.pr) { if (committer.pr) {
//Create pull request //Create pull request
let number = null let number = null
try { await retry(async () => {
({data:{number}} = await committer.rest.pulls.create({...github.context.repo, head:committer.head, base:committer.branch, title:`Auto-generated metrics for run #${github.context.runId}`, body:" ", maintainer_can_modify:true})) try {
info(`Pull request from ${committer.head} to ${committer.branch}`, "(created)") ({data:{number}} = await committer.rest.pulls.create({...github.context.repo, head:committer.head, base:committer.branch, title:`Auto-generated metrics for run #${github.context.runId}`, body:" ", maintainer_can_modify:true}))
} info(`Pull request from ${committer.head} to ${committer.branch}`, "(created)")
catch (error) {
console.debug(error)
//Check if pull request has already been created previously
if (/A pull request already exists/.test(error)) {
info(`Pull request from ${committer.head} to ${committer.branch}`, "(already existing)")
const q = `repo:${github.context.repo.owner}/${github.context.repo.repo}+type:pr+state:open+Auto-generated metrics for run #${github.context.runId}+in:title`
const prs = (await committer.rest.search.issuesAndPullRequests({q})).data.items.filter(({user:{login}}) => login === "github-actions[bot]")
if (prs.length < 1)
throw new Error("0 matching prs. Cannot proceed.")
if (prs.length > 1)
throw new Error(`Found more than one matching prs: ${prs.map(({number}) => `#${number}`).join(", ")}. Cannot proceed.`)
;({number} = prs.shift())
} }
//Check if pull request could not been created because there are no diff between head and base catch (error) {
else if (/No commits between/.test(error)) { console.debug(error)
info(`Pull request from ${committer.head} to ${committer.branch}`, "(no diff)") //Check if pull request has already been created previously
committer.merge = false if (/A pull request already exists/.test(error)) {
number = "(none)" info(`Pull request from ${committer.head} to ${committer.branch}`, "(already existing)")
const q = `repo:${github.context.repo.owner}/${github.context.repo.repo}+type:pr+state:open+Auto-generated metrics for run #${github.context.runId}+in:title`
const prs = (await committer.rest.search.issuesAndPullRequests({q})).data.items.filter(({user:{login}}) => login === "github-actions[bot]")
if (prs.length < 1)
throw new Error("0 matching prs. Cannot proceed.")
if (prs.length > 1)
throw new Error(`Found more than one matching prs: ${prs.map(({number}) => `#${number}`).join(", ")}. Cannot proceed.`)
;({number} = prs.shift())
}
//Check if pull request could not been created because there are no diff between head and base
else if (/No commits between/.test(error)) {
info(`Pull request from ${committer.head} to ${committer.branch}`, "(no diff)")
committer.merge = false
number = "(none)"
}
else
throw error
} }
else info("Pull request number", number)
throw error }, {retries:retries_output_action, delay:retries_delay_output_action})
}
info("Pull request number", number)
//Merge pull request //Merge pull request
if (committer.merge) { if (committer.merge) {
info("Merge method", committer.merge) info("Merge method", committer.merge)
let attempts = 240 let attempts = 240
do { do {
//Check pull request mergeability (https://octokit.github.io/rest.js/v18#pulls-get) const success = await retry(async () => {
const {data:{mergeable, mergeable_state:state}} = await committer.rest.pulls.get({...github.context.repo, pull_number:number}) //Check pull request mergeability (https://octokit.github.io/rest.js/v18#pulls-get)
console.debug(`Pull request #${number} mergeable state is "${state}"`) const {data:{mergeable, mergeable_state:state}} = await committer.rest.pulls.get({...github.context.repo, pull_number:number})
if (mergeable === null) { console.debug(`Pull request #${number} mergeable state is "${state}"`)
await wait(15) if (mergeable === null) {
await wait(15)
return false
}
if (!mergeable)
throw new Error(`Pull request #${number} is not mergeable (state is "${state}")`)
//Merge pull request
await committer.rest.pulls.merge({...github.context.repo, pull_number:number, merge_method:committer.merge})
info(`Merge #${number} to ${committer.branch}`, "ok")
return true
}, {retries:retries_output_action, delay:retries_delay_output_action})
if (!success)
continue continue
}
if (!mergeable)
throw new Error(`Pull request #${number} is not mergeable (state is "${state}")`)
//Merge pull request
await committer.rest.pulls.merge({...github.context.repo, pull_number:number, merge_method:committer.merge})
info(`Merge #${number} to ${committer.branch}`, "ok")
//Delete head branch //Delete head branch
try { await retry(async () => {
await wait(15) try {
await committer.rest.git.deleteRef({...github.context.repo, ref:`heads/${committer.head}`}) await wait(15)
} await committer.rest.git.deleteRef({...github.context.repo, ref:`heads/${committer.head}`})
catch (error) { }
console.debug(error) catch (error) {
if (!/reference does not exist/i.test(`${error}`)) console.debug(error)
throw error if (!/reference does not exist/i.test(`${error}`))
} throw error
info(`Branch ${committer.head}`, "(deleted)") }
info(`Branch ${committer.head}`, "(deleted)")
}, {retries:retries_output_action, delay:retries_delay_output_action})
break break
} while (--attempts) } while (--attempts)
} }

View File

@@ -228,11 +228,14 @@ It also possible to alter output condition using `output_condition` option, whic
output_action: pull-request-merge output_action: pull-request-merge
``` ```
### ♻️ Retrying automatically failed rendering ### ♻️ Retrying automatically failed rendering and output action
Rendering is subject to external factors and can fail from time to time. Rendering is subject to external factors and can fail from time to time.
It is possible to mitigate this issue using `retries` and `retries_delay` options to automatically retry later metrics rendering and avoid workflow fails. It is possible to mitigate this issue using `retries` and `retries_delay` options to automatically retry later metrics rendering and avoid workflow fails.
Output action is also subject to GitHub API rate-limiting and status and can fail from time to time.
It is possible to mitigate this issue using `retries_output_action` and `retries_delay_output_action` options to automatically retry later metrics output action and avoid workflow fails. As this is a separate step from rendering, metrics rendering won't be computed again during this phase.
#### Examples workflows #### Examples workflows
```yaml ```yaml
@@ -241,6 +244,8 @@ It is possible to mitigate this issue using `retries` and `retries_delay` option
# ... other options # ... other options
retries: 3 retries: 3
retries_delay: 300 retries_delay: 300
retries_output_action: 5
retries_delay_output_action: 120
``` ```
### 💱 Convert output to PNG/JPEG or JSON ### 💱 Convert output to PNG/JPEG or JSON

View File

@@ -244,6 +244,22 @@ inputs:
min: 0 min: 0
max: 3600 max: 3600
# Number of retries in case output action fail
retries_output_action:
description: Number of retries (output action)
type: number
default: 5
min: 1
max: 10
# Time to wait (in seconds) before each retry (output action)
retries_delay_output_action:
description: Time to wait (in seconds) before each retry (output action)
type: number
default: 120
min: 0
max: 3600
# ==================================================================================== # ====================================================================================
# 🚧 Options below are mostly used for testing # 🚧 Options below are mostly used for testing