From cd8ed4b8a8b56d64156b759f55d5c13f94a611dc Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 15 Apr 2026 13:35:44 +0700 Subject: [PATCH 1/2] Handle push race in matrix jobs with configurable retries --- README.md | 14 +++++ action.yml | 8 +++ dist/blog-post-workflow.js | 102 +++++++++++++++++++++++++++++++++---- src/blog-post-workflow.js | 54 ++++++++++++++++---- src/utils.js | 22 +++++++- 5 files changed, 180 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 1d3ec5a..e5cb22f 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,8 @@ This workflow has additional options that you can use to customize it for your u | `enable_keepalive` | `true` | Workflow will automatically do a dummy commit to keep the repository active if there is no commit activity for the last 50 days. GitHub will stop running all cron based triggers if the repository is not active for more than 60 days. This flag allows you to disable this feature. See [#53](https://git.io/Jtm4V) for more details. | No | | `retry_count` | `0` | Maximum number of times to retry the fetch operation if it fails, See [#66](https://github.com/ePlus-DEV/blog-post-workflow@v1/issues/66) for more details. | No | | `retry_wait_time` | `1` | Time to wait before each retry operation in seconds. | No | +| `request_timeout` | `30` | Timeout (seconds) for each feed HTTP request. Lower this value to fail fast on slow/unresponsive feeds and reduce total workflow duration. | No | +| `push_retry_count` | `3` | Number of retries for `git push` when another workflow run has pushed first (non-fast-forward updates). | No | | `disable_html_encoding` | `false` | Disables html encoding of the feed contents. | No | | `disable_item_validation` | `false` | Disables the validation checks for Title, publish date and URL. | No | | `filter_dates` | `""` | Allows you to filter post items based on date range.

**Supported Values** Make sure that you set the `max_post_count` to a higher value to get rid of max post count filtering, before using the above options. | No | @@ -91,6 +93,18 @@ This workflow has additional options that you can use to customize it for your u | `skip_commit` | `false` | Allows you to prevent the workflow from creating a new commit in the repository. Nevertheless of this option, changes to the readme will be made. This is particularly useful if you want to do the commits manually using the git cli or another workflow. One of the important thing to note is that the workflow will still do the keepalive commits, if you want to disable it you can use the `enable_keepalive` option instead. | No | | `dummy_commit_message` | `dummy commit to keep the repository active, see https://git.io/Jtm4V` | Dummy commit message, This is when the workflow is doing automated commits to keep the repository active | No | +### Performance tuning (important for matrix workflows) + +If your workflow is slow, the bottleneck is usually network waiting on RSS endpoints (not markdown rendering). + +- Set `request_timeout` to fail fast on slow feeds (`10`-`20` seconds is a good starting point). +- Keep `retry_count` low (`0`-`1`) unless your source is frequently unstable. +- Keep `push_retry_count` enabled to auto-recover when matrix jobs push to the same branch at nearly the same time. +- Avoid `max-parallel: 1` in matrix jobs unless you intentionally want strictly serial processing. +- In matrix strategy, set `fail-fast: false` if you want other matrix entries to continue even when one entry fails. + +> Example worst-case per feed: with `request_timeout: 30` and `retry_count: 5`, one feed can wait up to ~`210s` (3.5 minutes) before failing. + ## Advanced usage examples Click to expand: diff --git a/action.yml b/action.yml index 920bc06..21af02c 100644 --- a/action.yml +++ b/action.yml @@ -101,6 +101,14 @@ inputs: description: "Time to wait before each retry operation in seconds" default: "1" required: false + request_timeout: + description: "Timeout for each feed request in seconds" + default: "30" + required: false + push_retry_count: + description: "Retry count for git push when remote has new commits (non-fast-forward)" + default: "3" + required: false feed_names: description: "Comma separated name of the feeds to show on template" default: "" diff --git a/dist/blog-post-workflow.js b/dist/blog-post-workflow.js index 97f9594..309906d 100644 --- a/dist/blog-post-workflow.js +++ b/dist/blog-post-workflow.js @@ -13325,7 +13325,7 @@ var require_fetch = __commonJS({ function handleFetchDone(response) { finalizeAndReportTiming(response, "fetch"); } - function fetch(input, init = void 0) { + function fetch2(input, init = void 0) { webidl.argumentLengthCheck(arguments, 1, "globalThis.fetch"); let p = createDeferredPromise(); let requestObject; @@ -14282,7 +14282,7 @@ var require_fetch = __commonJS({ } } module.exports = { - fetch, + fetch: fetch2, Fetch, fetching, finalizeAndReportTiming @@ -18540,7 +18540,7 @@ var require_undici = __commonJS({ module.exports.setGlobalDispatcher = setGlobalDispatcher; module.exports.getGlobalDispatcher = getGlobalDispatcher; var fetchImpl = require_fetch().fetch; - module.exports.fetch = async function fetch(init, options = void 0) { + module.exports.fetch = async function fetch2(init, options = void 0) { try { return await fetchImpl(init, options); } catch (err) { @@ -19393,16 +19393,16 @@ var require_dist_node5 = __commonJS({ let headers = {}; let status; let url; - let { fetch } = globalThis; + let { fetch: fetch2 } = globalThis; if ((_b = requestOptions.request) == null ? void 0 : _b.fetch) { - fetch = requestOptions.request.fetch; + fetch2 = requestOptions.request.fetch; } - if (!fetch) { + if (!fetch2) { throw new Error( "fetch is not set. Please pass a fetch implementation as new Octokit({ request: { fetch }}). Learn more at https://github.com/octokit/octokit.js/#fetch-missing" ); } - return fetch(requestOptions.url, { + return fetch2(requestOptions.url, { method: requestOptions.method, body: requestOptions.body, redirect: (_c = requestOptions.request) == null ? void 0 : _c.redirect, @@ -30655,6 +30655,8 @@ var commitReadme = async (githubToken, readmeFilePaths) => { const committerUsername = getInput("committer_username"); const committerEmail = getInput("committer_email"); const commitMessage = getInput("commit_message"); + const pushRetryCount = Number.parseInt(getInput("push_retry_count"), 10) || 3; + const branchName = process.env.GITHUB_REF_NAME || process.env.GITHUB_HEAD_REF || "main"; await exec("git", ["config", "--global", "user.email", committerEmail]); if (githubToken) { await exec("git", [ @@ -30667,7 +30669,23 @@ var commitReadme = async (githubToken, readmeFilePaths) => { await exec("git", ["config", "--global", "user.name", committerUsername]); await exec("git", ["add", ...readmeFilePaths]); await exec("git", ["commit", "-m", commitMessage]); - await exec("git", ["push"]); + let pushTry = 0; + while (pushTry <= pushRetryCount) { + try { + await exec("git", ["push"]); + info("Readme updated successfully in the upstream repository"); + return; + } catch (err) { + pushTry = pushTry + 1; + if (pushTry > pushRetryCount) { + throw err; + } + warning( + `git push failed (attempt ${pushTry}/${pushRetryCount}). Pulling latest changes and retrying...` + ); + await exec("git", ["pull", "--rebase", "origin", branchName]); + } + } info("Readme updated successfully in the upstream repository"); }; var updateAndParseCompoundParams = (sourceWithParam, obj) => { @@ -30787,6 +30805,17 @@ var retryConfig = { factor: 1, minTimeout: Number.parseInt(getInput("retry_wait_time"), 10) * 1e3 }; +var REQUEST_TIMEOUT = Number.parseInt(getInput("request_timeout"), 10) || 30; +var RETRY_WAIT_SECONDS = retryConfig.minTimeout / 1e3; +info( + `Feed request config -> timeout: ${REQUEST_TIMEOUT}s, retries: ${retryConfig.retries}, retry_wait_time: ${RETRY_WAIT_SECONDS}s` +); +if (retryConfig.retries > 0) { + const worstCasePerFeedSeconds = REQUEST_TIMEOUT * (retryConfig.retries + 1) + RETRY_WAIT_SECONDS * retryConfig.retries; + info( + `Estimated worst-case wait per feed: ~${worstCasePerFeedSeconds}s before moving on.` + ); +} setSecret(GITHUB_TOKEN); for (let item of getInput("custom_tags").trim().split(",")) { item = item.trim(); @@ -30817,6 +30846,56 @@ var parser = new import_rss_parser.default({ item: [...customTagArgs] } }); +var PARSE_XML_ERROR_MESSAGE = "Unable to parse XML"; +var isXmlParseError = (err) => typeof err?.message === "string" && err.message.includes(PARSE_XML_ERROR_MESSAGE); +var shouldRetryFeedRequest = (err) => !isXmlParseError(err); +var sanitizeXml = (xmlText) => xmlText.replace(/^\uFEFF/, "").replace(/^[^<]*/, "").split("").filter((char) => { + const charCode = char.charCodeAt(0); + if (charCode <= 31 || charCode === 127) { + return charCode === 9 || charCode === 10 || charCode === 13; + } + return true; +}).join("").replace(/&(?!(?:amp|lt|gt|quot|apos|#\d+|#x[\dA-Fa-f]+);)/g, "&"); +var fetchFeedXml = async (siteUrl) => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT * 1e3); + try { + const response = await fetch(siteUrl, { + headers: { + "User-Agent": userAgent, + Accept: acceptHeader + }, + signal: controller.signal + }); + if (!response.ok) { + throw new Error(`Status code ${response.status}`); + } + return response.text(); + } catch (err) { + if (err.name === "AbortError") { + throw new Error( + `Request timeout after ${REQUEST_TIMEOUT}s while fetching ${siteUrl}` + ); + } + throw err; + } finally { + clearTimeout(timeout); + } +}; +var parseFeed = async (siteUrl) => { + const xmlText = await fetchFeedXml(siteUrl); + try { + return await parser.parseString(xmlText); + } catch (err) { + if (!isXmlParseError(err)) { + throw err; + } + warning( + `XML parse failed for ${siteUrl}. Retrying with sanitized XML fallback.` + ); + return parser.parseString(sanitizeXml(xmlText)); + } +}; for (const siteUrl of feedList) { runnerNameArray.push(siteUrl); promiseArray.push( @@ -30827,7 +30906,12 @@ for (const siteUrl of feedList) { `Previous try for ${siteUrl} failed, retrying: ${tryNumber - 1}` ); } - return parser.parseURL(siteUrl).catch(retry); + return parseFeed(siteUrl).catch((err) => { + if (shouldRetryFeedRequest(err)) { + retry(err); + } + throw err; + }); }, retryConfig).then( (data) => { if (!data.items) { diff --git a/src/blog-post-workflow.js b/src/blog-post-workflow.js index fc201bb..c863e13 100644 --- a/src/blog-post-workflow.js +++ b/src/blog-post-workflow.js @@ -77,6 +77,20 @@ const retryConfig = { factor: 1, minTimeout: Number.parseInt(core.getInput('retry_wait_time'), 10) * 1000, }; +const REQUEST_TIMEOUT = Number.parseInt(core.getInput('request_timeout'), 10) || 30; +const RETRY_WAIT_SECONDS = retryConfig.minTimeout / 1000; + +core.info( + `Feed request config -> timeout: ${REQUEST_TIMEOUT}s, retries: ${retryConfig.retries}, retry_wait_time: ${RETRY_WAIT_SECONDS}s`, +); +if (retryConfig.retries > 0) { + const worstCasePerFeedSeconds = + REQUEST_TIMEOUT * (retryConfig.retries + 1) + + RETRY_WAIT_SECONDS * retryConfig.retries; + core.info( + `Estimated worst-case wait per feed: ~${worstCasePerFeedSeconds}s before moving on.`, + ); +} core.setSecret(GITHUB_TOKEN); @@ -142,26 +156,46 @@ const sanitizeXml = (xmlText) => .join('') .replace(/&(?!(?:amp|lt|gt|quot|apos|#\d+|#x[\dA-Fa-f]+);)/g, '&'); -const parseFeed = async (siteUrl) => { +const fetchFeedXml = async (siteUrl) => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT * 1000); + try { - return await parser.parseURL(siteUrl); - } catch (err) { - if (!isXmlParseError(err)) { - throw err; - } - core.warning( - `XML parse failed for ${siteUrl}. Retrying with sanitized XML fallback.`, - ); const response = await fetch(siteUrl, { headers: { 'User-Agent': userAgent, Accept: acceptHeader, }, + signal: controller.signal, }); + if (!response.ok) { throw new Error(`Status code ${response.status}`); } - const xmlText = await response.text(); + return response.text(); + } catch (err) { + if (err.name === 'AbortError') { + throw new Error( + `Request timeout after ${REQUEST_TIMEOUT}s while fetching ${siteUrl}`, + ); + } + throw err; + } finally { + clearTimeout(timeout); + } +}; + +const parseFeed = async (siteUrl) => { + const xmlText = await fetchFeedXml(siteUrl); + try { + return await parser.parseString(xmlText); + } catch (err) { + if (!isXmlParseError(err)) { + throw err; + } + core.warning( + `XML parse failed for ${siteUrl}. Retrying with sanitized XML fallback.`, + ); return parser.parseString(sanitizeXml(xmlText)); } }; diff --git a/src/utils.js b/src/utils.js index 0cdb962..bba6941 100644 --- a/src/utils.js +++ b/src/utils.js @@ -103,6 +103,10 @@ const commitReadme = async (githubToken, readmeFilePaths) => { const committerUsername = core.getInput('committer_username'); const committerEmail = core.getInput('committer_email'); const commitMessage = core.getInput('commit_message'); + const pushRetryCount = + Number.parseInt(core.getInput('push_retry_count'), 10) || 3; + const branchName = + process.env.GITHUB_REF_NAME || process.env.GITHUB_HEAD_REF || 'main'; // Doing commit and push await exec('git', ['config', '--global', 'user.email', committerEmail]); if (githubToken) { @@ -117,7 +121,23 @@ const commitReadme = async (githubToken, readmeFilePaths) => { await exec('git', ['config', '--global', 'user.name', committerUsername]); await exec('git', ['add', ...readmeFilePaths]); await exec('git', ['commit', '-m', commitMessage]); - await exec('git', ['push']); + let pushTry = 0; + while (pushTry <= pushRetryCount) { + try { + await exec('git', ['push']); + core.info('Readme updated successfully in the upstream repository'); + return; + } catch (err) { + pushTry = pushTry + 1; + if (pushTry > pushRetryCount) { + throw err; + } + core.warning( + `git push failed (attempt ${pushTry}/${pushRetryCount}). Pulling latest changes and retrying...`, + ); + await exec('git', ['pull', '--rebase', 'origin', branchName]); + } + } core.info('Readme updated successfully in the upstream repository'); }; From edac8ff6c03d8f4275ed9f133a83d0bf01ec30e9 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 15 Apr 2026 13:41:31 +0700 Subject: [PATCH 2/2] Fix biome formatting for request timeout declaration --- src/blog-post-workflow.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/blog-post-workflow.js b/src/blog-post-workflow.js index c863e13..850bc85 100644 --- a/src/blog-post-workflow.js +++ b/src/blog-post-workflow.js @@ -77,7 +77,8 @@ const retryConfig = { factor: 1, minTimeout: Number.parseInt(core.getInput('retry_wait_time'), 10) * 1000, }; -const REQUEST_TIMEOUT = Number.parseInt(core.getInput('request_timeout'), 10) || 30; +const REQUEST_TIMEOUT = + Number.parseInt(core.getInput('request_timeout'), 10) || 30; const RETRY_WAIT_SECONDS = retryConfig.minTimeout / 1000; core.info(