ci: Add nightly SBOM license validation (no-changelog) (#38077)

This commit is contained in:
Declan Carroll
2026-09-08 14:30:36 +00:00
committed by GitHub
parent 1ad0b44a68
commit 28061c08e3
8 changed files with 466 additions and 32 deletions
+14
View File
@@ -370,6 +370,9 @@ release-publish.yml
│ └──────────▶ security-trivy-scan-callable.yml
└──────────────────────────▶ sbom-generation-callable.yml
test-sbom-nightly.yml
└──────────────────────────▶ sbom-validation-callable.yml
test-workflows-nightly.yml (manual dispatch only — nightly schedule disabled, DEVP-544)
└──────────────────────────▶ test-workflows-callable.yml
@@ -522,6 +525,7 @@ Push to master/1.x
| Daily 01:30, 02:30, 03:30 | `test-benchmark-nightly.yml` | Performance benchmarks |
| Daily 02:00 | `test-get-n8n.yml` | get.n8n.io installer health |
| Daily 02:00 | `test-e2e-pc-nightly.yml` | E2E on the `-pc` image |
| Daily 04:00 | `test-sbom-nightly.yml` | Release and image SBOM license validation |
| Daily 05:00 | `test-benchmark-destroy-nightly.yml`| Cleanup benchmark env |
| Daily 06:00 | `util-sync-master-to-3x.yml` | Replay 3.x onto master (v3) |
| Daily 08:00 | `build-v3-nightly.yml` | Nightly v3 Docker images |
@@ -643,6 +647,7 @@ Workflows with `workflow_call` trigger:
| `sec-sync-retarget-prs.yml` | none | Move bundle PRs back onto `bundle/*` |
| `security-trivy-scan-callable.yml` | `image_ref` | Trivy scan |
| `sbom-generation-callable.yml` | `n8n_version`, `release_tag_ref` | SBOM generation |
| `sbom-validation-callable.yml` | `sha` | Read-only SBOM validation |
| `test-single-instance-npm.yml` | `scope`, `base-ref`, `base-branch`, `blocking`, `timeout-minutes` | Dependency duplication |
---
@@ -670,6 +675,7 @@ Scripts in `.github/scripts/`:
| `docker/kafka-native-smoke-check.mjs`| Verify librdkafka binary loads in built image | `docker-build-smoke.yml`|
| `docker/assert-manifest-format.mjs`| Assert a merged manifest is an OCI image index with the expected platforms | `docker-build-push.yml`|
| `docker/should-smoke-build.mjs`| Narrow the `pnpm-workspace.yaml` smoke trigger to native dependency pins | `docker-build-smoke.yml`|
| `attest-image-sbom.mjs` | Generate, validate, and optionally attest image SBOMs | `docker-build-push.yml`, `test-sbom-nightly.yml` |
### Validation Scripts
@@ -678,6 +684,7 @@ Scripts in `.github/scripts/`:
| `validate-docs-links.js`| Check doc URLs | `util-check-docs-urls.yml`|
| `send-build-stats.mjs` | Build telemetry | `setup-nodejs` action |
| `resolve-pnpm-version.mjs` | Publish the pinned pnpm version and its executable cache key | `setup-nodejs` action |
| `nightly-sbom-context.mjs` | Resolve the source SHA and image tag for nightly SBOM validation | `test-sbom-nightly.yml` |
| `db-test-matrix.mjs` | DB test matrix from `postgres-versions.json` | `ci-pull-requests.yml` |
| `quality/check-cubic-config.mjs` | Validate `cubic.yaml` against the vendored cubic schema; enforce its silent agent/character limits. `--refresh` re-pulls the schema | `test-workflow-scripts-reusable.yml`, `util-refresh-cubic-schema.yml` |
| `probe-registry.mjs` | Registry path throughput probe (temporary) | `util-probe-registry.yml` |
@@ -910,6 +917,13 @@ Packages whose license cannot be resolved from disk go in
`scripts/licenses/license-overrides.json` with a verified `source` citation — the upstream
LICENSE file, not registry metadata.
`test-sbom-nightly.yml` runs at 04:00 UTC. It waits up to two hours for the current scheduled
Docker build to complete. It builds the production deployment closure at that run's SHA and
validates the release SBOM. It also resolves the four immutable SHA image tags from that
build and validates each image SBOM. The validation uses the same enrichment and SPDX gates
as a release. It does not publish, attest, or upload an artifact. A failure reports to the
Developer Platform Slack channel.
### SLSA L3 Provenance
SLSA (Supply-chain Levels for Software Artifacts) Level 3 provides cryptographic proof of build integrity.
+46 -27
View File
@@ -8,7 +8,8 @@
* Image refs + digests come from the environment (set by docker-build-push.yml).
* An image with no digest (not built for this release type) is skipped.
*
* Usage: node .github/scripts/attest-image-sbom.mjs (run from the repo root)
* Usage: node .github/scripts/attest-image-sbom.mjs [--validate-only]
* (run from the repo root)
*/
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
@@ -62,7 +63,10 @@ export function assertSbomIsUsable(sbomPath, label) {
}
}
function attest({ label, image, digest }) {
export function processTarget(
{ label, image, digest },
{ shouldAttest = true, runCommand = run, assertUsable = assertSbomIsUsable } = {},
) {
const ref = `${image}@${digest}`;
const out = path.join(REPO_ROOT, `sbom-${label}.cdx.json`);
console.log(`::group::SBOM for ${label} (${ref})`);
@@ -71,14 +75,14 @@ function attest({ label, image, digest }) {
// names the failing image renders inside a collapsed section.
try {
// Pull the (host-arch) image and scan its filesystem: OS packages + npm.
run('docker', ['pull', ref]);
runCommand('docker', ['pull', ref]);
// `docker:` pins the scan to the image just pulled. A bare ref lets syft's
// own provider order decide, and it may resolve the multi-arch index from
// the registry instead — describing a different manifest than the one
// cosign then attests to.
// syft reads licenses from the LICENSE files on disk, so this scan makes no
// registry requests. `-file` excludes its per-file catalogue, ~4000 entries.
run('syft', [
runCommand('syft', [
`docker:${ref}`,
'-o',
`cyclonedx-json@1.6=${out}`,
@@ -90,52 +94,67 @@ function attest({ label, image, digest }) {
// Resolve first-party + override licenses (lenient: this image holds only a
// subset of the npm closure, so absent overrides are not stale pins) and drop
// scanner filesystem phantoms.
run(process.execPath, [ENRICH, out, '--lenient-config', '--drop-phantom-npm']);
runCommand(process.execPath, [ENRICH, out, '--lenient-config', '--drop-phantom-npm']);
// Release-blocking gate, scoped to npm — OS packages carry upstream-distro
// license strings we don't control, so they're inventoried but not gated.
run(process.execPath, [CHECK, out, ...ALLOW_REFS, '--enforce-prefix=pkg:npm/']);
assertSbomIsUsable(out, label);
runCommand(process.execPath, [CHECK, out, ...ALLOW_REFS, '--enforce-prefix=pkg:npm/']);
assertUsable(out, label);
// --replace, so re-running after a mid-loop failure does not leave the
// digest carrying two CycloneDX attestations.
run('cosign', [
'attest',
'--yes',
'--replace',
'--type',
'cyclonedx',
'--predicate',
out,
ref,
]);
if (shouldAttest) {
runCommand('cosign', [
'attest',
'--yes',
'--replace',
'--type',
'cyclonedx',
'--predicate',
out,
ref,
]);
}
} finally {
console.log('::endgroup::');
}
}
function main() {
const targets = parseTargets(process.env);
if (targets.length === 0) {
console.log('No images with digests to attest — skipping.');
return;
}
export function processTargets(targets, options = {}) {
const action = options.shouldAttest === false ? 'validation' : 'attestation';
const process = options.processTarget ?? processTarget;
// Attempt every image, then report. Aborting on the first failure leaves the
// later images silently unattested and hides whether they would have passed.
// later images silently unvalidated and hides whether they would have passed.
const failed = [];
for (const target of targets) {
try {
attest(target);
process(target, options);
} catch (err) {
failed.push(`${target.label}: ${err.message}`);
console.log(`::error title=SBOM attestation::${target.label}: ${err.message}`);
console.log(`::error title=SBOM ${action}::${target.label}: ${err.message}`);
}
}
if (failed.length > 0) {
throw new Error(`${failed.length} of ${targets.length} image(s) failed:\n ${failed.join('\n ')}`);
throw new Error(
`${failed.length} of ${targets.length} image(s) failed:\n ${failed.join('\n ')}`,
);
}
}
export function main({
env = process.env,
args = process.argv.slice(2),
processAll = processTargets,
} = {}) {
const shouldAttest = !args.includes('--validate-only');
const targets = parseTargets(env);
if (targets.length === 0) {
console.log(`No images with digests to ${shouldAttest ? 'attest' : 'validate'} - skipping.`);
return;
}
processAll(targets, { shouldAttest });
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
main();
+93 -1
View File
@@ -3,7 +3,13 @@ import assert from 'node:assert/strict';
import { mkdtempSync, writeFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { assertSbomIsUsable, parseTargets } from './attest-image-sbom.mjs';
import {
assertSbomIsUsable,
main,
parseTargets,
processTarget,
processTargets,
} from './attest-image-sbom.mjs';
describe('parseTargets', () => {
it('builds a target per image when both ref and digest are present', () => {
@@ -82,3 +88,89 @@ describe('assertSbomIsUsable', () => {
});
});
});
describe('processTarget', () => {
const target = {
label: 'n8n-pc',
image: 'ghcr.io/n8n-io/n8n',
digest: 'sha256:aaa',
};
it('runs the complete validation chain without attesting in validation-only mode', () => {
const calls = [];
let asserted = false;
processTarget(target, {
shouldAttest: false,
runCommand: (command, args) => calls.push([command, args]),
assertUsable: (_sbomPath, label) => {
assert.equal(label, 'n8n-pc');
asserted = true;
},
});
assert.deepEqual(
calls.map(([command]) => path.basename(command)),
['docker', 'syft', 'node', 'node'],
);
assert.equal(calls[0][1][0], 'pull');
assert.equal(calls[1][1][0], 'docker:ghcr.io/n8n-io/n8n@sha256:aaa');
assert.ok(calls[2][1].includes('--drop-phantom-npm'));
assert.ok(calls[3][1].includes('--enforce-prefix=pkg:npm/'));
assert.equal(asserted, true);
});
it('attests after validation by default', () => {
const calls = [];
processTarget(target, {
runCommand: (command, args) => calls.push([command, args]),
assertUsable: () => {},
});
assert.equal(calls.at(-1)[0], 'cosign');
assert.deepEqual(calls.at(-1)[1].slice(0, 6), [
'attest',
'--yes',
'--replace',
'--type',
'cyclonedx',
'--predicate',
]);
});
});
describe('processTargets', () => {
it('validates every image before reporting aggregated failures', () => {
const attempted = [];
assert.throws(
() =>
processTargets([{ label: 'n8n' }, { label: 'n8n-pc' }, { label: 'runners' }], {
shouldAttest: false,
processTarget: (target) => {
attempted.push(target.label);
if (target.label !== 'n8n-pc') throw new Error('missing license');
},
}),
/2 of 3 image\(s\) failed/,
);
assert.deepEqual(attempted, ['n8n', 'n8n-pc', 'runners']);
});
});
describe('main', () => {
it('passes validation-only mode to image processing', () => {
let received;
main({
env: {
N8N_IMAGE: 'ghcr.io/n8n-io/n8n',
N8N_DIGEST: 'sha256:aaa',
},
args: ['--validate-only'],
processAll: (targets, options) => {
received = { targets, options };
},
});
assert.equal(received.targets[0].label, 'n8n');
assert.equal(received.options.shouldAttest, false);
});
});
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import { appendFileSync } from 'node:fs';
const MAX_AGE_MS = 6 * 60 * 60 * 1000;
const POLL_INTERVAL_MS = 5 * 60 * 1000;
const POLL_ATTEMPTS = 25;
function writeOutput(name, value) {
if (!process.env.GITHUB_OUTPUT) throw new Error('GITHUB_OUTPUT is not set.');
appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
}
function getLatestScheduledRun() {
const output = execFileSync(
'gh',
[
'run',
'list',
'--repo',
process.env.GITHUB_REPOSITORY,
'--workflow',
'docker-build-push.yml',
'--event',
'schedule',
'--limit',
'1',
'--json',
'conclusion,createdAt,headSha,status',
],
{ encoding: 'utf8' },
);
return JSON.parse(output)[0];
}
function isFullSha(value) {
return /^[0-9a-f]{40}$/.test(value ?? '');
}
async function resolveSource() {
if (process.env.GITHUB_EVENT_NAME === 'workflow_dispatch') {
if (!isFullSha(process.env.GITHUB_SHA)) throw new Error('GITHUB_SHA is not a full commit SHA.');
writeOutput('source_sha', process.env.GITHUB_SHA);
return;
}
for (let attempt = 1; attempt <= POLL_ATTEMPTS; attempt++) {
const run = getLatestScheduledRun();
const age = run?.createdAt ? Date.now() - Date.parse(run.createdAt) : MAX_AGE_MS + 1;
const isCurrent = age >= 0 && age <= MAX_AGE_MS;
if (
run?.status === 'completed' &&
run.conclusion === 'success' &&
isFullSha(run.headSha) &&
isCurrent
) {
writeOutput('source_sha', run.headSha);
return;
}
if (run?.status === 'completed' && run.createdAt && isCurrent) {
throw new Error(
`The current scheduled Docker build completed with conclusion '${run.conclusion}'.`,
);
}
if (attempt < POLL_ATTEMPTS) {
console.log(
`Waiting for the current scheduled Docker build (attempt ${attempt} of ${POLL_ATTEMPTS}).`,
);
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
}
throw new Error('No successful scheduled Docker build appeared within two hours.');
}
function resolveImageTag() {
if (process.env.GITHUB_EVENT_NAME === 'workflow_dispatch') {
writeOutput('image_tag', 'nightly');
return;
}
const shortSha = execFileSync('git', ['rev-parse', '--short=7', 'HEAD'], {
encoding: 'utf8',
}).trim();
writeOutput('image_tag', `nightly-${shortSha}`);
}
const command = process.argv[2];
try {
if (command === 'resolve-source') {
await resolveSource();
} else if (command === 'resolve-image-tag') {
resolveImageTag();
} else {
throw new Error('Expected resolve-source or resolve-image-tag.');
}
} catch (error) {
console.error(`::error::${error.message}`);
process.exit(1);
}
@@ -0,0 +1,53 @@
name: 'Test: Validate Release SBOM'
on:
workflow_call:
inputs:
sha:
description: 'Trusted full commit SHA to validate'
required: true
type: string
permissions:
contents: read
jobs:
validate-sbom:
name: Validate release SBOM
runs-on: blacksmith-4vcpu-ubuntu-2204
timeout-minutes: 15
steps:
- name: Checkout workflow source
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
# Poutine flags all runtime-selected checkouts. The only caller gets this
# value from GitHub's scheduled-run response or GITHUB_SHA, and this step
# independently rejects non-SHA values before fetch.
# poutine: untrusted_checkout_exec
- name: Checkout SBOM source
env:
SOURCE_SHA: ${{ inputs.sha }}
run: |
if [[ ! "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]]; then
echo '::error::The SBOM source must be a full commit SHA.'
exit 1
fi
git fetch --depth=1 origin "$SOURCE_SHA"
git checkout --detach "$SOURCE_SHA"
- name: Build production deployment artifact
uses: ./.github/actions/setup-nodejs
with:
build-command: 'pnpm build:deploy'
env:
N8N_GENERATE_LICENSES: 'true'
# build:deploy already runs this gate. Run it again against the final file
# so validation cannot report success without checking that artifact.
- name: Gate SBOM on resolved SPDX licenses
run: |
node scripts/licenses/check-sbom-licenses.mjs \
sbom-source.cdx.json \
--allow-ref=LicenseRef-n8n-sustainable-use --allow-ref=LicenseRef-n8n-enterprise
+142
View File
@@ -0,0 +1,142 @@
name: 'Test: Nightly SBOM Validation'
on:
schedule:
# Run after the 00:00 UTC nightly Docker build.
- cron: '0 4 * * *'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: nightly-sbom-validation
cancel-in-progress: false
jobs:
resolve-nightly-build:
name: Resolve nightly Docker build
if: github.repository == 'n8n-io/n8n'
runs-on: ubuntu-slim
timeout-minutes: 130
permissions:
actions: read
contents: read
outputs:
image_tag: ${{ steps.image-tag.outputs.image_tag }}
source_sha: ${{ steps.source.outputs.source_sha }}
steps:
- name: Checkout workflow source
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
persist-credentials: false
- name: Resolve source SHA
id: source
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node .github/scripts/nightly-sbom-context.mjs resolve-source
# Poutine flags all runtime-selected checkouts. This SHA comes only from
# GitHub's scheduled-run response or GITHUB_SHA, and the step rejects
# non-SHA values before fetch. This workflow has no pull_request trigger.
# poutine: untrusted_checkout_exec
- name: Checkout nightly source
env:
SOURCE_SHA: ${{ steps.source.outputs.source_sha }}
run: |
if [[ ! "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]]; then
echo '::error::The nightly source must be a full commit SHA.'
exit 1
fi
git fetch --depth=1 origin "$SOURCE_SHA"
git checkout --detach "$SOURCE_SHA"
- name: Select nightly image tag
id: image-tag
run: node .github/scripts/nightly-sbom-context.mjs resolve-image-tag
validate-release-sbom:
name: Validate release SBOM
needs: resolve-nightly-build
permissions:
contents: read
uses: ./.github/workflows/sbom-validation-callable.yml
with:
sha: ${{ needs.resolve-nightly-build.outputs.source_sha }}
validate-image-sboms:
name: Validate nightly image SBOMs
needs: resolve-nightly-build
runs-on: blacksmith-4vcpu-ubuntu-2204
timeout-minutes: 30
permissions:
contents: read
steps:
- name: Checkout
uses: useblacksmith/checkout@bcec731f1eb1367240608d1c889a75b96db6ec53 # v1.5.0
with:
fetch-depth: 1
persist-credentials: false
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
with:
build-command: ''
install-command: ''
cache-dependency-path: .github/scripts/pnpm-lock.yaml
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Install Syft
uses: anchore/sbom-action/download-syft@43a17d6e7add2b5535efe4dcae9952337c479a93 # v0.20.11
with:
syft-version: v1.38.2
- name: Resolve nightly image digests
id: digests
env:
N8N_TAG: ghcr.io/n8n-io/n8n:${{ needs.resolve-nightly-build.outputs.image_tag }}
N8N_PC_TAG: ghcr.io/n8n-io/n8n:${{ needs.resolve-nightly-build.outputs.image_tag }}-pc
RUNNERS_TAG: ghcr.io/n8n-io/runners:${{ needs.resolve-nightly-build.outputs.image_tag }}
DISTROLESS_TAG: ghcr.io/n8n-io/runners:${{ needs.resolve-nightly-build.outputs.image_tag }}-distroless
run: node .github/scripts/docker/get-manifest-digests.mjs
- name: Generate and validate image SBOMs
env:
N8N_IMAGE: ${{ steps.digests.outputs.n8n_image }}
N8N_DIGEST: ${{ steps.digests.outputs.n8n_digest }}
N8N_PC_IMAGE: ${{ steps.digests.outputs.n8n_pc_image }}
N8N_PC_DIGEST: ${{ steps.digests.outputs.n8n_pc_digest }}
RUNNERS_IMAGE: ${{ steps.digests.outputs.runners_image }}
RUNNERS_DIGEST: ${{ steps.digests.outputs.runners_digest }}
DISTROLESS_IMAGE: ${{ steps.digests.outputs.runners_distroless_image }}
DISTROLESS_DIGEST: ${{ steps.digests.outputs.runners_distroless_digest }}
run: node .github/scripts/attest-image-sbom.mjs --validate-only
notify-on-failure:
name: Notify on nightly SBOM validation failure
needs: [resolve-nightly-build, validate-release-sbom, validate-image-sboms]
if: |
always() &&
github.event_name == 'schedule' &&
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
runs-on: ubuntu-slim
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: .github/scripts/slack
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Notify Slack
env:
SLACK_TOKEN: ${{ secrets.QBOT_SLACK_TOKEN }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
node .github/scripts/slack/notify.mjs \
--channel C0B4GC7MNF3 \
--text "<${RUN_URL}|Nightly SBOM license validation failed>"
+2 -2
View File
@@ -563,8 +563,8 @@ try {
// scripts, so we don't carry a second isolated install.
//
// Default: skip. cdxgen + license rendering adds ~minutes to every build:deploy and
// is only needed for the release SBOM job. The release-publish workflow opts in by
// setting N8N_GENERATE_LICENSES=true; regular CI Docker prepare runs skip it.
// is only needed for release and nightly SBOM validation. Those workflows opt in
// with N8N_GENERATE_LICENSES=true; regular CI Docker prepare runs skip it.
if (generateLicenses) {
echo(chalk.yellow('INFO: Generating SBOM and rendering THIRD_PARTY_LICENSES.md...'));
try {
+11 -2
View File
@@ -39,10 +39,14 @@ pnpm build:deploy (N8N_GENERATE_LICENSES=true)
└─ gh release upload
```
The nightly validation builds the same production deployment closure and runs the same
enrichment and SPDX gate through `sbom-validation-callable.yml`. It has read-only
permissions and does not contain attestation or release upload steps.
### Docker image SBOM
Produced by the `sbom-attestation` job in `docker-build-push.yml` on
`stable`/`rc`/`nightly` builds.
Produced by the `sbom-attestation` job in `docker-build-push.yml` for release builds that
enable attestations.
```
docker push
@@ -52,6 +56,11 @@ docker push
└─ cosign attest → attested to image digest
```
The daily validation waits up to two hours for the current scheduled Docker build to
complete. It resolves that build's immutable SHA tags to digests. It runs the same Syft
scan, enrichment, and npm SPDX gate for all four images. It uses
`attest-image-sbom.mjs --validate-only`, so it does not run Cosign or write to the registry.
The two pipelines use different scanners deliberately. The release SBOM scans a
pnpm lockfile, which has no package files to read licenses from, so it queries
the npm registry (`FETCH_LICENSE=true`). The image SBOM scans a filesystem, so