ci: check dependency vulnerabilities on PRs and stable releases (#31858)

* ci: audit dependency security without gating releases

* ci: audit stable release tags instead of running daily

* ci: isolate release audit data and fix integration checks

* ci: require exact audit inputs and ignore candidate archive attributes
This commit is contained in:
Neil
2026-10-22 00:43:07 -07:00
committed by GitHub
parent 213bc4684d
commit 2078db93ec
15 changed files with 773 additions and 3 deletions
@@ -0,0 +1,28 @@
name: Prepare release dependency audit data
description: Extract only manifests, lockfiles, and patches without candidate executable configuration
inputs:
ref:
required: true
description: Exact release ref to audit
runs:
using: composite
steps:
- shell: bash
env:
RELEASE_REF: ${{ inputs.ref }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
git -c credential.helper= -c 'credential.helper=!gh auth git-credential' fetch --no-tags --depth=1 origin "$RELEASE_REF"
mapfile -t files < <(git ls-tree -r FETCH_HEAD -- \
pnpm-lock.yaml pnpm-workspace.yaml mobile/pnpm-lock.yaml mobile/pnpm-workspace.yaml \
native config/patches mobile/patches | \
awk '$1 ~ /^100(644|755)$/ {sub(/^[^\t]*\t/, ""); print}' | \
rg '(^|/)(pnpm-lock.yaml|pnpm-workspace.yaml)$|^native/[^/]+/Cargo\.(toml|lock)$|^(config|mobile)/patches/.*\.patch$')
test "${#files[@]}" -gt 0
mkdir candidate
git archive --worktree-attributes FETCH_HEAD -- "${files[@]}" | tar -x -C candidate
for file in pnpm-lock.yaml pnpm-workspace.yaml mobile/pnpm-lock.yaml mobile/pnpm-workspace.yaml; do
test -f "candidate/$file"
test ! -L "candidate/$file"
done
@@ -22,6 +22,7 @@ jobs:
const superseded = context.payload.action === 'synchronize'
const targets = [
['pr-checks', 'pr.yml'],
['dependency-security', 'dependency-security.yml'],
['node-server', 'node-server-tests.yml'],
['ssh-windows-hosts', 'ssh-windows-hosts.yml'],
['ssh-hostile-hosts', 'ssh-hostile-hosts.yml'],
+112
View File
@@ -0,0 +1,112 @@
name: Dependency security
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review, converted_to_draft]
paths:
- '**/package.json'
- '**/pnpm-lock.yaml'
- '**/pnpm-workspace.yaml'
- '**/.npmrc'
- 'native/**/Cargo.toml'
- 'native/**/Cargo.lock'
- 'config/dependency-security-exceptions.json'
- 'config/cargo-deny.toml'
- 'config/scripts/*dependency-security*'
- 'config/scripts/pnpm-cli-invocation.mjs'
- 'config/pnpmfile.cjs'
- '**/patches/**'
- '.github/workflows/dependency-security.yml'
- '.github/workflows/release-cut.yml'
- '.github/actions/install-node-dependencies/**'
- '.github/actions/prepare-dependency-audit/**'
- 'src/packages/process-host/**'
workflow_call:
inputs:
ref:
description: Exact release tag to audit using the workflow commit's tooling
required: true
type: string
workflow_dispatch:
permissions:
contents: read
concurrency:
group: dependency-security-${{ inputs.ref && github.run_id || github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
ORCA_BACKGROUND_LAUNCH: '1'
jobs:
javascript:
if: github.event_name != 'pull_request' || github.event.pull_request.draft != true
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.workflow_sha }}
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
- uses: ./.github/actions/prepare-dependency-audit
if: inputs.ref != ''
with:
ref: ${{ inputs.ref }}
- name: Audit JavaScript lockfiles
shell: bash
env:
RELEASE_REF: ${{ inputs.ref }}
run: |
if [ -n "$RELEASE_REF" ]; then
pnpm check:dependencies candidate . mobile
else
pnpm check:dependencies
fi
- uses: actions/upload-artifact@v7
if: always()
with:
name: dependency-security-javascript
path: tmp/dependency-security/*.json
if-no-files-found: error
rust:
if: github.event_name != 'pull_request' || github.event.pull_request.draft != true
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.workflow_sha }}
persist-credentials: false
- uses: actions/cache@v5
id: scanner
with:
path: ~/.cargo/bin/cargo-deny
key: cargo-deny-0.20.2-${{ runner.os }}-${{ runner.arch }}
- if: steps.scanner.outputs.cache-hit != 'true'
run: cargo install cargo-deny --locked --version 0.20.2
- uses: ./.github/actions/prepare-dependency-audit
if: inputs.ref != ''
with:
ref: ${{ inputs.ref }}
- name: Audit native Rust lockfiles
shell: bash
working-directory: ${{ inputs.ref != '' && 'candidate' || '.' }}
run: |
set -euo pipefail
report_dir="$GITHUB_WORKSPACE/tmp/dependency-security"
mkdir -p "$report_dir"
status=0
for manifest in native/*/Cargo.toml; do
name="$(basename "$(dirname "$manifest")")"
cargo deny --manifest-path "$manifest" --locked --config "$GITHUB_WORKSPACE/config/cargo-deny.toml" check advisories 2>&1 | tee "$report_dir/$name.log" || status=1
done
exit "$status"
- uses: actions/upload-artifact@v7
if: always()
with:
name: dependency-security-rust
path: tmp/dependency-security/*.log
if-no-files-found: error
+9
View File
@@ -852,6 +852,13 @@ jobs:
TAG: ${{ needs.cut.outputs.tag }}
run: node config/scripts/create-draft-release.mjs "$TAG"
dependency-security:
needs: cut
if: needs.cut.outputs.should_release == 'true' && !contains(needs.cut.outputs.tag, '-')
uses: ./.github/workflows/dependency-security.yml
with:
ref: refs/tags/${{ needs.cut.outputs.tag }}
terminal-rendering-golden:
needs: cut
if: needs.cut.outputs.should_release == 'true'
@@ -2436,6 +2443,7 @@ jobs:
publish-release:
needs:
- cut
- dependency-security
- build
- build-mac
- verify-mac-build
@@ -2446,6 +2454,7 @@ jobs:
if: >-
!cancelled() &&
needs.cut.result == 'success' &&
(needs.dependency-security.result == 'success' || contains(needs.cut.outputs.tag, '-')) &&
needs.build.result == 'success' &&
needs.build-mac.result == 'success' &&
needs.verify-mac-build.result == 'success' &&
+5
View File
@@ -0,0 +1,5 @@
[graph]
all-features = true
[advisories]
ignore = []
@@ -0,0 +1,54 @@
[
{
"workspace": "mobile",
"package": "braces",
"version": "3.0.3",
"advisory": "GHSA-vfj7-8cjw-p6xm",
"owner": "Orca maintainers",
"reason": "Parser and AST walkers reject excessive nesting via the patch merged in #31681, covered by dependency-security-patches.test.mjs. Remove when a fixed upstream release is available.",
"expires": "2026-11-20",
"patch": {
"path": "patches/braces@3.0.3.patch",
"sha256": "c07bb14cec04bc891e1fe529720444cc9729a25117f9aaf24253cfe3132a34fd"
}
},
{
"workspace": "docs/site",
"package": "braces",
"version": "3.0.3",
"advisory": "GHSA-vfj7-8cjw-p6xm",
"owner": "Orca maintainers",
"reason": "Parser and AST walkers reject excessive nesting via the patch merged in #31681, covered by dependency-security-patches.test.mjs. Remove when a fixed upstream release is available.",
"expires": "2026-11-20",
"patch": {
"path": "../../config/patches/braces@3.0.3.patch",
"sha256": "c07bb14cec04bc891e1fe529720444cc9729a25117f9aaf24253cfe3132a34fd"
}
},
{
"workspace": "config/audit-tools",
"package": "braces",
"version": "3.0.3",
"advisory": "GHSA-vfj7-8cjw-p6xm",
"owner": "Orca maintainers",
"reason": "Parser and AST walkers reject excessive nesting via the patch merged in #31681, covered by dependency-security-patches.test.mjs. Remove when a fixed upstream release is available.",
"expires": "2026-11-20",
"patch": {
"path": "../patches/braces@3.0.3.patch",
"sha256": "c07bb14cec04bc891e1fe529720444cc9729a25117f9aaf24253cfe3132a34fd"
}
},
{
"workspace": "mobile",
"package": "node-forge",
"version": "1.4.0",
"advisory": "GHSA-86w9-cpqp-85rv",
"owner": "Orca maintainers",
"reason": "RSA DigestAlgorithm rejects extra nested elements via the patch merged in #31681, covered by mobile/scripts/dependency-security-patches.test.ts. Remove when a fixed upstream release is available.",
"expires": "2026-11-20",
"patch": {
"path": "patches/node-forge@1.4.0.patch",
"sha256": "2798686e4488058ad81c3b8e32eb763c0feff21f4c717ce4d13b6ef2432cd5b8"
}
}
]
+42
View File
@@ -0,0 +1,42 @@
# Dependency security checks
`pnpm check:dependencies` audits the desktop, mobile, cloud, docs, and isolated
audit-tool lockfiles, including development and optional dependencies. It does not
install those workspaces or execute their pnpm hooks. High/critical advisories and
scanner failures fail the check; lower severities remain visible in JSON reports
under `tmp/dependency-security/`.
The Linux workflow runs on dependency-change PRs, manually, and when cutting a
stable desktop release. There is no daily schedule. The release check scans the
exact release tag in parallel with builds and must pass before the release is
published; RC releases skip it. Stable releases audit the desktop and embedded
mobile-web lockfiles plus native Rust. Cloud, docs, and isolated audit tooling
remain covered by dependency PR checks and do not block desktop releases.
Scanner tooling and exception policy come from the workflow commit, while
lockfiles, patch registrations, and patch bytes come from the release tag. Only candidate Rust manifests, lockfiles, and patches are extracted; candidate
package manifests, pnpm/Cargo configuration, and toolchain overrides are excluded. The candidate is
not installed and its pnpm hooks do not run. Branch protection is unchanged.
The Rust job runs pinned cargo-deny 0.20.2 directly against every native manifest
with `--locked`, all features, and no target restriction. The scanner binary is
cached; advisory data is fetched on every run. Reports upload even on failure.
The first CI run took 44 seconds for JavaScript and 2m04s for Rust, including a
cold scanner installation; the audit steps themselves took 2 and 1 seconds.
The only JavaScript exceptions cover previously reviewed local patches. Each
names an exact workspace, package, version, advisory, owner, expiry, patch path,
and SHA-256. An exception is considered only for an actual high/critical finding.
The patch bytes, workspace registration, and locked patch hash/snapshots must
match before the exception applies. Expired exceptions stop exempting findings;
stale exceptions for removed/fixed packages do not fail clean scans. Remove an
exception and its patch when an upstream fixed version replaces them.
This initial check covers JavaScript and native Rust advisories. It does not
establish reachability or scan licenses, container layers, downloaded binaries,
CocoaPods, Gradle, or Ruby dependencies. Registry/advisory outages fail the check
and block stable release publication until a successful rerun. After installing
cargo-deny, the Rust command can also be run locally:
```sh
cargo deny --manifest-path native/windows-agent-hook/Cargo.toml --locked --config config/cargo-deny.toml check advisories
```
@@ -0,0 +1,87 @@
import { runProcessSync } from '@orca/process-host'
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import {
AUDIT_WORKSPACES,
evaluateSecurityAudit,
securityExceptions
} from './dependency-security-audit.mjs'
import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs'
const ROOT = resolve(import.meta.dirname, '../..')
export function readSecurityAudit(result) {
if (
result.signal ||
result.timedOut ||
result.outputTruncated ||
(result.code !== 0 && result.code !== 1)
) {
throw new Error(`Dependency audit failed (${result.code}): ${result.stderr || result.stdout}`)
}
const report = JSON.parse(result.stdout)
if (result.code === 1 && Object.keys(report.advisories ?? {}).length === 0) {
throw new Error('Audit failed without a vulnerability report')
}
return report
}
export function checkDependencySecurity(root = ROOT, workspaces = AUDIT_WORKSPACES) {
const exceptions = securityExceptions.parse(
JSON.parse(readFileSync(join(ROOT, 'config/dependency-security-exceptions.json'), 'utf8'))
)
const reportDir = join(ROOT, 'tmp/dependency-security')
mkdirSync(reportDir, { recursive: true })
const { command, prefixArgs } = resolvePnpmCliInvocation()
const today = new Date().toISOString().slice(0, 10)
let passed = true
for (const workspace of workspaces) {
const report = { workspace, checkedAt: new Date().toISOString() }
try {
const result = runProcessSync({
program: command,
args: [
...prefixArgs,
'audit',
'--json',
'--ignore-pnpmfile',
'--registry=https://registry.npmjs.org'
],
cwd: join(root, workspace),
timeoutMs: 120_000,
maxOutputBytes: 32 * 1024 * 1024
})
report.audit = readSecurityAudit(result)
report.findings = evaluateSecurityAudit(report.audit, workspace, exceptions, { root, today })
for (const item of report.findings) {
console.log(
`${workspace}: ${item.exception ? 'PATCHED' : item.blocking ? 'BLOCKING' : 'FINDING'} ${item.package}@${item.version} ${item.severity} ${item.advisory} ${item.url}`
)
}
if (report.findings.some((item) => item.blocking)) {
passed = false
}
console.log(`${workspace}: ${report.findings.length} finding(s)`)
} catch (error) {
report.error = error.message
console.error(`${workspace}: ${error.message}`)
passed = false
}
const name = workspace === '.' ? 'desktop' : workspace.replaceAll('/', '-')
writeFileSync(join(reportDir, `${name}.json`), `${JSON.stringify(report, null, 2)}\n`)
}
console.log(`Dependency reports: ${reportDir}`)
return passed ? 0 : 1
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const workspaces = process.argv.slice(3)
if (workspaces.some((workspace) => !AUDIT_WORKSPACES.includes(workspace))) {
throw new Error('Unknown dependency audit workspace')
}
process.exitCode = checkDependencySecurity(
resolve(process.argv[2] ?? ROOT),
workspaces.length ? workspaces : AUDIT_WORKSPACES
)
}
@@ -75,6 +75,7 @@ it('looks up exact PR groups without branch or head-SHA inference', async () =>
await result
expect(request.mock.calls.map(([, args]) => args.concurrency_group_name)).toEqual([
'pr-checks-123',
'dependency-security-123',
'node-server-123',
'ssh-windows-hosts-123',
'ssh-hostile-hosts-123',
@@ -0,0 +1,107 @@
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { parse, parseAllDocuments } from 'yaml'
import { z } from 'zod'
export const AUDIT_WORKSPACES = ['.', 'mobile', 'cloud', 'docs/site', 'config/audit-tools']
const text = z.string().trim().min(1)
const severities = ['info', 'low', 'moderate', 'high', 'critical']
export const securityExceptions = z.array(
z.strictObject({
workspace: z.enum(AUDIT_WORKSPACES),
package: text,
version: text,
advisory: z.string().regex(/^GHSA-[-a-z0-9]+$/),
owner: text,
reason: text,
expires: z.iso.date(),
patch: z.strictObject({ path: text, sha256: z.string().regex(/^[a-f0-9]{64}$/) })
})
)
const auditSchema = z.object({
advisories: z.record(
text,
z.object({
module_name: text,
github_advisory_id: text,
severity: z.enum(severities),
url: z.url(),
findings: z.array(z.object({ version: text, paths: z.array(text).min(1) })).min(1)
})
),
metadata: z.object({
vulnerabilities: z.object(
Object.fromEntries(severities.map((level) => [level, z.int().min(0)]))
)
})
})
function verifyPatch(root, exception) {
const cwd = join(root, exception.workspace)
const specifier = `${exception.package}@${exception.version}`
const policy = parse(readFileSync(join(cwd, 'pnpm-workspace.yaml'), 'utf8'))
const documents = parseAllDocuments(readFileSync(join(cwd, 'pnpm-lock.yaml'), 'utf8'))
for (const document of documents) {
if (document.errors.length) {
throw document.errors[0]
}
}
const lock = documents
.map((document) => document.toJS())
.find((value) => value?.patchedDependencies?.[specifier])
const snapshots = Object.keys(lock?.snapshots ?? {}).filter(
(key) => key === specifier || key.startsWith(`${specifier}(`)
)
if (
policy.patchedDependencies?.[specifier] !== exception.patch.path ||
lock?.patchedDependencies?.[specifier] !== exception.patch.sha256 ||
snapshots.length === 0 ||
snapshots.some((key) => !key.includes(`(patch_hash=${exception.patch.sha256})`)) ||
createHash('sha256')
.update(readFileSync(join(cwd, exception.patch.path)))
.digest('hex') !== exception.patch.sha256
) {
throw new Error(`Reviewed patch is not locked: ${exception.workspace} ${specifier}`)
}
}
export function evaluateSecurityAudit(
value,
workspace,
exceptions,
{ root, today = new Date().toISOString().slice(0, 10) } = {}
) {
const audit = auditSchema.parse(value)
const count = Object.values(audit.metadata.vulnerabilities).reduce((sum, n) => sum + n, 0)
if (count !== Object.keys(audit.advisories).length) {
throw new Error('Incomplete vulnerability report')
}
const findings = []
for (const advisory of Object.values(audit.advisories)) {
for (const finding of advisory.findings) {
const item = {
package: advisory.module_name,
version: finding.version,
advisory: advisory.github_advisory_id,
severity: advisory.severity,
url: advisory.url,
blocking: ['high', 'critical'].includes(advisory.severity)
}
const exception = exceptions.find(
(entry) =>
entry.workspace === workspace &&
entry.package === item.package &&
entry.version === item.version &&
entry.advisory === item.advisory
)
if (item.blocking && exception && exception.expires > today) {
verifyPatch(root, exception)
item.exception = exception
item.blocking = false
}
findings.push(item)
}
}
return findings
}
@@ -0,0 +1,149 @@
import { createHash } from 'node:crypto'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it } from 'vitest'
import { stringify } from 'yaml'
import { evaluateSecurityAudit } from './dependency-security-audit.mjs'
import { readSecurityAudit } from './check-dependency-security.mjs'
const roots = []
afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })))
const today = '2026-10-22'
const patch = 'reviewed security fix\n'
const sha256 = createHash('sha256').update(patch).digest('hex')
const exception = {
workspace: '.',
package: 'example',
version: '1.2.3',
advisory: 'GHSA-1234-5678-9abc',
owner: 'Maintainer',
reason: 'Patched while awaiting upstream',
expires: '2026-11-20',
patch: { path: 'example.patch', sha256 }
}
function audit(severity = 'high', versions = ['1.2.3']) {
return {
advisories: versions.length
? {
123: {
module_name: 'example',
github_advisory_id: exception.advisory,
severity,
url: 'https://github.com/advisories/GHSA-1234-5678-9abc',
findings: versions.map((version) => ({ version, paths: ['.>build-tool>example'] }))
}
}
: {},
metadata: {
vulnerabilities: {
info: 0,
low: 0,
moderate: 0,
high: 0,
critical: 0,
[severity]: versions.length ? 1 : 0
}
}
}
}
function patchedWorkspace() {
const root = mkdtempSync(join(tmpdir(), 'orca-dependency-security-'))
roots.push(root)
writeFileSync(join(root, 'example.patch'), patch)
writeFileSync(
join(root, 'pnpm-workspace.yaml'),
stringify({
patchedDependencies: { 'example@1.2.3': 'example.patch' }
})
)
const lock = {
patchedDependencies: { 'example@1.2.3': sha256 },
snapshots: { [`example@1.2.3(patch_hash=${sha256})`]: {} }
}
const writeLock = () =>
writeFileSync(
join(root, 'pnpm-lock.yaml'),
`---\n${stringify({ lockfileVersion: '9.0', importers: {}, snapshots: {} })}---\n${stringify(lock)}`
)
writeLock()
return { root, lock, writeLock }
}
it.each(['high', 'critical'])('blocks %s advisories including build-time findings', (severity) => {
expect(evaluateSecurityAudit(audit(severity), '.', [], { today })).toEqual([
expect.objectContaining({ severity, blocking: true })
])
})
it('retains lower severities without blocking', () => {
expect(evaluateSecurityAudit(audit('moderate'), '.', [], { today })).toEqual([
expect.objectContaining({ severity: 'moderate', blocking: false })
])
})
it('applies an exception only to the exact patched finding', () => {
const { root } = patchedWorkspace()
const findings = evaluateSecurityAudit(audit('high', ['1.2.3', '2.0.0']), '.', [exception], {
root,
today
})
expect(findings.map((item) => item.blocking)).toEqual([false, true])
for (const change of [
{ workspace: 'mobile' },
{ package: 'other' },
{ advisory: 'GHSA-abcd-5678-9abc' }
]) {
expect(
evaluateSecurityAudit(audit(), '.', [{ ...exception, ...change }], { root, today })[0]
.blocking
).toBe(true)
}
})
it('lets expired and removed patch exceptions coexist with a clean graph', () => {
expect(
evaluateSecurityAudit(audit('high', []), '.', [exception], { today: '2026-11-20' })
).toEqual([])
expect(
evaluateSecurityAudit(audit(), '.', [exception], { today: '2026-11-20' })[0].blocking
).toBe(true)
})
it.each(['bytes', 'registration', 'lock hash', 'unpatched snapshot'])(
'refuses a changed %s',
(change) => {
const { root, lock, writeLock } = patchedWorkspace()
if (change === 'bytes') {
writeFileSync(join(root, 'example.patch'), 'different fix')
}
if (change === 'registration') {
writeFileSync(join(root, 'pnpm-workspace.yaml'), '{}')
}
if (change === 'lock hash') {
lock.patchedDependencies['example@1.2.3'] = '0'.repeat(64)
}
if (change === 'unpatched snapshot') {
lock.snapshots['example@1.2.3'] = {}
}
writeLock()
expect(() => evaluateSecurityAudit(audit(), '.', [exception], { root, today })).toThrow(
'Reviewed patch'
)
}
)
it('rejects incomplete reports instead of treating them as clean', () => {
expect(() => evaluateSecurityAudit({}, '.', [], { today })).toThrow()
const incomplete = audit()
incomplete.advisories = {}
expect(() => evaluateSecurityAudit(incomplete, '.', [], { today })).toThrow('Incomplete')
})
it.each([
{ code: 2 },
{ signal: 'SIGTERM' },
{ timedOut: true },
{ outputTruncated: true },
{ stdout: '{' },
{ code: 1, stdout: '{"error":"registry unavailable"}' }
])('fails closed on scanner errors: %j', (change) => {
expect(() => readSecurityAudit({ code: 0, stdout: JSON.stringify(audit()), ...change })).toThrow()
})
it('reads nonzero finding reports for policy evaluation', () => {
expect(readSecurityAudit({ code: 1, stdout: JSON.stringify(audit()) })).toEqual(audit())
})
@@ -0,0 +1,164 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { runProcess } from '@orca/process-host'
import { runInNewContext } from 'node:vm'
import { expect, it } from 'vitest'
import { parse } from 'yaml'
const release = parse(readFileSync('.github/workflows/release-cut.yml', 'utf8'))
const security = parse(readFileSync('.github/workflows/dependency-security.yml', 'utf8'))
const gate = release.jobs['dependency-security']
const publish = release.jobs['publish-release']
function evaluate(expression, tag, result) {
const needs = Object.fromEntries(publish.needs.map((job) => [job, { result: 'success' }]))
needs.cut.outputs = { tag, should_release: 'true' }
needs['dependency-security'].result = result
return runInNewContext(expression.replace(/needs\.([\w-]+)/g, 'needs["$1"]'), {
needs,
cancelled: () => false,
contains: (value, part) => value.includes(part)
})
}
it('blocks stable publication until its dependency scan succeeds while leaving RCs independent', () => {
for (const tag of ['v1.5.0', 'v2.0.1']) {
expect(evaluate(gate.if, tag, 'success')).toBe(true)
expect(evaluate(publish.if, tag, 'success')).toBe(true)
for (const result of ['failure', 'cancelled', 'skipped', '', undefined]) {
expect(evaluate(publish.if, tag, result)).toBe(false)
}
}
for (const tag of ['v1.5.0-rc.1', 'v1.5.0-rc.1.perf']) {
expect(evaluate(gate.if, tag, 'skipped')).toBe(false)
expect(evaluate(publish.if, tag, 'skipped')).toBe(true)
}
})
it('scans the release tag with read-only workflow tooling instead of installing candidate code', () => {
expect(gate.with.ref).toBe('refs/tags/${{ needs.cut.outputs.tag }}')
expect(gate.uses).toBe('./.github/workflows/dependency-security.yml')
expect(security.permissions).toEqual({ contents: 'read' })
expect(security.on.schedule).toBeUndefined()
for (const job of Object.values(security.jobs)) {
const checkouts = job.steps.filter((step) => step.uses === 'actions/checkout@v6')
expect(checkouts).toHaveLength(1)
expect(checkouts[0].with.ref).toBe('${{ github.workflow_sha }}')
const preparation = job.steps.find(
(step) => step.uses === './.github/actions/prepare-dependency-audit'
)
expect(preparation.with.ref).toBe('${{ inputs.ref }}')
expect(preparation.if).toBe("inputs.ref != ''")
for (const step of job.steps) {
if (step.uses?.startsWith('./')) {
expect(step['working-directory']).toBeUndefined()
expect(step.with?.['working-directory']).toBeUndefined()
}
}
}
})
it('extracts release data while excluding executable package and Cargo configuration', async () => {
const root = mkdtempSync(join(tmpdir(), 'release audit data '))
const source = join(root, 'source')
const tooling = join(root, 'tooling')
mkdirSync(source)
mkdirSync(tooling)
const data = [
'pnpm-lock.yaml',
'pnpm-workspace.yaml',
'mobile/pnpm-lock.yaml',
'mobile/pnpm-workspace.yaml',
'native/example/Cargo.toml',
'native/example/Cargo.lock',
'config/patches/example.patch',
'mobile/patches/example.patch'
]
const configuration = [
'package.json',
'mobile/package.json',
'.gitattributes',
'.npmrc',
'.pnpmfile.cjs',
'config/pnpmfile.cjs',
'mobile/.npmrc',
'mobile/.pnpmfile.cjs',
'.cargo/config.toml',
'native/.cargo/config.toml',
'native/example/.cargo/config.toml',
'native/example/rust-toolchain.toml',
'native/example/build.rs'
]
async function run(program, args, cwd, env = process.env) {
const result = await runProcess({ program, args, cwd, env })
expect(result.code, result.stderr).toBe(0)
}
try {
for (const file of [...data, ...configuration]) {
mkdirSync(dirname(join(source, file)), { recursive: true })
writeFileSync(
join(source, file),
file === '.gitattributes'
? '*.yaml export-ignore\n*.patch export-subst\n'
: `${file} contents`
)
}
await run('git', ['init', '--quiet'], source)
await run('git', ['add', '.'], source)
await run(
'git',
[
'-c',
'user.name=Test',
'-c',
'user.email=test@example.com',
'commit',
'--quiet',
'-m',
'fixture'
],
source
)
await run('git', ['init', '--quiet'], tooling)
await run('git', ['remote', 'add', 'origin', source], tooling)
const action = parse(
readFileSync('.github/actions/prepare-dependency-audit/action.yml', 'utf8')
)
await run('bash', ['-c', action.runs.steps[0].run], tooling, {
...process.env,
RELEASE_REF: 'HEAD'
})
for (const file of data) {
expect(readFileSync(join(tooling, 'candidate', file), 'utf8')).toBe(`${file} contents`)
}
for (const file of configuration) {
expect(existsSync(join(tooling, 'candidate', file)), file).toBe(false)
}
rmSync(join(tooling, 'candidate'), { recursive: true })
await run('git', ['rm', '--quiet', 'pnpm-workspace.yaml'], source)
await run(
'git',
[
'-c',
'user.name=Test',
'-c',
'user.email=test@example.com',
'commit',
'--quiet',
'-m',
'missing workspace'
],
source
)
const missing = await runProcess({
program: 'bash',
args: ['-c', action.runs.steps[0].run],
cwd: tooling,
env: { ...process.env, RELEASE_REF: 'HEAD' }
})
expect(missing.code).not.toBe(0)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
@@ -136,7 +136,8 @@ describe('Electron runtime package contract', () => {
const evaluate = (source, needs, cancelled) =>
runInNewContext(source.replace(/needs\.([a-z][a-z0-9-]*)/g, 'needs["$1"]'), {
needs,
cancelled: () => cancelled
cancelled: () => cancelled,
contains: (value, part) => value.includes(part)
})
const results = ['success', 'failure', 'skipped', 'cancelled']
for (const matrix of results) {
@@ -144,7 +145,8 @@ describe('Electron runtime package contract', () => {
for (const verification of results) {
for (const cancelled of [false, true]) {
const needs = {
cut: { result: 'success', outputs: { should_release: 'true' } },
cut: { result: 'success', outputs: { should_release: 'true', tag: 'v1.5.0' } },
'dependency-security': { result: 'success' },
build: { result: matrix },
'build-mac': { result: dispatch },
'verify-mac-build': { result: verification },
@@ -167,14 +169,16 @@ describe('Electron runtime package contract', () => {
}
for (const prerequisite of [
'cut',
'dependency-security',
'skill-sharing-linux-floor-release-gate',
'skill-sharing-release-gate',
'terminal-rendering-golden'
]) {
const needs = Object.fromEntries([
['cut', { result: 'success', outputs: { should_release: 'true' } }],
['cut', { result: 'success', outputs: { should_release: 'true', tag: 'v1.5.0' } }],
...[
'build',
'dependency-security',
'build-mac',
'verify-mac-build',
'skill-sharing-linux-floor-release-gate',
@@ -51,6 +51,12 @@ const EXPECTED_MATRIX = {
[`${RELEASE_WORKFLOW}#verify-mac-build`]: { actions: 'read', contents: 'read' },
[`${RELEASE_WORKFLOW}#create-release`]: { contents: 'write' },
[`${RELEASE_WORKFLOW}#cut`]: { contents: 'write' },
[`${RELEASE_WORKFLOW}#dependency-security`]: { contents: 'read' },
[`${RELEASE_WORKFLOW}#dependency-security -> .github/workflows/dependency-security.yml#javascript`]:
{ contents: 'read' },
[`${RELEASE_WORKFLOW}#dependency-security -> .github/workflows/dependency-security.yml#rust`]: {
contents: 'read'
},
[`${RELEASE_WORKFLOW}#docs-production-dispatch`]: { actions: 'write' },
[`${RELEASE_WORKFLOW}#homebrew-bump`]: { contents: 'read' },
[`${RELEASE_WORKFLOW}#homebrew-bump -> .github/workflows/homebrew-bump.yml#bump-cask`]: {
+1
View File
@@ -10,6 +10,7 @@
},
"main": "./out/main/index.js",
"scripts": {
"check:dependencies": "pnpm run build:packages && node config/scripts/check-dependency-security.mjs",
"install:release": "pnpm install --frozen-lockfile --cpu=current,x64,arm64",
"audit:perf": "oxlint --config config/oxlint-performance-audit.json --format json src",
"test:perf:contracts": "vitest run --config config/vitest.performance.config.ts",