From 6bafd2caf322c997d2f147fc0b915081a80b5377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 27 Aug 2026 16:26:32 +0200 Subject: [PATCH 1/2] fix(ci): stop ten artifact uploads discarding their hidden paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `actions/upload-artifact` has excluded hidden files and directories by default since v4.4 (this repository pins v4.6.2), and most diagnostics here are written under `.tmp`. Ten upload steps across seven files therefore uploaded nothing from those paths: macos.yml's xcresult bundle, both mutation lanes' reports and shards, replays-nightly's fuzz output, xctest-nightly's results, test-app-build-cache's fixture tarball, and 1874-diagnose's per-iteration logs. Most fail silently, since they pair the omission with `if-no-files-found: warn` or `ignore`. test-app-build-cache sets `error`, so that one does not. A structural guard rather than a shared upload wrapper: the wrapper would be a shallow mirror of the action's options over artifacts with different owners, while the policy question — a hidden path needs the flag — is one rule that belongs in one place. Each workflow still declares its own artifact. test/ci/upload-artifact-hidden-paths.test.ts holds it across every workflow and composite action, and is red if any single flag is dropped. --- .github/workflows/1874-diagnose.yml | 1 + .github/workflows/macos.yml | 1 + .github/workflows/mutation-affected.yml | 3 ++ .github/workflows/mutation-weekly.yml | 2 + .github/workflows/replays-nightly.yml | 1 + .github/workflows/test-app-build-cache.yml | 1 + .github/workflows/xctest-nightly.yml | 1 + test/ci/upload-artifact-hidden-paths.test.ts | 53 ++++++++++++++++++++ vitest.config.ts | 1 + 9 files changed, 64 insertions(+) create mode 100644 test/ci/upload-artifact-hidden-paths.test.ts diff --git a/.github/workflows/1874-diagnose.yml b/.github/workflows/1874-diagnose.yml index 2539704f6b..9c7bd6e5c7 100644 --- a/.github/workflows/1874-diagnose.yml +++ b/.github/workflows/1874-diagnose.yml @@ -106,6 +106,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: stall-evidence-${{ matrix.arch }}-${{ matrix.mode }} + include-hidden-files: true path: | stall-summary.txt .tmp/stall-failure-*.log diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 37b6259a11..64e59ce5ff 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -122,6 +122,7 @@ jobs: uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: xctest-host-results-${{ github.run_id }}-${{ github.run_attempt }} + include-hidden-files: true path: .tmp/xctest-host if-no-files-found: warn diff --git a/.github/workflows/mutation-affected.yml b/.github/workflows/mutation-affected.yml index 9ade741720..7c0aef5391 100644 --- a/.github/workflows/mutation-affected.yml +++ b/.github/workflows/mutation-affected.yml @@ -81,6 +81,7 @@ jobs: uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: mutation-affected-select + include-hidden-files: true path: .tmp/mutation/lane-envelope.json if-no-files-found: warn @@ -122,6 +123,7 @@ jobs: uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: mutation-affected-shard-${{ matrix.name }} + include-hidden-files: true path: | .tmp/mutation/mutation.json .tmp/mutation/mutation.html @@ -175,6 +177,7 @@ jobs: uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: mutation-affected + include-hidden-files: true path: | .tmp/mutation/shards .tmp/mutation/lane-envelope.json diff --git a/.github/workflows/mutation-weekly.yml b/.github/workflows/mutation-weekly.yml index 3b475f82cf..ed8ddf701b 100644 --- a/.github/workflows/mutation-weekly.yml +++ b/.github/workflows/mutation-weekly.yml @@ -83,6 +83,7 @@ jobs: uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: mutation-shard-${{ matrix.name }} + include-hidden-files: true path: | .tmp/mutation/mutation.json .tmp/mutation/lane-envelope.json @@ -160,6 +161,7 @@ jobs: uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: mutation-decision-kernels + include-hidden-files: true path: | .tmp/mutation/shards .tmp/mutation/lane-envelope.json diff --git a/.github/workflows/replays-nightly.yml b/.github/workflows/replays-nightly.yml index 6f04bdd910..9ecc227043 100644 --- a/.github/workflows/replays-nightly.yml +++ b/.github/workflows/replays-nightly.yml @@ -92,6 +92,7 @@ jobs: uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: parser-fuzz-run-${{ github.run_id }}-${{ github.run_attempt }} + include-hidden-files: true path: .tmp/fuzz if-no-files-found: warn diff --git a/.github/workflows/test-app-build-cache.yml b/.github/workflows/test-app-build-cache.yml index 95421847ac..f675985131 100644 --- a/.github/workflows/test-app-build-cache.yml +++ b/.github/workflows/test-app-build-cache.yml @@ -230,6 +230,7 @@ jobs: uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: ${{ matrix.artifactName }} + include-hidden-files: true path: .tmp/test-app-artifact/binary.tar.gz if-no-files-found: error compression-level: 0 # already gzipped diff --git a/.github/workflows/xctest-nightly.yml b/.github/workflows/xctest-nightly.yml index 6fc3e926db..91ba7a5648 100644 --- a/.github/workflows/xctest-nightly.yml +++ b/.github/workflows/xctest-nightly.yml @@ -186,5 +186,6 @@ jobs: uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: xctest-nightly-results-${{ github.run_id }}-${{ github.run_attempt }} + include-hidden-files: true path: .tmp/xctest-nightly if-no-files-found: warn diff --git a/test/ci/upload-artifact-hidden-paths.test.ts b/test/ci/upload-artifact-hidden-paths.test.ts new file mode 100644 index 0000000000..637e8f251c --- /dev/null +++ b/test/ci/upload-artifact-hidden-paths.test.ts @@ -0,0 +1,53 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { expect, test } from 'vitest'; +import { parse } from 'yaml'; + +// `actions/upload-artifact` excludes hidden files and directories unless `include-hidden-files` is +// set (default since v4.4); this repository writes most of its diagnostics under `.tmp`. + +const repoRoot = path.resolve(import.meta.dirname, '../..'); + +type Step = { uses?: unknown; with?: Record }; + +function uploadSteps(node: unknown): Step[] { + if (Array.isArray(node)) return node.flatMap(uploadSteps); + if (node === null || typeof node !== 'object') return []; + const record = node as Record; + const self = + typeof record.uses === 'string' && record.uses.startsWith('actions/upload-artifact@') + ? [record as Step] + : []; + return [...self, ...Object.values(record).flatMap(uploadSteps)]; +} + +function isHidden(entry: string): boolean { + return entry + .split('/') + .some((segment) => segment.startsWith('.') && segment !== '.' && segment !== '..'); +} + +function configuredFiles(): string[] { + const workflows = path.join(repoRoot, '.github/workflows'); + const actions = path.join(repoRoot, '.github/actions'); + return [ + ...fs.readdirSync(workflows).map((name) => `.github/workflows/${name}`), + ...fs.readdirSync(actions).map((name) => `.github/actions/${name}/action.yml`), + ].filter((file) => file.endsWith('.yml') && fs.existsSync(path.join(repoRoot, file))); +} + +test('every artifact upload that writes a hidden path opts into hidden files', () => { + const offenders = configuredFiles().flatMap((file) => + uploadSteps(parse(fs.readFileSync(path.join(repoRoot, file), 'utf8'))) + .filter((step) => { + const paths = String(step.with?.path ?? '') + .split('\n') + .map((entry) => entry.trim()) + .filter(Boolean); + return paths.some(isHidden) && step.with?.['include-hidden-files'] !== true; + }) + .map((step) => `${file}: ${step.with?.name ?? '(unnamed)'}`), + ); + + expect(offenders, 'these uploads silently discard their hidden paths').toEqual([]); +}); diff --git a/vitest.config.ts b/vitest.config.ts index d7c888a4b0..f7303c7e1b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -152,6 +152,7 @@ export default defineConfig({ 'scripts/__tests__/size-report-package.test.ts', // Parses CI configuration only, so this action guard needs no device or subprocess lane. 'test/ci/upload-agent-device-artifacts.test.ts', + 'test/ci/upload-artifact-hidden-paths.test.ts', // The size reporter is preserved across a base checkout; its entrypoint and imported // modules must move as one directory or the Bundle Size lane fails before measuring. 'test/ci/size-workflow.test.ts', From 8f90ef5dcb3814e1dc68cf9ac1cc81b98bce4918 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 27 Aug 2026 19:29:57 +0200 Subject: [PATCH 2/2] test(ci): scan every YAML shape GitHub accepts, not just top-level *.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard read `.github/workflows/*.yml` and assumed local actions live one directory deep as `action.yml`. GitHub also reads `.yaml` for both, and local actions nest, so a hidden-path upload in any of those shapes passed the gate. It now walks the `.github` tree recursively for either extension, and a second test plants the three shapes the old scan missed and asserts all three are found — executable rather than a one-off manual check. Red against narrowing the extension, against dropping the recursive walk, and against removing any single real flag. Also drops the action-version note under the comment rule in #2087; the version behaviour belongs in the PR, and the assertion message already says what the omission costs. --- test/ci/upload-artifact-hidden-paths.test.ts | 70 +++++++++++++------- 1 file changed, 47 insertions(+), 23 deletions(-) diff --git a/test/ci/upload-artifact-hidden-paths.test.ts b/test/ci/upload-artifact-hidden-paths.test.ts index 637e8f251c..d7b4b6d183 100644 --- a/test/ci/upload-artifact-hidden-paths.test.ts +++ b/test/ci/upload-artifact-hidden-paths.test.ts @@ -1,11 +1,9 @@ import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { expect, test } from 'vitest'; import { parse } from 'yaml'; -// `actions/upload-artifact` excludes hidden files and directories unless `include-hidden-files` is -// set (default since v4.4); this repository writes most of its diagnostics under `.tmp`. - const repoRoot = path.resolve(import.meta.dirname, '../..'); type Step = { uses?: unknown; with?: Record }; @@ -27,27 +25,53 @@ function isHidden(entry: string): boolean { .some((segment) => segment.startsWith('.') && segment !== '.' && segment !== '..'); } -function configuredFiles(): string[] { - const workflows = path.join(repoRoot, '.github/workflows'); - const actions = path.join(repoRoot, '.github/actions'); - return [ - ...fs.readdirSync(workflows).map((name) => `.github/workflows/${name}`), - ...fs.readdirSync(actions).map((name) => `.github/actions/${name}/action.yml`), - ].filter((file) => file.endsWith('.yml') && fs.existsSync(path.join(repoRoot, file))); +/** Every YAML GitHub reads under a `.github` tree: both extensions, local actions at any depth. */ +function offenders(root: string): string[] { + return fs + .readdirSync(root, { recursive: true, encoding: 'utf8' }) + .map((entry) => path.join(root, entry)) + .filter((file) => /\.ya?ml$/.test(file) && fs.statSync(file).isFile()) + .flatMap((file) => + uploadSteps(parse(fs.readFileSync(file, 'utf8'))) + .filter((step) => { + const paths = String(step.with?.path ?? '') + .split('\n') + .map((entry) => entry.trim()) + .filter(Boolean); + return paths.some(isHidden) && step.with?.['include-hidden-files'] !== true; + }) + .map((step) => `${path.relative(root, file)}: ${step.with?.name ?? '(unnamed)'}`), + ); } test('every artifact upload that writes a hidden path opts into hidden files', () => { - const offenders = configuredFiles().flatMap((file) => - uploadSteps(parse(fs.readFileSync(path.join(repoRoot, file), 'utf8'))) - .filter((step) => { - const paths = String(step.with?.path ?? '') - .split('\n') - .map((entry) => entry.trim()) - .filter(Boolean); - return paths.some(isHidden) && step.with?.['include-hidden-files'] !== true; - }) - .map((step) => `${file}: ${step.with?.name ?? '(unnamed)'}`), - ); - - expect(offenders, 'these uploads silently discard their hidden paths').toEqual([]); + expect( + offenders(path.join(repoRoot, '.github')), + 'these uploads silently discard their hidden paths', + ).toEqual([]); +}); + +test('the scan reaches every shape GitHub accepts', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'hidden-uploads-')); + const plant = (file: string, name: string) => { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync( + path.join(root, file), + `jobs:\n j:\n steps:\n - uses: actions/upload-artifact@v4\n` + + ` with:\n name: ${name}\n path: .tmp/out\n`, + ); + }; + try { + plant('workflows/dotyaml.yaml', 'yaml-extension'); + plant('actions/nested/inner/action.yaml', 'nested-action'); + plant('actions/shallow/action.yml', 'shallow-action'); + + expect(offenders(root).sort()).toEqual([ + 'actions/nested/inner/action.yaml: nested-action', + 'actions/shallow/action.yml: shallow-action', + 'workflows/dotyaml.yaml: yaml-extension', + ]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } });