mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
fix(ci): isolate persistent pnpm indexes on runner data volumes
This commit is contained in:
1 parent
350dcad963
commit
f50dce316c
13 files changed
+71
-32
No files matched your search
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-06-pr-ci-runner-temporary-storage.md
|
||||
2026-09-06-pr-ci-runner-temporary-storage.md: ea8c96cbcc3944a9ce66a60daa2a0366cd0280d0
|
||||
2026-09-06-pr-ci-runner-temporary-storage.zh.md: e131a27e8f9202938583929fd4c663a4f0ca170c
|
||||
2026-09-06-pr-ci-runner-temporary-storage.md: 7c0ee0e6d1b357790061ca5e61a2c871d8669bb1
|
||||
2026-09-06-pr-ci-runner-temporary-storage.zh.md: ff4bae48a2681d70e1b32901297ec4b6fae75e3b
|
||||
@@ -12,7 +12,7 @@ The Linux failover pool runs multiple runner instances on one VM. PR coverage an
|
||||
|
||||
The static, coverage, and consumer jobs in [PR CI](../../../../.github/workflows/ci.yml) export `TMPDIR=runner.temp` through `GITHUB_ENV` in their first step before any setup or test process starts. Node, Vite, tsx, and temporary test consumers inherit the runner-owned location. Each runner owns its directory and GitHub Actions clears its removable contents at job start and completion; fixtures still allocate unique children and retain their own cleanup.
|
||||
|
||||
The three workers also set `npm_config_cache` to `runner.temp/npm-cache`. The [release workflows](../../../../.github/workflows/release.yml) apply the same cache location in their existing temporary-storage setup, including [vendor rehearsals](../../../../.github/workflows/release-vendor.yml). npm otherwise caches registry responses under the shared home directory regardless of `TMPDIR`; a temporary consumer alone does not isolate those writes. The persistent pnpm store is unchanged.
|
||||
The three workers also set `npm_config_cache` to `runner.temp/npm-cache`. The [release workflows](../../../../.github/workflows/release.yml) apply the same cache location in their existing temporary-storage setup, including [vendor rehearsals](../../../../.github/workflows/release-vendor.yml). npm otherwise caches registry responses under the shared home directory regardless of `TMPDIR`; a temporary consumer alone does not isolate those writes. Each worker keeps its persistent pnpm store beside `RUNNER_TEMP`, under its runner work root. This places the SQLite store index on the workspace volume and separates concurrent runner instances without deleting the shared home store. The first install per runner is cold; subsequent jobs on that runner reuse its store. Operators retain responsibility for persistent-store capacity.
|
||||
|
||||
The [release rehearsal decision](../process/2026-09-06-release-rehearsal-selfhosted.md) applies the same lifetime rule to release consumers. The [failover runbook](../process/2026-07-26-ci-failover-runbook.md) continues to own runner selection and shared-host capacity. This change does not retarget jobs, reduce concurrency, retry tests, change assertions, or modify master-only CI.
|
||||
|
||||
@@ -22,7 +22,7 @@ The [ACP diagnostic scenario](../../../../snapshots/session/subagent-acp-diagnos
|
||||
|
||||
## Workspace-grant fixture placement
|
||||
|
||||
The headless `session-sandbox-root` fixture declares `workspace.parent: outside-temp`, not a home-filesystem dependency. Its allocator uses a sibling of the canonical platform temp root where that avoids system directories, otherwise home, and rejects a cwd already covered by automatic temporary write grants. On the failover runner this keeps the test on the data volume without making its write succeed through a temporary-directory exemption. Atomic workspace allocation, final cleanup, recorded Session bytes, and the independent expected file remain unchanged.
|
||||
The headless `session-sandbox-root` fixture declares `workspace.parent: outside-temp`, not a home-filesystem dependency. Its allocator uses a sibling of the canonical platform temp root where the parent is writable and avoids system temporary grants, otherwise home, and rejects a cwd already covered by automatic temporary write grants. On the failover runner this keeps the test on the data volume without making its write succeed through a temporary-directory exemption. Atomic workspace allocation, final cleanup, recorded Session bytes, and the independent expected file remain unchanged.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Linux 故障切换池在同一台虚拟机上运行多个 runner 实例。PR 覆
|
||||
|
||||
[PR CI](../../../../.github/workflows/ci.yml) 的静态检查、覆盖率和消费者作业在任何准备或测试进程启动前,在首个步骤通过 `GITHUB_ENV` 导出 `TMPDIR=runner.temp`。Node、Vite、tsx 和临时测试消费者继承 runner 管理的位置。每个 runner 管理自己的目录,GitHub Actions 在作业开始和完成时清除其中可删除的内容;测试夹具仍分配唯一子目录,并保留自身清理逻辑。
|
||||
|
||||
这三个 worker 还将 `npm_config_cache` 设为 `runner.temp/npm-cache`。[发布工作流](../../../../.github/workflows/release.yml) 在既有临时存储准备步骤中采用相同缓存位置,[vendor 演练](../../../../.github/workflows/release-vendor.yml) 也如此。否则,无论 `TMPDIR` 如何设置,npm 都会在共享 home 目录中缓存注册表响应;仅使用临时消费者目录不能隔离这些写入。持久化 pnpm store 保持不变。
|
||||
这三个 worker 还将 `npm_config_cache` 设为 `runner.temp/npm-cache`。[发布工作流](../../../../.github/workflows/release.yml) 在既有临时存储准备步骤中采用相同缓存位置,[vendor 演练](../../../../.github/workflows/release-vendor.yml) 也如此。否则,无论 `TMPDIR` 如何设置,npm 都会在共享 home 目录中缓存注册表响应;仅使用临时消费者目录不能隔离这些写入。每个 worker 将持久化 pnpm store 放在 `RUNNER_TEMP` 旁的 runner 工作根目录下。这使 SQLite store 索引位于 workspace 所在卷,并隔离并发 runner 实例,而不删除共享 home store。每个 runner 的首次安装是冷启动;该 runner 的后续作业复用其 store。持久化 store 的容量仍由运维人员负责。
|
||||
|
||||
[发布演练决策](../process/2026-09-06-release-rehearsal-selfhosted.zh.md) 对发布消费者采用相同的生命周期规则。[故障切换运行手册](../process/2026-07-26-ci-failover-runbook.zh.md) 继续负责 runner 选择和共享主机容量。本变更不调整作业目标、不降低并发、不重试测试、不修改断言,也不修改仅在 master 上执行的 CI。
|
||||
|
||||
@@ -22,7 +22,7 @@ Linux 故障切换池在同一台虚拟机上运行多个 runner 实例。PR 覆
|
||||
|
||||
## Workspace 授权夹具的位置
|
||||
|
||||
Headless 的 `session-sandbox-root` 夹具声明 `workspace.parent: outside-temp`,而不是依赖 home 所在文件系统。分配器在无需使用系统目录时选择规范化平台临时根目录的同级目录,否则使用 home,并拒绝已被自动临时写授权覆盖的 cwd。在故障切换 runner 上,这让测试留在数据卷中,同时不会让写入借助临时目录豁免而成功。原子 workspace 分配、最终清理、录制的 Session 字节以及独立预期文件保持不变。
|
||||
Headless 的 `session-sandbox-root` 夹具声明 `workspace.parent: outside-temp`,而不是依赖 home 所在文件系统。分配器在父目录可写且避开系统临时授权时选择规范化平台临时根目录的同级目录,否则使用 home,并拒绝已被自动临时写授权覆盖的 cwd。在故障切换 runner 上,这让测试留在数据卷中,同时不会让写入借助临时目录豁免而成功。原子 workspace 分配、最终清理、录制的 Session 字节以及独立预期文件保持不变。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
- name: Configure pnpm store path
|
||||
id: pnpm-store
|
||||
run: |
|
||||
store_root="$HOME/.local/share/pnpm/store"
|
||||
store_root="${RUNNER_TEMP%/*}/pnpm-store"
|
||||
echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"
|
||||
store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)
|
||||
echo "path=$store_path" >> "$GITHUB_OUTPUT"
|
||||
@@ -136,14 +136,13 @@ jobs:
|
||||
- name: Configure pnpm store path
|
||||
id: pnpm-store
|
||||
run: |
|
||||
store_root="$HOME/.local/share/pnpm/store"
|
||||
store_root="${RUNNER_TEMP%/*}/pnpm-store"
|
||||
echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"
|
||||
store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)
|
||||
echo "path=$store_path" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Skipped under failover: the self-hosted VM's persistent pnpm store
|
||||
# already serves warm installs, while restoring the hosted archive
|
||||
# would spend ~52 s pulling ~180 MB into that populated store.
|
||||
# Self-hosted runners retain their own stores between jobs.
|
||||
# Hosted runners restore the remote cache into their ephemeral store.
|
||||
- uses: actions/cache/restore@v4
|
||||
if: vars.DSH_CI_FAILOVER_LINUX != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]'
|
||||
with:
|
||||
@@ -253,7 +252,7 @@ jobs:
|
||||
- name: Configure pnpm store path
|
||||
id: pnpm-store
|
||||
run: |
|
||||
store_root="$HOME/.local/share/pnpm/store"
|
||||
store_root="${RUNNER_TEMP%/*}/pnpm-store"
|
||||
echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"
|
||||
store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)
|
||||
echo "path=$store_path" >> "$GITHUB_OUTPUT"
|
||||
@@ -261,8 +260,8 @@ jobs:
|
||||
# Pull requests restore the pnpm store and Playwright caches without paying
|
||||
# compression and upload on the required path. No master job saves these
|
||||
# hosted cache keys, so each restore-keys fallback hits the matching archived
|
||||
# entry until it evicts, after which the store is cold. Skipped under failover
|
||||
# — the self-hosted VM's persistent store is already warm.
|
||||
# entry until it evicts, after which the store is cold. Self-hosted runners
|
||||
# keep their own persistent stores instead of restoring the remote archive.
|
||||
- uses: actions/cache/restore@v4
|
||||
if: vars.DSH_CI_FAILOVER_LINUX != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]'
|
||||
with:
|
||||
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
- name: Configure pnpm store path
|
||||
id: pnpm-store
|
||||
run: |
|
||||
store_root="$HOME/.local/share/pnpm/store"
|
||||
store_root="${RUNNER_TEMP%/*}/pnpm-store"
|
||||
echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"
|
||||
store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)
|
||||
echo "path=$store_path" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
- name: Configure pnpm store path
|
||||
id: pnpm-store
|
||||
run: |
|
||||
store_root="$HOME/.local/share/pnpm/store"
|
||||
store_root="${RUNNER_TEMP%/*}/pnpm-store"
|
||||
echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"
|
||||
store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)
|
||||
echo "path=$store_path" >> "$GITHUB_OUTPUT"
|
||||
@@ -128,7 +128,7 @@ jobs:
|
||||
- name: Configure pnpm store path
|
||||
id: pnpm-store
|
||||
run: |
|
||||
store_root="$HOME/.local/share/pnpm/store"
|
||||
store_root="${RUNNER_TEMP%/*}/pnpm-store"
|
||||
echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"
|
||||
store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)
|
||||
echo "path=$store_path" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/test-support/session-snapshot/README.md
|
||||
README.md: cd6cc70c4617a5df61fd630571d024fc1bcab7fb
|
||||
README.zh.md: 2d425c829ae96f027f322b2c116daf7628e8ef41
|
||||
README.md: 8d1e4ad7bc7611c4d22a8109dcc0b4993f49bb01
|
||||
README.zh.md: 287de8c7f4390e0762d202e5650a2c5e9ba49c1b
|
||||
@@ -84,7 +84,7 @@ A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.j
|
||||
|
||||
### Platform and composition variants
|
||||
|
||||
A scenario requiring a non-Windows host declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere; a scenario whose composition needs a usable `pwsh` declares `pwshOnly`. `workspaceParent` moves the generated child cwd outside the platform temp directory when temporary-directory grants are themselves under test; a scenario's committed `workspace/` is copied into that child first, then `prepareWorkspace` runs against the generated cwd before the agent starts. Default generated workspaces are stored in session fixtures as `{{cwd}}`, so platform temp roots and random basenames do not affect recordings. Headless manifests use `workspace.parent: outside-temp` when the Session workspace grant itself is under test. The adapter allocates beside the platform temp root where possible, otherwise under home, and rejects any generated cwd already covered by automatic temporary write grants.
|
||||
A scenario requiring a non-Windows host declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere; a scenario whose composition needs a usable `pwsh` declares `pwshOnly`. `workspaceParent` moves the generated child cwd outside the platform temp directory when temporary-directory grants are themselves under test; a scenario's committed `workspace/` is copied into that child first, then `prepareWorkspace` runs against the generated cwd before the agent starts. Default generated workspaces are stored in session fixtures as `{{cwd}}`, so platform temp roots and random basenames do not affect recordings. Headless manifests use `workspace.parent: outside-temp` when the Session workspace grant itself is under test. The adapter allocates beside the platform temp root when its parent is writable and outside system temporary grants, otherwise under home, and rejects any generated cwd already covered by automatic temporary write grants.
|
||||
|
||||
### What can go wrong
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ defineAcpSnapshotSuite({
|
||||
|
||||
### 平台与组合变体
|
||||
|
||||
需要非 Windows 主机的场景声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件;组合需要可用 `pwsh` 的场景声明 `pwshOnly`。当临时目录授权自身待测时,`workspaceParent` 将生成子级 cwd 移出平台临时区域;场景签入的 `workspace/` 会先复制到该子级,随后 `prepareWorkspace` 在 agent 启动前针对生成 cwd 运行。默认生成的 workspace 在会话 fixture 中存储为 `{{cwd}}`,使平台临时根目录与随机 basename 不影响录制。 Headless manifest 在测试 Session workspace 授权本身时使用 `workspace.parent: outside-temp`。适配器优先在平台临时根目录旁分配目录,否则使用 home,并拒绝已被自动临时写授权覆盖的生成 cwd。
|
||||
需要非 Windows 主机的场景声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件;组合需要可用 `pwsh` 的场景声明 `pwshOnly`。当临时目录授权自身待测时,`workspaceParent` 将生成子级 cwd 移出平台临时区域;场景签入的 `workspace/` 会先复制到该子级,随后 `prepareWorkspace` 在 agent 启动前针对生成 cwd 运行。默认生成的 workspace 在会话 fixture 中存储为 `{{cwd}}`,使平台临时根目录与随机 basename 不影响录制。 Headless manifest 在测试 Session workspace 授权本身时使用 `workspace.parent: outside-temp`。适配器在父目录可写且位于系统临时授权之外时,于平台临时根目录旁分配目录,否则使用 home,并拒绝已被自动临时写授权覆盖的生成 cwd。
|
||||
|
||||
### 可能出什么问题
|
||||
|
||||
|
||||
@@ -49,6 +49,16 @@ describe('CI workflow', () => {
|
||||
'',
|
||||
].join('\n'),
|
||||
})
|
||||
const store: unknown = job.steps.find(step => isRecord(step) && step.name === 'Configure pnpm store path')
|
||||
expect(store).toMatchObject({
|
||||
run: [
|
||||
'store_root="${RUNNER_TEMP%/*}/pnpm-store"',
|
||||
'echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"',
|
||||
'store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)',
|
||||
'echo "path=$store_path" >> "$GITHUB_OUTPUT"',
|
||||
'',
|
||||
].join('\n'),
|
||||
})
|
||||
for (const step of job.steps) {
|
||||
if (isRecord(step) && isRecord(step.env)) {
|
||||
expect(step.env.TMPDIR).toBeUndefined()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, readFile, rm, stat, symlink } from 'node:fs/promises'
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, stat, symlink } from 'node:fs/promises'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { dirname, join, parse } from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
@@ -12,9 +12,18 @@ import { assertWorkspaceOutsideTemp, outsideTempWorkspaceParent } from './snapsh
|
||||
|
||||
// Host disk exhaustion is not simulated: placement and the real write fence are the regression oracles.
|
||||
describe('snapshot workspace parent', () => {
|
||||
it.skipIf(process.platform === 'win32')('places runner-temp siblings on the runner data filesystem instead of home', () => {
|
||||
expect(outsideTempWorkspaceParent('/data_local/ci/_work28/_temp', '/home/ubuntu'))
|
||||
.toBe('/data_local/ci/_work28')
|
||||
// Windows directory permissions and root bypass do not enforce POSIX write bits.
|
||||
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)('uses home when the temp parent is not writable', async () => {
|
||||
const base = await mkdtemp(join(homedir(), '.dsh-snapshot-readonly-'))
|
||||
try {
|
||||
const temporary = join(base, '_temp')
|
||||
await mkdir(temporary)
|
||||
await chmod(base, 0o500)
|
||||
expect(outsideTempWorkspaceParent(temporary)).toBe(homedir())
|
||||
} finally {
|
||||
await chmod(base, 0o700)
|
||||
await rm(base, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('uses home when a temp sibling would require a system directory or inherit its grant', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Workspace placement for snapshots that must not inherit temporary-directory write grants. */
|
||||
|
||||
import { accessSync, constants } from 'node:fs'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { dirname, isAbsolute, parse, relative, sep } from 'node:path'
|
||||
import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox'
|
||||
@@ -10,19 +11,26 @@ function contains(root: string, path: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a sibling parent of the platform temp directory, or home for the system temp root.
|
||||
* Select a writable temp sibling parent, or home when that parent is unavailable for writes.
|
||||
* The caller atomically allocates and owns cleanup of the generated workspace.
|
||||
* @param tempRoot - platform temporary directory.
|
||||
* @param home - home directory used when a temp sibling would require a system directory.
|
||||
* @param home - fallback when temp siblings require a system directory or a non-writable parent.
|
||||
* @returns existing parent outside the automatic temporary write grants.
|
||||
*/
|
||||
export function outsideTempWorkspaceParent(tempRoot = tmpdir(), home = homedir()): string {
|
||||
const temporary = canonicalPath(tempRoot)
|
||||
const systemTemporary = canonicalPath('/tmp')
|
||||
const parent = dirname(temporary)
|
||||
return temporary === systemTemporary || parent === parse(parent).root || contains(systemTemporary, parent)
|
||||
? home
|
||||
: parent
|
||||
if (temporary === systemTemporary || parent === parse(parent).root || contains(systemTemporary, parent)) return home
|
||||
try {
|
||||
accessSync(parent, constants.W_OK)
|
||||
} catch (error) {
|
||||
// A non-writable parent cannot host siblings; allocation failures still propagate from mkdtemp.
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') return home
|
||||
throw error
|
||||
}
|
||||
return parent
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -47,6 +47,12 @@ function assertEarlyNpmCacheExport(steps: Step[]): void {
|
||||
expect(steps[cacheIndex]?.if).toBeUndefined()
|
||||
}
|
||||
|
||||
function assertRunnerPrivateStore(run: string | undefined): void {
|
||||
expect(run).toContain('store_root="${RUNNER_TEMP%/*}/pnpm-store"')
|
||||
expect(run).toContain('echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"')
|
||||
expect(run).toContain('store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)')
|
||||
}
|
||||
|
||||
const trustedPr = {
|
||||
'vars.DSH_CI_FAILOVER_LINUX': 'selfhosted',
|
||||
'github.repository': repository,
|
||||
@@ -141,8 +147,15 @@ for (const [file, jobIds] of [['release.yml', ['dependencies', 'pack']], ['relea
|
||||
steps.push({ run: npmCacheExport })
|
||||
expect(() => { assertEarlyNpmCacheExport(steps) }).toThrow()
|
||||
})
|
||||
it('uses the persistent store without remote cache reads or writes on self-hosted', () => {
|
||||
expect(job.steps.find(step => step.name === 'Configure pnpm store path')?.run).toContain('store_root="$HOME/.local/share/pnpm/store"')
|
||||
it.each(['', 'store_root="$HOME/.local/share/pnpm/store"', 'store_root="$RUNNER_TEMP/pnpm-store"'])(
|
||||
'rejects missing, shared-home, or job-temporary store placement: %s', (replacement) => {
|
||||
const run = job.steps.find(step => step.name === 'Configure pnpm store path')?.run
|
||||
?.replace('store_root="${RUNNER_TEMP%/*}/pnpm-store"', replacement)
|
||||
expect(() => { assertRunnerPrivateStore(run) }).toThrow()
|
||||
},
|
||||
)
|
||||
it('uses a runner-private persistent store without remote cache reads or writes on self-hosted', () => {
|
||||
assertRunnerPrivateStore(job.steps.find(step => step.name === 'Configure pnpm store path')?.run)
|
||||
const caches = job.steps.filter(step => step.uses?.startsWith('actions/cache'))
|
||||
expect(caches.map(step => step.uses)).toEqual(['actions/cache/restore@v4'])
|
||||
for (const step of caches) {
|
||||
|
||||
Reference in new issue
Block a user