diff --git a/.github/workflows/dependencies.yml b/.github/workflows/dependencies.yml new file mode 100644 index 0000000..15257c8 --- /dev/null +++ b/.github/workflows/dependencies.yml @@ -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" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e94c6a6..7364b15 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/scripts/dependency-snapshot.mjs b/scripts/dependency-snapshot.mjs new file mode 100644 index 0000000..97acd12 --- /dev/null +++ b/scripts/dependency-snapshot.mjs @@ -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))) +} diff --git a/scripts/dependency-snapshot.spec.mjs b/scripts/dependency-snapshot.spec.mjs new file mode 100644 index 0000000..38b09b3 --- /dev/null +++ b/scripts/dependency-snapshot.spec.mjs @@ -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() +})