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'); 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'); + }); });