Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/workflows/dependencies.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: Dependencies

on:
push:
branches: ['beta']
paths:
- 'package-lock.json'
- 'package.json'
- '.nvmrc'
- 'scripts/dependency-snapshot.mjs'
- '.github/workflows/dependencies.yml'
workflow_dispatch:

permissions: {}

concurrency:
group: '${{ github.workflow }}-${{ github.ref }}'
cancel-in-progress: false

jobs:
snapshot:
if: github.ref == 'refs/heads/beta'
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: write # Submit the repository dependency snapshot through the API.
steps:
- uses: actions/checkout@v6.1.0
with:
# Queued runs refresh the latest lockfile, regardless of push order.
ref: 'beta'
persist-credentials: false
- uses: actions/setup-node@v6.5.0
with:
node-version-file: '.nvmrc'
package-manager-cache: false
- name: Generate the locked dependency snapshot without installing packages
run: |
npm sbom --package-lock-only --sbom-format=cyclonedx --include=dev --include=optional --include=peer > "$RUNNER_TEMP/dependency-bom.json"
SNAPSHOT_SHA="$(git rev-parse HEAD)" node scripts/dependency-snapshot.mjs "$RUNNER_TEMP/dependency-bom.json" > "$RUNNER_TEMP/dependency-snapshot.json"
- name: Refresh the dependency graph through the API
env:
GH_TOKEN: '${{ github.token }}'
run: |
gh api --method POST --header 'X-GitHub-Api-Version: 2026-03-10' "repos/$GITHUB_REPOSITORY/dependency-graph/snapshots" --input "$RUNNER_TEMP/dependency-snapshot.json"
5 changes: 5 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ job; fork PRs retain the local gate and reports but cannot upload. The Linux
Node 24 job also checks real Secret Service storage with dummy tokens in an
isolated D-Bus session and temporary keyring, never a developer credential store.

The [dependency snapshot workflow](.github/workflows/dependencies.yml) refreshes
GitHub's dependency graph through its API when the lockfile or snapshot tooling
changes on `beta`. It uses npm's lockfile-only SBOM without installing packages
and replaces the existing snapshot. Dispatch it on `beta` to retry a failed refresh.

Use normal npm lifecycle behavior locally and in CI. Review install hooks,
including implicit native builds, when changing dependencies; record any
required setup in [the dependency review](docs/dependencies.md). Suppressing
Expand Down
42 changes: 42 additions & 0 deletions scripts/dependency-snapshot.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { pathToFileURL } from 'node:url'

export function createSnapshot (bom, env) {
assert.equal(env.GITHUB_REF, 'refs/heads/beta')
assert.match(env.SNAPSHOT_SHA, /^[a-f0-9]{40}$/)
const components = new Map(bom.components.map(component => [component['bom-ref'], component]))
const dependencies = new Map(bom.dependencies.map(entry => [entry.ref, entry.dependsOn ?? []]))
const root = bom.metadata.component['bom-ref']
assert.ok(dependencies.has(root), 'Dependency snapshot is missing the root dependency list')
const direct = new Set(dependencies.get(root))
const packageUrl = ref => {
const url = components.get(ref)?.purl
assert.ok(url?.startsWith('pkg:npm/'), 'Dependency snapshot contains an unresolved npm package')
return url
}
for (const ref of direct) packageUrl(ref)
const resolved = Object.fromEntries([...components].map(([ref, component]) => [packageUrl(ref), {
package_url: packageUrl(ref),
relationship: direct.has(ref) ? 'direct' : 'indirect',
scope: component.properties?.some(property => property.name === 'cdx:npm:package:development' && property.value === 'true') ? 'development' : 'runtime',
dependencies: (dependencies.get(ref) ?? []).map(packageUrl)
}]))
return {
version: 0,
sha: env.SNAPSHOT_SHA,
ref: env.GITHUB_REF,
job: {
id: `${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}`,
correlator: 'resenv-beta-lockfile',
html_url: `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}`
},
detector: { name: 'npm-sbom-api', version: bom.metadata.tools.find(tool => tool.vendor === 'npm').version, url: 'https://docs.npmjs.com/cli/v11/commands/npm-sbom' },
scanned: bom.metadata.timestamp,
manifests: { 'package-lock.json': { name: 'package-lock.json', file: { source_location: 'package-lock.json' }, resolved } }
}
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
console.log(JSON.stringify(createSnapshot(JSON.parse(readFileSync(process.argv[2], 'utf8')), process.env)))
}
41 changes: 41 additions & 0 deletions scripts/dependency-snapshot.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { expect, it } from 'vitest'
import { createSnapshot } from './dependency-snapshot.mjs'

const env = {
SNAPSHOT_SHA: 'a'.repeat(40), GITHUB_SHA: 'b'.repeat(40), GITHUB_REF: 'refs/heads/beta',
GITHUB_RUN_ID: '123', GITHUB_RUN_ATTEMPT: '2', GITHUB_SERVER_URL: 'https://github.com', GITHUB_REPOSITORY: 'ciqol/resenv'
}
const bom = {
metadata: { component: { 'bom-ref': 'root' }, timestamp: '2026-10-13T00:00:00Z', tools: [{ vendor: 'npm', version: '11.17.0' }] },
components: [
{ 'bom-ref': 'dev', purl: 'pkg:npm/%40example/tool@1.0.0', properties: [{ name: 'cdx:npm:package:development', value: 'true' }] },
{ 'bom-ref': 'runtime', purl: 'pkg:npm/runtime@2.0.0' },
{ 'bom-ref': 'child', purl: 'pkg:npm/child@3.0.0', properties: [{ name: 'cdx:npm:package:development', value: 'true' }] }
],
dependencies: [{ ref: 'root', dependsOn: ['dev', 'runtime'] }, { ref: 'dev', dependsOn: ['child'] }]
}

it('preserves scopes and dependency edges while replacing the existing beta snapshot', () => {
const snapshot = createSnapshot(bom, env)
expect(snapshot.sha).toBe(env.SNAPSHOT_SHA)
expect(snapshot.job).toEqual({ id: '123-2', correlator: 'resenv-beta-lockfile', html_url: 'https://github.com/ciqol/resenv/actions/runs/123' })
expect(snapshot.detector.name).toBe('npm-sbom-api')
expect(snapshot.manifests['package-lock.json'].resolved).toEqual({
'pkg:npm/%40example/tool@1.0.0': { package_url: 'pkg:npm/%40example/tool@1.0.0', relationship: 'direct', scope: 'development', dependencies: ['pkg:npm/child@3.0.0'] },
'pkg:npm/runtime@2.0.0': { package_url: 'pkg:npm/runtime@2.0.0', relationship: 'direct', scope: 'runtime', dependencies: [] },
'pkg:npm/child@3.0.0': { package_url: 'pkg:npm/child@3.0.0', relationship: 'indirect', scope: 'development', dependencies: [] }
})
})

it('emits an empty manifest when all dependencies have been removed', () => {
const snapshot = createSnapshot({ ...bom, components: [], dependencies: [{ ref: 'root' }] }, env)
expect(snapshot.manifests['package-lock.json'].resolved).toEqual({})
})

it('fails on unresolved dependency edges instead of submitting an incomplete graph', () => {
expect(() => createSnapshot({ ...bom, dependencies: [...bom.dependencies, { ref: 'dev', dependsOn: ['missing'] }] }, env)).toThrow('unresolved npm package')
})

it('rejects snapshots outside the beta branch', () => {
expect(() => createSnapshot(bom, { ...env, GITHUB_REF: 'refs/pull/2/merge' })).toThrow()
})
Loading