Files
callstack__agent-device/test/ci/upload-artifact-hidden-paths.test.ts
Michał Pierzchała 539e848e0c fix(ci): stop ten artifact uploads discarding their hidden paths (#2091)
* fix(ci): stop ten artifact uploads discarding their hidden paths

`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.

* test(ci): scan every YAML shape GitHub accepts, not just top-level *.yml

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.
2026-08-27 20:08:45 +02:00

78 lines
2.8 KiB
TypeScript

import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { expect, test } from 'vitest';
import { parse } from 'yaml';
const repoRoot = path.resolve(import.meta.dirname, '../..');
type Step = { uses?: unknown; with?: Record<string, unknown> };
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<string, unknown>;
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 !== '..');
}
/** 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', () => {
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 });
}
});