From f877f9a9dd4a97ed1a72c45effcf3bbc10709b46 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 16 Aug 2026 14:11:12 -0600 Subject: [PATCH 1/2] fix(update): discard dirty .rsstack overlays when floating Ratspeak pnpm run update failed when prior overlays left .rsstack/rsReticulum dirty while origin/main advanced. Auto-reset the disposable cache (still refuse external WIP unless RS_STACK_DISCARD_DIRTY=1), bump packageManager to pnpm 11.22.0, and sync Flatpak standalone pnpm URLs/checksums. --- org.coloradomesh.MeshClient.yml | 8 +-- package.json | 2 +- pnpm-lock.yaml | 48 +++++++------- scripts/clone-ratspeak-stack.sh | 26 ++++++-- scripts/clone-ratspeak-stack.test.mjs | 95 ++++++++++++++++++++++++--- 5 files changed, 136 insertions(+), 43 deletions(-) diff --git a/org.coloradomesh.MeshClient.yml b/org.coloradomesh.MeshClient.yml index 994abc1c0..90aa8ade5 100644 --- a/org.coloradomesh.MeshClient.yml +++ b/org.coloradomesh.MeshClient.yml @@ -92,15 +92,15 @@ modules: path: . - flatpak/generated-sources.json - type: archive - url: https://github.com/pnpm/pnpm/releases/download/v11.21.0/pnpm-linux-x64.tar.gz - sha256: aadc489ce4473c2af0fec06a5c19e113b5793d404eac49853c8153bf4b0d8263 + url: https://github.com/pnpm/pnpm/releases/download/v11.22.0/pnpm-linux-x64.tar.gz + sha256: 4c592fa410eb23b69691a9efb9bf21c87c15b3e9d88c6ec8acdd354a0eb8de71 dest: pnpm-vendor # pnpm tarball has root-level `pnpm` + `dist/`; default strip-components:1 drops the binary. strip-components: 0 only-arches: [x86_64] - type: archive - url: https://github.com/pnpm/pnpm/releases/download/v11.21.0/pnpm-linux-arm64.tar.gz - sha256: 64eb219b008f7a4c176d81fbce919c20b0b7e093e815cbb669d46e681451f43b + url: https://github.com/pnpm/pnpm/releases/download/v11.22.0/pnpm-linux-arm64.tar.gz + sha256: f1426231f365bdfd46c15fa3d1211c3936ee2c4e557afd304f6c66dbf1b2a8bf dest: pnpm-vendor strip-components: 0 only-arches: [aarch64] diff --git a/package.json b/package.json index 3a8875903..220f0a8a5 100644 --- a/package.json +++ b/package.json @@ -231,7 +231,7 @@ "vitest-axe": "1.0.0-pre.5", "zustand": "^5.0.15" }, - "packageManager": "pnpm@11.21.0+sha512.521705bce689924eac72f5a3587122f362689ef6571e55ba80076fd637c11132ecffada26fad4ea79c485bfddbfd3d5a2a5b05805a77e893de71ec8a6cca3bb1", + "packageManager": "pnpm@11.22.0+sha512.1ff870c4c6133dfd88fb2afc46dd13d47f09c9794b438c6fdb47ca98caf3bc16381ee0be93a091b8e3824cf01f889f46d7d9e20910fb0be1ab0fb5baa80dd621", "engines": { "node": ">=22.13.0", "pnpm": ">=11.0.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a1783d27..e4e888f5d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -296,7 +296,7 @@ importers: version: 1.0.0-pre.5(vitest@4.1.10) zustand: specifier: ^5.0.15 - version: 5.0.15(@types/react@19.2.18)(immer@11.1.16)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)) + version: 5.0.15(@types/react@19.2.18)(immer@11.1.17)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)) packages: @@ -407,8 +407,8 @@ packages: '@bufbuild/protobuf@2.14.0': resolution: {integrity: sha512-C3UGsiCwSprE2NKIIFA3hCDlpXTMCAXRZuEVp88L1GY36Y41+rYL5fryE+nOFhp4p4JPQvdV8PQ4DWgHgeTE+w==} - '@csstools/color-helpers@6.1.0': - resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + '@csstools/color-helpers@6.1.1': + resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==} engines: {node: '>=20.19.0'} '@csstools/css-calc@3.3.0': @@ -418,8 +418,8 @@ packages: '@csstools/css-parser-algorithms': ^4.0.0 '@csstools/css-tokenizer': ^4.0.0 - '@csstools/css-color-parser@4.1.10': - resolution: {integrity: sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==} + '@csstools/css-color-parser@4.2.0': + resolution: {integrity: sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==} engines: {node: '>=20.19.0'} peerDependencies: '@csstools/css-parser-algorithms': ^4.0.0 @@ -431,8 +431,8 @@ packages: peerDependencies: '@csstools/css-tokenizer': ^4.0.0 - '@csstools/css-syntax-patches-for-csstree@1.1.7': - resolution: {integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==} + '@csstools/css-syntax-patches-for-csstree@1.1.8': + resolution: {integrity: sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==} peerDependencies: css-tree: ^3.2.1 peerDependenciesMeta: @@ -2044,8 +2044,8 @@ packages: electron-publish@26.15.3: resolution: {integrity: sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==} - electron-to-chromium@1.5.406: - resolution: {integrity: sha512-hWH5ORBi3d0IipnMh7BN5GDTaAmrSSSWmznwt2zltdiRNEWoEQyTwF0FFSBxzHO7hLSRT6loQu3IQGV0wg/Tvg==} + electron-to-chromium@1.5.407: + resolution: {integrity: sha512-4R8XgQOdfxexCd/u63lRm6wCHjECwI45MV9wxAs2ggtfWe2hwlo1ql97jKsju2IcJ+jFSTwBssyYoiWhh7mauQ==} electron-updater@6.8.9: resolution: {integrity: sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==} @@ -2606,8 +2606,8 @@ packages: immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} - immer@11.1.16: - resolution: {integrity: sha512-Xs7H9rBc+kti1J6RueUvbEBkmOz7jqj11XYgf+YMXAYzu8EeE7hwZ9poLXdVfVnGmJu7QAf41T7H2KuF6QoK6Q==} + immer@11.1.17: + resolution: {integrity: sha512-8Vu44Y0MuMBlTQz/jQ8HEMYNq/bBqk87MnBwYR5mC8AthfhEXidZ5aT/oA/CUqboa8THKltnD9L3xyqhU/Sy1Q==} imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} @@ -4657,7 +4657,7 @@ snapshots: dependencies: '@asamuzakjp/generational-cache': 1.0.1 '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - '@csstools/css-color-parser': 4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 @@ -4788,16 +4788,16 @@ snapshots: '@bufbuild/protobuf@2.14.0': {} - '@csstools/color-helpers@6.1.0': {} + '@csstools/color-helpers@6.1.1': {} '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - '@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + '@csstools/css-color-parser@4.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: - '@csstools/color-helpers': 6.1.0 + '@csstools/color-helpers': 6.1.1 '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 @@ -4806,7 +4806,7 @@ snapshots: dependencies: '@csstools/css-tokenizer': 4.0.0 - '@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)': + '@csstools/css-syntax-patches-for-csstree@1.1.8(css-tree@3.2.1)': optionalDependencies: css-tree: 3.2.1 @@ -5177,7 +5177,7 @@ snapshots: dependencies: '@standard-schema/spec': 1.1.0 '@standard-schema/utils': 0.3.0 - immer: 11.1.16 + immer: 11.1.17 redux: 5.0.1 redux-thunk: 3.1.0(redux@5.0.1) reselect: 5.2.0 @@ -6029,7 +6029,7 @@ snapshots: dependencies: baseline-browser-mapping: 2.11.14 caniuse-lite: 1.0.30001809 - electron-to-chromium: 1.5.406 + electron-to-chromium: 1.5.407 node-releases: 2.0.53 update-browserslist-db: 1.3.1(browserslist@4.28.8) @@ -6430,7 +6430,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron-to-chromium@1.5.406: {} + electron-to-chromium@1.5.407: {} electron-updater@6.8.9(supports-color@8.1.1): dependencies: @@ -7178,7 +7178,7 @@ snapshots: immediate@3.0.6: {} - immer@11.1.16: {} + immer@11.1.17: {} imurmurhash@0.1.4: {} @@ -7399,7 +7399,7 @@ snapshots: '@asamuzakjp/css-color': 5.1.11 '@asamuzakjp/dom-selector': 7.1.1 '@bramus/specificity': 2.4.2 - '@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1) + '@csstools/css-syntax-patches-for-csstree': 1.1.8(css-tree@3.2.1) '@exodus/bytes': 1.15.1(@noble/hashes@2.3.0) css-tree: 3.2.1 data-urls: 7.0.0(@noble/hashes@2.3.0) @@ -8400,7 +8400,7 @@ snapshots: decimal.js-light: 2.5.1 es-toolkit: 1.50.0 eventemitter3: 5.0.4 - immer: 11.1.16 + immer: 11.1.17 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) react-is: 17.0.2 @@ -9320,9 +9320,9 @@ snapshots: zod@4.4.3: {} - zustand@5.0.15(@types/react@19.2.18)(immer@11.1.16)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)): + zustand@5.0.15(@types/react@19.2.18)(immer@11.1.17)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)): optionalDependencies: '@types/react': 19.2.18 - immer: 11.1.16 + immer: 11.1.17 react: 19.2.8 use-sync-external-store: 1.6.0(react@19.2.8) diff --git a/scripts/clone-ratspeak-stack.sh b/scripts/clone-ratspeak-stack.sh index 8c0597f31..ad4a920db 100755 --- a/scripts/clone-ratspeak-stack.sh +++ b/scripts/clone-ratspeak-stack.sh @@ -94,15 +94,31 @@ ensure_repo() { ENSURE_REPO_SELECTED_REF="${target_ref}" if [[ -n "$(git -C "${dir}" status --porcelain)" ]]; then - # Overlays leave siblings dirty after a successful float — allow that when already - # on the target tip. Refuse only when checkout would rewrite a dirty tree. + # Overlays leave checkouts dirty after a successful float — allow that when already + # on the target tip. When floating/pinning to a new SHA: + # - Default .rsstack workspace is disposable overlay cache → hard-reset, then checkout + # (overlays are re-applied after ensure_repo returns for RNS/LXMF). + # - External WORKSPACE_ROOT (sibling clones with real WIP) still refuses unless + # RS_STACK_DISCARD_DIRTY=1. if [[ "${current_head}" == "${target_sha}" ]]; then echo "warning: ${dir} has uncommitted changes; already at ${target_ref} (${target_sha:0:12}), skipping checkout" >&2 return 0 fi - echo "error: ${dir} has uncommitted changes; refuse to float/pin to ${target_ref} (${target_sha:0:12}) from ${current_head:0:12} (stash or reset, then re-run)" >&2 - git -C "${dir}" status --short >&2 || true - exit 1 + local discard_dirty=0 + if [[ "${RS_STACK_DISCARD_DIRTY:-}" == '1' ]]; then + discard_dirty=1 + elif [[ "$(basename "${WORKSPACE_ROOT}")" == '.rsstack' ]]; then + discard_dirty=1 + fi + if [[ "${discard_dirty}" -eq 1 ]]; then + echo "warning: ${dir} has uncommitted changes; discarding to float/pin to ${target_ref} (${target_sha:0:12}) from ${current_head:0:12}" >&2 + git -C "${dir}" reset --hard HEAD > /dev/null + git -C "${dir}" clean -fd > /dev/null + else + echo "error: ${dir} has uncommitted changes; refuse to float/pin to ${target_ref} (${target_sha:0:12}) from ${current_head:0:12} (stash or reset, or set RS_STACK_DISCARD_DIRTY=1)" >&2 + git -C "${dir}" status --short >&2 || true + exit 1 + fi fi if [[ "${current_head}" != "${target_sha}" ]]; then diff --git a/scripts/clone-ratspeak-stack.test.mjs b/scripts/clone-ratspeak-stack.test.mjs index 108308c0a..9df5b0b5e 100644 --- a/scripts/clone-ratspeak-stack.test.mjs +++ b/scripts/clone-ratspeak-stack.test.mjs @@ -61,25 +61,28 @@ function createLocalRemote({ defaultBranch = 'main', pinTag = null } = {}) { return { remote, tipSha, pinSha }; } -function runEnsureRepo({ remoteUrl, destDir, pinRef = '' }) { +function runEnsureRepo({ remoteUrl, destDir, pinRef = '', env = {}, mergeStderr = false }) { // Plain strings so bash ${...}/$(...) is not JS template interpolation. + const ensureCall = + 'ensure_repo ' + + JSON.stringify(destDir) + + ' ' + + JSON.stringify(remoteUrl) + + ' ' + + JSON.stringify(pinRef) + + " 'rsLXST'" + + (mergeStderr ? ' 2>&1' : ''); const script = [ 'set -euo pipefail', 'source ' + JSON.stringify(cloneScriptPath), - 'ensure_repo ' + - JSON.stringify(destDir) + - ' ' + - JSON.stringify(remoteUrl) + - ' ' + - JSON.stringify(pinRef) + - " 'rsLXST'", + ensureCall, 'echo "SELECTED=${ENSURE_REPO_SELECTED_REF}"', 'echo "MODE=$(format_repo_mode "${ENSURE_REPO_SELECTED_REF}" ' + JSON.stringify(pinRef) + ')"', 'echo "SHA=$(git -C ' + JSON.stringify(destDir) + ' rev-parse HEAD)"', ].join('\n'); return execFileSync('bash', ['-c', script], { encoding: 'utf8', - env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' }, + env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', ...env }, }); } @@ -93,6 +96,10 @@ describe('clone-ratspeak-stack.sh float policy', () => { expect(cloneScript).toContain('export RS_RETICULUM_DIR='); expect(cloneScript).toContain('export RS_LXMF_DIR='); expect(cloneScript).toContain('refuse to float/pin'); + expect(cloneScript).toContain('RS_STACK_DISCARD_DIRTY'); + expect(cloneScript).toContain('discarding to float/pin'); + expect(cloneScript).toContain('basename "${WORKSPACE_ROOT}"'); + expect(cloneScript).toContain("== '.rsstack'"); expect(cloneScript).toContain('already at'); expect(cloneScript).toContain('skipping checkout'); expect(cloneScript).toContain('origin/${ref_or_empty}'); @@ -124,6 +131,76 @@ describe('clone-ratspeak-stack.sh float policy', () => { expect(git(dest, 'rev-parse', 'HEAD')).toBe(tipSha); }); + it('ensure_repo discards dirty overlay state under .rsstack workspace to float', () => { + const { remote, tipSha } = createLocalRemote({ defaultBranch: 'main' }); + const dest = join(makeTempDir('workspace-'), 'rsLXST'); + runEnsureRepo({ remoteUrl: remote, destDir: dest }); + expect(git(dest, 'rev-parse', 'HEAD')).toBe(tipSha); + + // Advance remote tip after initial clone so float must move HEAD. + const seed = makeTempDir('rsLXST-advance-'); + git(seed, 'clone', remote, '.'); + git(seed, 'config', 'user.email', 'test@example.com'); + git(seed, 'config', 'user.name', 'test'); + writeFileSync(join(seed, 'NEXT'), 'next\n'); + git(seed, 'add', 'NEXT'); + git(seed, 'commit', '-m', 'next'); + const newTip = git(seed, 'rev-parse', 'HEAD'); + git(seed, 'push', 'origin', 'HEAD:main'); + expect(newTip).not.toBe(tipSha); + + writeFileSync(join(dest, 'OVERLAY'), 'dirty\n'); + expect(git(dest, 'status', '--porcelain')).toContain('OVERLAY'); + + // Default WORKSPACE_ROOT from sourced script is repo .rsstack → discard dirty. + const out = runEnsureRepo({ remoteUrl: remote, destDir: dest, mergeStderr: true }); + expect(out).toContain('discarding to float/pin'); + expect(out).toContain(`SHA=${newTip}`); + expect(git(dest, 'rev-parse', 'HEAD')).toBe(newTip); + expect(git(dest, 'status', '--porcelain')).toBe(''); + }); + + it('ensure_repo refuses dirty float outside .rsstack unless RS_STACK_DISCARD_DIRTY=1', () => { + const { remote, tipSha } = createLocalRemote({ defaultBranch: 'main' }); + const dest = join(makeTempDir('siblings-'), 'rsLXST'); + runEnsureRepo({ remoteUrl: remote, destDir: dest }); + expect(git(dest, 'rev-parse', 'HEAD')).toBe(tipSha); + + const seed = makeTempDir('rsLXST-advance-'); + git(seed, 'clone', remote, '.'); + git(seed, 'config', 'user.email', 'test@example.com'); + git(seed, 'config', 'user.name', 'test'); + writeFileSync(join(seed, 'NEXT'), 'next\n'); + git(seed, 'add', 'NEXT'); + git(seed, 'commit', '-m', 'next'); + const newTip = git(seed, 'rev-parse', 'HEAD'); + git(seed, 'push', 'origin', 'HEAD:main'); + expect(newTip).not.toBe(tipSha); + + writeFileSync(join(dest, 'WIP'), 'keep\n'); + + const externalWorkspace = makeTempDir('not-rsstack-'); + expect(() => + runEnsureRepo({ + remoteUrl: remote, + destDir: dest, + env: { WORKSPACE_ROOT: externalWorkspace }, + }), + ).toThrow(/refuse to float\/pin/); + expect(git(dest, 'rev-parse', 'HEAD')).toBe(tipSha); + expect(git(dest, 'status', '--porcelain')).toContain('WIP'); + + const out = runEnsureRepo({ + remoteUrl: remote, + destDir: dest, + mergeStderr: true, + env: { WORKSPACE_ROOT: externalWorkspace, RS_STACK_DISCARD_DIRTY: '1' }, + }); + expect(out).toContain('discarding to float/pin'); + expect(out).toContain(`SHA=${newTip}`); + expect(git(dest, 'status', '--porcelain')).toBe(''); + }); + it('ensure_repo falls back to origin/master when main is absent', () => { const { remote, tipSha } = createLocalRemote({ defaultBranch: 'master' }); const dest = join(makeTempDir('workspace-'), 'rsLXST'); From 1e7704be53074e34e3c8ae0c4bc939dbdcef24e7 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 16 Aug 2026 14:29:41 -0600 Subject: [PATCH 2/2] fix(ci): retry actionlint download on transient fetch failures CI failed on Install actionlint with Node "fetch failed" against the GitHub release CDN. Retry the download with backoff and clean up partial files between attempts. --- scripts/install-actionlint.mjs | 68 ++++++++++++++-- scripts/install-actionlint.test.mjs | 122 +++++++++++++++++++++++++++- 2 files changed, 182 insertions(+), 8 deletions(-) diff --git a/scripts/install-actionlint.mjs b/scripts/install-actionlint.mjs index 9956a63cb..edc4fb658 100644 --- a/scripts/install-actionlint.mjs +++ b/scripts/install-actionlint.mjs @@ -75,14 +75,68 @@ export function pickActionlintAsset(assets, osKey, archKey) { return { name: asset.name, browser_download_url: asset.browser_download_url }; } -async function downloadToFile(url, destinationPath, headers) { - const res = await fetch(url, { headers }); - if (!res.ok) { - throw new Error(`Failed to download: ${res.status} ${res.statusText}`); +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Download a URL to disk with retries for transient CDN / network failures + * (Node `fetch failed`, 5xx, etc.). Used by CI `setup:actionlint`. + * + * @param {string} url + * @param {string} destinationPath + * @param {Record} headers + * @param {{ + * attempts?: number, + * baseDelayMs?: number, + * fetchImpl?: typeof fetch, + * sleepImpl?: (ms: number) => Promise, + * warn?: (...args: unknown[]) => void, + * }} [opts] + */ +export async function downloadToFile( + url, + destinationPath, + headers, + { + attempts = 5, + baseDelayMs = 500, + fetchImpl = fetch, + sleepImpl = sleep, + warn = console.warn, + } = {}, +) { + let lastError = /** @type {unknown} */ (null); + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + const res = await fetchImpl(url, { headers, redirect: 'follow' }); + if (!res.ok) { + throw new Error(`Failed to download: ${res.status} ${res.statusText}`); + } + if (!res.body) { + throw new Error('Failed to download: empty response body'); + } + const file = createWriteStream(destinationPath); + await pipeline(res.body, file); + return; + } catch (err) { + lastError = err; + try { + await fs.unlink(destinationPath); + } catch { + // catch-no-log-ok partial download may not exist + } + if (attempt >= attempts) break; + const delayMs = Math.min(8_000, baseDelayMs * 2 ** (attempt - 1)); + warn( + `[install-actionlint] download attempt ${attempt}/${attempts} failed: ${ + err instanceof Error ? err.message : String(err) + } — retrying in ${delayMs}ms`, + ); + await sleepImpl(delayMs); + } } - const file = createWriteStream(destinationPath); - // Pipe the response body stream into the file. - await pipeline(res.body, file); + throw lastError instanceof Error ? lastError : new Error(String(lastError)); } async function pathExists(p) { diff --git a/scripts/install-actionlint.test.mjs b/scripts/install-actionlint.test.mjs index 273dcdd46..cdcc47526 100644 --- a/scripts/install-actionlint.test.mjs +++ b/scripts/install-actionlint.test.mjs @@ -1,6 +1,12 @@ -import { describe, expect, it } from 'vitest'; +import { promises as fs } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Readable } from 'node:stream'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; import { + downloadToFile, githubApiHeaders, normalizeArch, normalizeOs, @@ -9,6 +15,26 @@ import { pinnedActionlintAsset, } from './install-actionlint.mjs'; +const tempDirs = []; + +afterEach(async () => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + try { + await fs.rm(dir, { recursive: true, force: true }); + } catch { + // ignore cleanup failures + } + } + vi.restoreAllMocks(); +}); + +async function makeTempDir(prefix) { + const dir = await fs.mkdtemp(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + describe('install-actionlint', () => { it('pins a concrete actionlint version for API rate-limit fallback', () => { expect(PINNED_ACTIONLINT_VERSION).toMatch(/^\d+\.\d+\.\d+$/); @@ -61,4 +87,98 @@ describe('install-actionlint', () => { browser_download_url: 'https://example.test/darwin.tar.gz', }); }); + + it('downloadToFile retries transient fetch failures then succeeds', async () => { + const dir = await makeTempDir('actionlint-dl-'); + const dest = join(dir, 'asset.bin'); + const sleeps = []; + const warns = []; + let calls = 0; + const fetchImpl = vi.fn(async () => { + calls += 1; + if (calls < 3) { + throw new TypeError('fetch failed'); + } + return { + ok: true, + body: Readable.toWeb(Readable.from([Buffer.from('ok-bytes')])), + }; + }); + + await downloadToFile( + 'https://example.test/asset.bin', + dest, + { 'User-Agent': 'test' }, + { + attempts: 5, + baseDelayMs: 10, + fetchImpl, + sleepImpl: async (ms) => { + sleeps.push(ms); + }, + warn: (...args) => warns.push(args.join(' ')), + }, + ); + + expect(fetchImpl).toHaveBeenCalledTimes(3); + expect(sleeps).toEqual([10, 20]); + expect(warns.some((line) => line.includes('fetch failed'))).toBe(true); + expect(await fs.readFile(dest, 'utf8')).toBe('ok-bytes'); + }); + + it('downloadToFile exhausts retries on persistent failure', async () => { + const dir = await makeTempDir('actionlint-dl-fail-'); + const dest = join(dir, 'asset.bin'); + const fetchImpl = vi.fn(async () => { + throw new TypeError('fetch failed'); + }); + + await expect( + downloadToFile( + 'https://example.test/asset.bin', + dest, + { 'User-Agent': 'test' }, + { + attempts: 3, + baseDelayMs: 1, + fetchImpl, + sleepImpl: async () => {}, + warn: () => {}, + }, + ), + ).rejects.toThrow(/fetch failed/); + expect(fetchImpl).toHaveBeenCalledTimes(3); + await expect(fs.access(dest)).rejects.toThrow(); + }); + + it('downloadToFile retries non-OK HTTP responses', async () => { + const dir = await makeTempDir('actionlint-dl-http-'); + const dest = join(dir, 'asset.bin'); + let calls = 0; + const fetchImpl = vi.fn(async () => { + calls += 1; + if (calls === 1) { + return { ok: false, status: 502, statusText: 'Bad Gateway', body: null }; + } + return { + ok: true, + body: Readable.toWeb(Readable.from([Buffer.from('recovered')])), + }; + }); + + await downloadToFile( + 'https://example.test/asset.bin', + dest, + {}, + { + attempts: 3, + baseDelayMs: 1, + fetchImpl, + sleepImpl: async () => {}, + warn: () => {}, + }, + ); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(await fs.readFile(dest, 'utf8')).toBe('recovered'); + }); });