mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
ci: Add nightly SBOM license validation (no-changelog) (#38077)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
@@ -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>"
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user