Skip to content

Files to read before they run with your key

These are the files the CI guides tell you to copy into your own repository. Read each one before it runs with your API key: nothing here is a binary, and nothing here needs a password to read.

Uploading from CI or the API is part of the Maker plan and the ones above it. On the Free plan the Settings page shows no key form. Without a key you can still run the free SBOM check and upload a release by hand in the app.

action.yml

The vendored GitHub Action: declares the inputs (api-key, product-id, sbom-path, …) and runs upload.mjs. Goes to .github/actions/declarable/action.yml.

yaml
name: Declarable SBOM upload
description: Upload the build's SBOM to Declarable and fail the step if the open findings cross your thresholds.
author: Declarable
branding:
  icon: upload-cloud
  color: blue

inputs:
  api-key:
    description: A Declarable API key with the releases:write and products:read scopes. Pass it as ${{ secrets.DECLARABLE_API_KEY }}.
    required: true
  product-id:
    description: The product's id (it is in the product page's address).
    required: true
  sbom-path:
    description: One or more SBOM files or globs (*, ?, **), separated by newlines or spaces, such as build/spdx/*.spdx. Every match is sent in one release, each file once. A path with a space in it cannot be named.
    required: true
  keep-paths:
    description: 'Set "true" to send each file under its path inside the project (such as .pio/libdeps/esp32dev/ArduinoJson/library.json) instead of its base name. Needed only by a reader that takes the environment from the file''s path (PlatformIO today); the step stops if a file is outside the folder it runs in.'
    required: false
    default: "false"
  version:
    description: The release's version label. Re-running with a version that already exists records nothing new and goes on to check the findings.
    required: false
    default: ${{ github.sha }}
  api-base-url:
    description: Where Declarable runs. Change it only for a self-hosted or staging copy.
    required: false
    default: https://declarable.io
  replace:
    description: 'Set "true" to replace the release already under this version when the files differ. Without it, different files under an existing version are left alone.'
    required: false
    default: "false"
  released-at:
    description: 'Optional. The day the firmware shipped, as YYYY-MM-DD, read as a UTC date. Leave it empty to send no date (the build date is not the ship date). Declarable refuses a date in the future. If these exact files were already uploaded under this version, the date is ignored.'
    required: false
    default: ""
  commit:
    description: 'The commit the firmware was built from, stored with the release. Default: the commit that triggered the workflow. Set "none" to send no commit. At most 200 characters. If these exact files were already uploaded under this version, the commit is ignored.'
    required: false
    default: ${{ github.sha }}
  build-id:
    description: 'Optional. Your own label for the build (a CI run number, say), stored with the release. At most 200 characters. Ignored when these exact files were already uploaded under this version.'
    required: false
    default: ""
  image-sha256:
    description: 'Optional. The SHA-256 of the firmware image, 64 hex characters (a leading sha256: is dropped). Stored with the release; nothing checks it against a real image. Ignored when these exact files were already uploaded under this version.'
    required: false
    default: ""
  supported-until:
    description: 'Optional. The last day you will support this release, as YYYY-MM-DD. Ignored when these exact files were already uploaded under this version.'
    required: false
    default: ""
  fail-on-gap:
    description: "Fail the step at this gap severity or worse: blocker, major, minor or none. none never fails the step. Gaps are read from the product's current release; before the first release they are reported as not checked."
    required: false
    default: blocker
  fail-on-exploited:
    description: Fail the step when an open vulnerability is known to be exploited ("true" or "false").
    required: false
    default: "true"

outputs:
  release-id:
    description: The id of the release this step uploaded, or of the release already under this version. Empty if the upload failed.
    value: ${{ steps.upload.outputs.release-id }}
  release-url:
    description: The address of that release in Declarable.
    value: ${{ steps.upload.outputs.release-url }}
  component-count:
    description: How many components the release holds. Empty when the version already exists with different files, because Declarable does not send the count then.
    value: ${{ steps.upload.outputs.component-count }}
  recorded:
    description: '"true" when this step recorded a new or replaced release; "false" when the version already existed (same files, or different files left alone).'
    value: ${{ steps.upload.outputs.recorded }}

runs:
  using: composite
  steps:
    - name: Check that Node 18 or later is available
      shell: bash
      run: |
        if ! command -v node >/dev/null 2>&1; then
          echo "Declarable: this step needs Node 18 or later on PATH. In a container: job, add actions/setup-node before it, or upload from a second job that runs on ubuntu-latest." >&2
          exit 2
        fi
        if [ "$(node -p 'Number(process.versions.node.split(".")[0])')" -lt 18 ]; then
          echo "Declarable: this step needs Node 18 or later, and found $(node --version). Add actions/setup-node with node-version 18 or later before it, or upload from a second job that runs on ubuntu-latest." >&2
          exit 2
        fi
    - name: Upload the SBOM to Declarable
      id: upload
      shell: bash
      env:
        DECLARABLE_API_KEY: ${{ inputs.api-key }}
        DECLARABLE_PRODUCT_ID: ${{ inputs.product-id }}
        DECLARABLE_SBOM_PATH: ${{ inputs.sbom-path }}
        DECLARABLE_KEEP_PATHS: ${{ inputs.keep-paths }}
        DECLARABLE_VERSION: ${{ inputs.version }}
        DECLARABLE_API_BASE_URL: ${{ inputs.api-base-url }}
        DECLARABLE_REPLACE: ${{ inputs.replace }}
        DECLARABLE_RELEASED_AT: ${{ inputs.released-at }}
        DECLARABLE_COMMIT: ${{ inputs.commit }}
        DECLARABLE_BUILD_ID: ${{ inputs.build-id }}
        DECLARABLE_IMAGE_SHA256: ${{ inputs.image-sha256 }}
        DECLARABLE_SUPPORTED_UNTIL: ${{ inputs.supported-until }}
        DECLARABLE_FAIL_ON_GAP: ${{ inputs.fail-on-gap }}
        DECLARABLE_FAIL_ON_EXPLOITED: ${{ inputs.fail-on-exploited }}
      # In a container job the github.action_path expression is the host's path; the environment
      # variable is the mapped one (actions/runner#716; argument, no real runner run yet).
      # Docs read 2026-10-11: https://docs.github.com/en/actions/reference/workflows-and-actions/metadata-syntax
      run: node "$GITHUB_ACTION_PATH/upload.mjs"

Download action.yml

upload.mjs

Plain Node 18+, no dependency to install: uploads the SBOM and exits non-zero if your gap threshold is crossed. Goes to GitHub: .github/actions/declarable/upload.mjs. GitLab: ci/declarable/upload.mjs, the path DECLARABLE_UPLOAD_SCRIPT names in the GitLab example (another path works if you change that variable to match)..

javascript
// Uploads the build's SBOM file(s) to Declarable's /api/v1 and decides pass or fail.
// Plain Node 18+, global fetch, no dependency to install. Inputs arrive as DECLARABLE_* env vars
// (action.yml sets them). Exit 0 = uploaded and inside your thresholds, or uploaded with the
// findings not checked (said in words); 1 = a threshold was crossed; 2 = the upload itself did
// not work. A re-run of a version that already exists is not
// an upload failure: the same files again record nothing new, and the findings are checked as usual.
// "Checked" is the product read's `currentRelease.checked` for the release this job uploaded: if
// that release is not the product's current one, or has not been matched, nothing is checked.
import { appendFileSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { basename, isAbsolute, join, relative, resolve } from 'node:path';

const GAP_LEVELS = ['blocker', 'major', 'minor', 'none'];

/** Thrown by `stop()` to unwind to `main()`'s catch without running anything after it; the exit
 * code itself travels on `process.exitCode`, set before the throw. Keeps Windows from hitting
 * libuv's handle-closing assertion that a bare `process.exit()` after a `fetch` can trigger. */
class StopSignal extends Error {}

const env = (name, fallback = '') => (process.env[name] ?? fallback).trim();

const apiKey = env('DECLARABLE_API_KEY');
const productId = env('DECLARABLE_PRODUCT_ID');
const sbomPath = env('DECLARABLE_SBOM_PATH');
const version = env('DECLARABLE_VERSION') || env('GITHUB_SHA');
const baseUrl = env('DECLARABLE_API_BASE_URL', 'https://declarable.io').replace(/\/+$/, '');
const failOnGap = env('DECLARABLE_FAIL_ON_GAP', 'blocker').toLowerCase();
const replace = env('DECLARABLE_REPLACE', 'false').toLowerCase() === 'true';
const releasedAt = env('DECLARABLE_RELEASED_AT');
// The commit defaults to the CI system's own variable; "none" sends no commit.
const commitSetting = env('DECLARABLE_COMMIT') || env('GITHUB_SHA') || env('CI_COMMIT_SHA');
const commit = commitSetting.toLowerCase() === 'none' ? '' : commitSetting;
const buildId = env('DECLARABLE_BUILD_ID');
const imageSha256 = env('DECLARABLE_IMAGE_SHA256');
const supportedUntil = env('DECLARABLE_SUPPORTED_UNTIL');
// Off by default: each file goes under its base name, as every existing recipe expects. "true" sends
// each file under its path relative to the workspace, so a reader that takes the environment from
// the path (PlatformIO's .pio/libdeps/<env>/<Library>/library.json) can see it. A step sets it with
// action.yml's `keep-paths` input, mapped to this variable in its `env:` block.
const keepPaths = env('DECLARABLE_KEEP_PATHS', 'false').toLowerCase() === 'true';
const failOnExploited = env('DECLARABLE_FAIL_ON_EXPLOITED', 'true').toLowerCase() !== 'false';

/** Removes the key from any text before it is printed, even if a library echoed it back. */
function redact(text) {
  let out = String(text);
  if (apiKey) out = out.split(apiKey).join('***');
  return out.replace(/dk_[A-Za-z0-9]+_[A-Za-z0-9_-]+/g, 'dk_***');
}

/** Hands a value to later steps of the same job through the file GITHUB_OUTPUT names. Off GitHub it
 * does nothing. A value is one line: a newline in it would start a second output. */
function setOutput(name, value) {
  const file = env('GITHUB_OUTPUT');
  if (file) appendFileSync(file, `${name}=${String(value ?? '').replace(/[\r\n]+/g, ' ')}\n`);
}

/** Prints a GitHub workflow command (an annotation beside the check) and does nothing off GitHub, so
 * GitLab and a shell see no extra line. Source: GitHub Docs, "Workflow commands for GitHub Actions",
 * read 13 Oct 2026. `file` and `line` are optional and left out on purpose: a gap belongs to the
 * SBOM, not to a source line. A message escapes % CR LF; a property such as the title also escapes
 * : and ,. The text goes through redact() first, because a gap's title comes from the server. */
function annotate(level, title, message) {
  if (!env('GITHUB_ACTIONS')) return;
  const escape = (text) => redact(text).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
  const property = (text) => escape(text).replace(/:/g, '%3A').replace(/,/g, '%2C');
  console.log(`::${level} title=${property(title)}::${escape(message)}`);
}

function writeSummary(markdown) {
  const file = env('GITHUB_STEP_SUMMARY');
  if (file) appendFileSync(file, `${markdown}\n`);
  else console.log(markdown);
}

function stop(code, message) {
  console.error(redact(message));
  annotate('error', 'Declarable SBOM not uploaded', message);
  writeSummary(`## Declarable SBOM upload\n\n**Not uploaded.** ${redact(message)}\n`);
  process.exitCode = code;
  throw new StopSignal();
}

function readdirSafe(dir) {
  try {
    return readdirSync(dir, { withFileTypes: true });
  } catch {
    return [];
  }
}

/** `*` and `?` inside one path segment. */
function segmentMatcher(pattern) {
  const source = pattern
    .replace(/[.+^${}()|[\]\\]/g, '\\$&')
    .replace(/\*/g, '[^/]*')
    .replace(/\?/g, '[^/]');
  return new RegExp(`^${source}$`);
}

/** Walks `segments` under `dir`; a `**` segment matches any depth. */
function walk(dir, segments, found) {
  if (segments.length === 0) return;
  const [head, ...rest] = segments;
  if (head === '**') {
    walk(dir, rest, found);
    for (const entry of readdirSafe(dir)) {
      if (entry.isDirectory()) walk(join(dir, entry.name), segments, found);
    }
    return;
  }
  const matches = segmentMatcher(head);
  for (const entry of readdirSafe(dir)) {
    if (!matches.test(entry.name)) continue;
    const full = join(dir, entry.name);
    if (rest.length === 0) {
      if (entry.isFile()) found.add(full);
    } else if (entry.isDirectory()) {
      walk(full, rest, found);
    }
  }
}

/** The files a single path or a glob names, sorted. */
function expand(pattern) {
  const normal = pattern.replace(/\\/g, '/');
  if (!/[*?]/.test(normal)) {
    try {
      return statSync(normal).isFile() ? [resolve(normal)] : [];
    } catch {
      return [];
    }
  }
  const parts = normal.split('/').filter((p) => p !== '');
  let root = '.';
  if (normal.startsWith('/')) root = '/';
  else if (/^[A-Za-z]:$/.test(parts[0] ?? '')) root = `${parts.shift()}/`;
  const found = new Set();
  walk(root, parts, found);
  return [...found].map((f) => resolve(f)).sort();
}

async function readJson(response) {
  try {
    return await response.json();
  } catch {
    return null;
  }
}

function apiMessage(body, response) {
  const message = body?.error?.message;
  return message ? `${message} (HTTP ${response.status})` : `HTTP ${response.status} ${response.statusText}`;
}

// --- checks before any network call -------------------------------------------------------

async function main() {
// Names a missing setting in the words of the system the script runs in.
const onGithub = Boolean(env('GITHUB_ACTIONS'));
const setting = (input, variable) => (onGithub ? `input ${input}` : variable);
if (!apiKey) {
  stop(
    2,
    onGithub
      ? 'No API key. Pass api-key: ${{ secrets.DECLARABLE_API_KEY }}. Pull requests from forks get no secrets.'
      : 'No API key. Set DECLARABLE_API_KEY. In GitLab, a protected variable reaches only protected branches and protected tags.',
  );
}
if (!productId) stop(2, `No product id. Set ${setting('product-id', 'DECLARABLE_PRODUCT_ID')}.`);
if (!sbomPath) stop(2, `No SBOM path. Set ${setting('sbom-path', 'DECLARABLE_SBOM_PATH')}.`);
if (!version) stop(2, `No version. Set ${setting('version', 'DECLARABLE_VERSION')}.`);
if (!GAP_LEVELS.includes(failOnGap)) {
  stop(2, `fail-on-gap must be one of ${GAP_LEVELS.join(', ')}; got "${failOnGap}".`);
}

// The date goes to the server untouched: it reads it as a UTC day and refuses one in the future.
if (releasedAt && !/^\d{4}-\d{2}-\d{2}$/.test(releasedAt)) {
  stop(2, `released-at must look like 2026-09-30 (YYYY-MM-DD); got "${releasedAt}".`);
}

// The same checks, in the same words, as the releases route (lib/products/releases.ts
// parseBuildIdentity), so a value the server would refuse stops the job before any upload.
const MAX_BUILD_FIELD = 200;
const imageHash = imageSha256.replace(/^sha256:/i, '').trim();
if (imageSha256 && !/^[0-9a-f]{64}$/i.test(imageHash)) {
  stop(2, `${setting('image-sha256', 'DECLARABLE_IMAGE_SHA256')}: A firmware image hash is 64 hex characters.`);
}
if (buildId.length > MAX_BUILD_FIELD) stop(2, `${setting('build-id', 'DECLARABLE_BUILD_ID')}: Keep the build id under ${MAX_BUILD_FIELD} characters.`);
if (commit.length > MAX_BUILD_FIELD) stop(2, `${setting('commit', 'DECLARABLE_COMMIT')}: Keep the commit under ${MAX_BUILD_FIELD} characters.`);
if (supportedUntil) {
  const day = /^\d{4}-\d{2}-\d{2}$/.test(supportedUntil) ? new Date(`${supportedUntil}T00:00:00.000Z`) : null;
  if (!day || Number.isNaN(day.getTime()) || day.toISOString().slice(0, 10) !== supportedUntil) {
    stop(2, `${setting('supported-until', 'DECLARABLE_SUPPORTED_UNTIL')}: Give the support date as YYYY-MM-DD.`);
  }
}

console.log(
  `Thresholds: fail-on-gap = ${failOnGap} (input fail-on-gap, ${GAP_LEVELS.join('|')}); ` +
    `fail-on-exploited = ${failOnExploited} (input fail-on-exploited, true|false).`,
);

// sbom-path holds one or more paths or globs, separated by newlines or spaces (so a path with a
// space in it cannot be named). Every match goes in the one release, each file once.
const patterns = sbomPath.split(/\s+/).filter((p) => p !== '');
const fileSet = new Set();
const unmatched = [];
for (const pattern of patterns) {
  const hits = expand(pattern);
  if (hits.length === 0) unmatched.push(pattern);
  for (const hit of hits) fileSet.add(hit);
}
if (unmatched.length > 0) {
  const named = unmatched.map((p) => `"${p}"`).join(', ');
  stop(2, `No file matches sbom-path ${named}. Check that the SBOM step ran first and wrote there.`);
}
const files = [...fileSet];

/** The file's path under the workspace, with forward slashes. A file outside the workspace has no
 * such path, and sending only its base name would hide the very thing keep-paths is for, so it stops. */
function workspaceRelative(file) {
  const rel = relative(env('GITHUB_WORKSPACE') || process.cwd(), file).replaceAll('\\', '/');
  if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
    stop(2, `DECLARABLE_KEEP_PATHS is on but ${file} is outside the workspace, so it has no relative path. Run the step from the project folder.`);
  }
  return rel;
}

// --- upload -------------------------------------------------------------------------------

const form = new FormData();
form.set('version', version);
if (replace) form.set('replace', 'true');
// promise: releasedAt is read by the releases route and stored by setReleasedAt
if (releasedAt) form.set('releasedAt', releasedAt);
// promise: commit, buildId, imageSha256 and supportedUntil are read by the releases route and stored with the release
if (commit) form.set('commit', commit);
if (buildId) form.set('buildId', buildId);
if (imageSha256) form.set('imageSha256', imageHash);
if (supportedUntil) form.set('supportedUntil', supportedUntil);
let totalBytes = 0;
for (const file of files) {
  const data = readFileSync(file);
  totalBytes += data.length;
  // promise: the releases route keeps a part's file name as sent, path included (measured with the same FormData parse it uses, 11 Oct 2026), so the engine reads the environment from it
  form.append('files[]', new Blob([data]), keepPaths ? workspaceRelative(file) : basename(file));
}
console.log(`Sending ${files.length} file(s), ${totalBytes} bytes total.`);

const productUrl = `${baseUrl}/api/v1/products/${encodeURIComponent(productId)}`;
const headers = { Authorization: `Bearer ${apiKey}` };

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

/** Waits and retries `doFetch` once on a 429 whose Retry-After is 60s or less; a longer wait or
 * any other status is returned as-is for the caller to handle. */
async function withRetry429(doFetch) {
  const response = await doFetch();
  if (response.status !== 429) return response;
  const wait = Number(response.headers.get('retry-after'));
  if (!(Number.isFinite(wait) && wait > 0 && wait <= 60)) return response;
  const body = await readJson(response);
  console.log(`${apiMessage(body, response)} Waiting ${wait}s and retrying once.`);
  await sleep(wait * 1000);
  return doFetch();
}

const UPLOAD_LIMIT_S = 330; // the route may run 300s
const READ_LIMIT_S = 30;

/** One try plus one retry after a 5xx or a dropped connection. A timeout is not retried: the
 * server may still be working on the first request. */
async function send(limitSeconds, doFetch) {
  for (let attempt = 1; ; attempt += 1) {
    let response;
    try {
      response = await withRetry429(() => doFetch(AbortSignal.timeout(limitSeconds * 1000)));
    } catch (cause) {
      if (cause?.name === 'TimeoutError') throw new Error(`no answer after ${limitSeconds}s`);
      if (attempt > 1) throw cause;
      const text = cause instanceof Error ? cause.message : String(cause);
      console.log(`Connection failed (${text}). Waiting 5s and retrying once.`);
      await sleep(5000);
      continue;
    }
    if (response.status >= 500 && attempt === 1) {
      console.log(`Server answered HTTP ${response.status}. Waiting 5s and retrying once.`);
      await sleep(5000);
      continue;
    }
    return response;
  }
}

let release;
let alreadyExists = false;
let unchanged = false;
let replaced = null;
try {
  const response = await send(UPLOAD_LIMIT_S, (signal) =>
    fetch(`${productUrl}/releases`, { method: 'POST', headers, body: form, signal }),
  );
  const body = await readJson(response);
  if (response.status === 409 && body?.error?.code === 'duplicate_version') {
    alreadyExists = true;
    // The id sits under `release` now; a self-hosted copy older than that sends a top-level
    // `releaseId`. Drop the fallback one release cycle after every copy has updated.
    const existing = body.release ?? {};
    const id = existing.id ?? body.releaseId;
    release = {
      id,
      version: existing.version ?? version,
      componentCount: null,
      url:
        existing.url ??
        `${baseUrl}/app/products/${encodeURIComponent(productId)}/releases/${encodeURIComponent(id ?? '')}`,
    };
  } else {
    if (!response.ok) {
      if (response.status === 413 && !body?.error) {
        stop(
          2,
          `Upload refused: the host refused the request for its size before Declarable read it (${totalBytes} bytes).`,
        );
      }
      stop(2, `Upload refused: ${apiMessage(body, response)}`);
    }
    release = body?.release;
    if (!release) stop(2, 'Upload answered without a release in the body.');
    unchanged = response.status === 200 && body?.unchanged === true;
    replaced = body?.replaced ?? null;
  }
} catch (cause) {
  // A stop(2, ...) inside the try above (a non-ok response, a missing release) already printed
  // its own refusal and set the exit code; let that StopSignal keep unwinding instead of
  // re-wrapping it here, or it is reported twice.
  if (cause instanceof StopSignal) throw cause;
  const text = cause instanceof Error ? cause.message : String(cause);
  const timedOut = text.startsWith('no answer after');
  stop(
    2,
    `Upload failed: ${text}.` +
      (timedOut ? ' The release may have been recorded; sending the same files again is safe.' : ''),
  );
}

if (alreadyExists) {
  // promise: the replace input sends replace=true, which the releases route passes to createRelease
  console.log(
    `Release ${release.version} already exists (${release.id}) with different files. Nothing was replaced; ` +
      `set replace to true to replace it. ${release.url}`,
  );
} else if (unchanged) {
  console.log(
    `Release ${release.version} already uploaded with the same files; nothing new was recorded.`,
  );
} else {
  console.log(`Uploaded ${files.length} file(s) as ${release.version}: ${release.componentCount} components.`);
  if (replaced) {
    console.log(
      `Replaced release ${replaced.version} (${replaced.id})` +
        (replaced.kept ? ': it was kept under a new label rather than deleted.' : '.'),
    );
  }
}

// promise: action.yml declares these four outputs and this is the only place that sets them
setOutput('release-id', release.id ?? '');
setOutput('release-url', release.url ?? '');
setOutput('component-count', release.componentCount ?? '');
setOutput('recorded', !alreadyExists && !unchanged);

// --- what is open in the release just uploaded --------------------------------------------

let status = null;
let statusNote = null;
// Set only when the product read itself failed. A read that worked but has no current release
// leaves it null, and the gap line below says that instead.
let unreadReason = null;
try {
  const response = await send(READ_LIMIT_S, (signal) => fetch(productUrl, { headers, signal }));
  const body = await readJson(response);
  if (response.ok) status = body;
  else {
    unreadReason = `the product read failed (${apiMessage(body, response)})`;
    statusNote = `The findings could not be read (${apiMessage(body, response)}).`;
    if (response.status === 403) statusNote += ' Give the key the products:read scope to check them.';
  }
} catch (cause) {
  const text = cause instanceof Error ? cause.message : String(cause);
  unreadReason = `the product read failed (${text})`;
  statusNote = `The findings could not be read (${text}).`;
}

if (status && !unreadReason) {
  // The read worked; does it say anything about THIS release? If not, none of its numbers are used.
  // promise: GET /api/v1/products/:id answers currentRelease {id, version, checked} (api/v1/products/[id]/route.ts)
  const current = status.currentRelease;
  if (current === undefined) {
    unreadReason = 'this Declarable server does not say whether the release has been matched';
  } else if (current === null) {
    unreadReason = 'the product has no current release';
  } else if (release.id && current.id !== release.id) {
    unreadReason =
      `release ${release.version} is not the product's current release (${current.version} is), ` +
      'and findings are read for the current release only';
  } else if (current.checked !== true) {
    unreadReason = 'the release has not been matched yet';
  }
  if (unreadReason) status = null;
}

const open = status?.openVulnerabilities ?? null;
const exploited = open?.exploited ?? null;
const productPage = `${baseUrl}/app/products/${encodeURIComponent(productId)}`;

const gapList = Array.isArray(status?.gaps) ? status.gaps : null;
const gapCounts = { blocker: 0, major: 0, minor: 0 };
for (const gap of gapList ?? []) {
  if (gap?.severity in gapCounts) gapCounts[gap.severity] += 1;
}
const gapsOver =
  gapList === null || failOnGap === 'none'
    ? 0
    : gapCounts.blocker +
      (failOnGap === 'blocker' ? 0 : gapCounts.major) +
      (failOnGap === 'blocker' || failOnGap === 'major' ? 0 : gapCounts.minor);
const unchecked = unreadReason !== null || gapList === null;
const uncheckedReason = unreadReason ?? 'the release has not been matched yet';
const gapText =
  unchecked
    ? `Not checked yet (${uncheckedReason})`
    : `${gapCounts.blocker} blocker, ${gapCounts.major} major, ${gapCounts.minor} minor`;
console.log(`Gaps: ${gapText}`);

const SHOWN_GAPS = 10;
const levelsShown = failOnGap === 'none' ? [] : GAP_LEVELS.slice(0, GAP_LEVELS.indexOf(failOnGap) + 1);
const namedGaps = (gapList ?? []).filter((gap) => levelsShown.includes(gap?.severity));
for (const gap of namedGaps.slice(0, SHOWN_GAPS)) console.log(`  ${gap.severity}: ${gap.title}`);
if (namedGaps.length > SHOWN_GAPS) console.log(`  and ${namedGaps.length - SHOWN_GAPS} more`);
console.log(`Release: ${release.url}`);

const failures = [];
if (gapsOver > 0) failures.push(`${gapsOver} gap${gapsOver === 1 ? '' : 's'} at "${failOnGap}" or worse.`);
if (failOnExploited && exploited !== null && exploited > 0) {
  failures.push(`${exploited} open vulnerabilit${exploited === 1 ? 'y is' : 'ies are'} known to be exploited.`);
}

const uncheckedLine = `Uploaded. Findings not checked: ${uncheckedReason}.`;
const cell = (n) => (n === null || n === undefined ? 'Not checked yet' : String(n));
const byConfidence = open?.byConfidence ?? null;
const gapLines = namedGaps.slice(0, SHOWN_GAPS).map((gap) => `- ${gap.severity}: ${gap.title}`);
if (namedGaps.length > SHOWN_GAPS) gapLines.push(`- and ${namedGaps.length - SHOWN_GAPS} more`);
const lines = [
  '## Declarable SBOM upload',
  '',
  alreadyExists
    ? `Release **${release.version}** already exists with different files. Nothing was replaced. [Open the release](${release.url}) · [Product page](${productPage})`
    : unchanged
    ? `Release **${release.version}** already uploaded with the same files; nothing new was recorded. [Open the release](${release.url}) · [Product page](${productPage})`
    : `Uploaded **${files.length}** file(s) as release **${release.version}** (${release.componentCount} components). [Open the release](${release.url}) · [Product page](${productPage})`,
  '',
  '| Open vulnerabilities, by match confidence | Count |',
  '| --- | --- |',
  `| High confidence | ${cell(byConfidence?.high)} |`,
  `| Medium confidence | ${cell(byConfidence?.medium)} |`,
  `| Low confidence | ${cell(byConfidence?.low)} |`,
  `| Known to be exploited | ${cell(exploited)} |`,
  `| Gaps | ${gapText} |`,
  '',
];
if (gapLines.length) lines.push(...gapLines, '');
if (statusNote) lines.push(statusNote, '');
lines.push(
  failures.length
    ? `**Failed:** ${failures.join(' ')}`
    : unchecked
    ? `**${uncheckedLine}**`
    : '**Inside your thresholds.**',
  '',
  'Draft findings from your SBOM, for review. Not legal advice and not a certification.',
);
writeSummary(lines.join('\n'));

if (failures.length) {
  console.error(`Failed: ${failures.join(" ")}`);
  annotate('error', 'Declarable threshold crossed', `Failed: ${failures.join(' ')}`);
  process.exitCode = 1;
  return;
}
// Exit stays 0: an upload that worked is not a failed build. The log's last line says what was
// and was not checked.
console.log(unchecked ? uncheckedLine : 'Inside your thresholds.');
if (unchecked) annotate('warning', 'Declarable findings not checked', uncheckedLine);
}

main().catch((error) => {
  if (error instanceof StopSignal) return;
  throw error;
});

Download upload.mjs

declarable.yml

The GitLab CI job template. Extend `.declarable_upload` from it and set DECLARABLE_UPLOAD_SCRIPT to wherever you put upload.mjs; the job runs it the same way the GitHub Action does. Goes to integrations/gitlab-ci/declarable.yml, the path the GitLab example's `include:` names (another path works if you change the include to match).

yaml
# Declarable SBOM upload for GitLab CI. Include this file, then extend the job:
#
#   include:
#     - local: integrations/gitlab-ci/declarable.yml
#   declarable_upload:
#     extends: .declarable_upload
#     variables:
#       DECLARABLE_PRODUCT_ID: "<your product id>"
#       DECLARABLE_SBOM_PATH: "build/spdx/*.spdx"
#
# DECLARABLE_API_KEY is not set here: put it in Settings -> CI/CD -> Variables (masked, protected).
# Every variable below is read by integrations/github-action/upload.mjs, the same script the
# GitHub Action runs, so the three integrations behave alike. See README.md in this folder.
.declarable_upload:
  # node:24-alpine is the current LTS (checked against the schedule at nodejs.org/en/about/previous-releases
  # on 2026-10-10: 24.x is LTS until 2028-04-30).
  image: node:24-alpine
  # Needs the SBOM files from an earlier job: add `needs:` with `artifacts: true` in your own job.
  variables:
    # Path to the upload.mjs you copied into your own repository. Required: no default here, because
    # any default would point into Declarable's own tree, which means nothing in yours.
    DECLARABLE_UPLOAD_SCRIPT: ""
    DECLARABLE_VERSION: $CI_COMMIT_SHA
    DECLARABLE_API_BASE_URL: https://declarable.io
    # "true" sends each file under its path inside the project instead of its base name. Needed only
    # by a reader that takes the environment from the file's path (PlatformIO today); the job stops
    # if a file is outside the folder it runs in. Uncomment to turn it on.
    # DECLARABLE_KEEP_PATHS: "true"
    # "true" replaces the release already under this version when the files differ.
    DECLARABLE_REPLACE: "false"
    # Optional day the firmware shipped, YYYY-MM-DD, read as a UTC date. Empty sends no date.
    DECLARABLE_RELEASED_AT: ""
    # The commit stored with the release. "none" sends no commit.
    DECLARABLE_COMMIT: $CI_COMMIT_SHA
    # Optional build fields, stored with the release; empty sends nothing. The image hash is 64 hex
    # characters; the support date is YYYY-MM-DD. A value Declarable would refuse stops the job (exit 2).
    DECLARABLE_BUILD_ID: ""
    DECLARABLE_IMAGE_SHA256: ""
    DECLARABLE_SUPPORTED_UNTIL: ""
    DECLARABLE_FAIL_ON_GAP: blocker
    DECLARABLE_FAIL_ON_EXPLOITED: "true"
  # Default-branch pipelines and tags; a job that extends this can replace `rules:` with its own.
  rules:
    - if: $CI_COMMIT_TAG
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  script:
    # Exit 0 uploaded and inside your thresholds, 1 a threshold was crossed, 2 the upload failed.
    - |
      if [ -z "$DECLARABLE_UPLOAD_SCRIPT" ]; then
        echo "DECLARABLE_UPLOAD_SCRIPT is not set: point it at the upload.mjs you copied into this repository." >&2
        exit 2
      fi
    - node "$DECLARABLE_UPLOAD_SCRIPT"

Download declarable.yml

upload.sh

Plain POSIX sh plus curl, for any other CI: the same DECLARABLE_* variables and exit codes as the Action. Goes to integrations/curl/upload.sh, the path the shell example runs (another path works if you change that line).

sh
#!/bin/sh
# Uploads your build's SBOM file(s) to Declarable's /api/v1 and decides pass or fail.
# Plain POSIX sh plus curl: no jq, no bash-only syntax, so it runs under `sh` on Linux and under
# Git Bash on Windows. Same DECLARABLE_* variables, same exit codes as
# integrations/github-action/upload.mjs: 0 = uploaded and inside your thresholds, or uploaded with
# the findings not checked (said in words); 1 = a threshold was crossed; 2 = the upload itself did not work. A re-run of a version that already exists is not
# an upload failure: the same files again record nothing new, and the findings are checked as usual.
# "Checked" is the product read's currentRelease.checked for the release this job uploaded: if that
# release is not the product's current one, or has not been matched, nothing is checked.
#
# Variables: DECLARABLE_API_KEY, DECLARABLE_PRODUCT_ID, DECLARABLE_SBOM_PATH (one or
# more files or globs separated by spaces or newlines, such as "build/spdx/*.spdx west.yml"; no
# spaces inside a path), DECLARABLE_VERSION, DECLARABLE_API_BASE_URL,
# DECLARABLE_REPLACE (true|false), DECLARABLE_RELEASED_AT (YYYY-MM-DD, optional), DECLARABLE_FAIL_ON_GAP (blocker|major|minor|none),
# DECLARABLE_FAIL_ON_EXPLOITED (true|false), and the optional build fields DECLARABLE_COMMIT
# (default $CI_COMMIT_SHA, then $GITHUB_SHA; "none" sends none), DECLARABLE_BUILD_ID,
# DECLARABLE_IMAGE_SHA256 and DECLARABLE_SUPPORTED_UNTIL (YYYY-MM-DD). DECLARABLE_KEEP_PATHS=true sends each file
# under its path relative to the folder the script runs in, not its base name (for PlatformIO, whose
# reader takes the environment from .pio/libdeps/<env>/ in the path); the default is the base name.

key=${DECLARABLE_API_KEY:-}
product=${DECLARABLE_PRODUCT_ID:-}
sbom=${DECLARABLE_SBOM_PATH:-}
version=${DECLARABLE_VERSION:-${CI_COMMIT_SHA:-${GITHUB_SHA:-}}}
base=${DECLARABLE_API_BASE_URL:-https://declarable.io}
base=${base%/}
replace=$(printf '%s' "${DECLARABLE_REPLACE:-false}" | tr 'A-Z' 'a-z')
released_at=$(printf '%s' "${DECLARABLE_RELEASED_AT:-}" | tr -d ' \t\r\n')
commit=$(printf '%s' "${DECLARABLE_COMMIT:-${CI_COMMIT_SHA:-${GITHUB_SHA:-}}}" | tr -d '
')
[ "$(printf '%s' "$commit" | tr 'A-Z' 'a-z')" != none ] || commit=
build_id=$(printf '%s' "${DECLARABLE_BUILD_ID:-}" | tr -d '
')
image_sha=$(printf '%s' "${DECLARABLE_IMAGE_SHA256:-}" | tr -d ' 	
')
supported_until=$(printf '%s' "${DECLARABLE_SUPPORTED_UNTIL:-}" | tr -d ' 	
')
fail_gap=$(printf '%s' "${DECLARABLE_FAIL_ON_GAP:-blocker}" | tr 'A-Z' 'a-z')
keep_paths=$(printf '%s' "${DECLARABLE_KEEP_PATHS:-false}" | tr 'A-Z' 'a-z')
fail_exploited=$(printf '%s' "${DECLARABLE_FAIL_ON_EXPLOITED:-true}" | tr 'A-Z' 'a-z')

command -v curl >/dev/null 2>&1 || { echo "curl is not installed. This script needs curl on the PATH." >&2; exit 2; }

tmp=$(mktemp -d 2>/dev/null) || { echo "Cannot create a temporary folder." >&2; exit 2; }
trap 'rm -rf "$tmp"' EXIT HUP INT TERM

# Removes the key from any text before it is printed, even if the server echoed it back.
redact() { sed 's/dk_[A-Za-z0-9]*_[A-Za-z0-9_-]*/dk_***/g'; }

stop() {
  printf '%s\n' "$2" | redact >&2
  exit "$1"
}

# curl reads its settings from stdin so the key never appears in the process list.
# Usage: call METHOD URL OUTFILE [curl args...]; prints the HTTP status. Response headers land in
# $tmp/headers.txt, read by call_retry for Retry-After. $limit is the whole-request time limit in
# seconds: the upload route may run 300s, so the upload waits longer than that and reads do not.
limit=30
call() {
  method=$1; url=$2; out=$3; shift 3
  : > "$tmp/headers.txt"
  printf 'header = "Authorization: Bearer %s"\n' "$key" |
    curl -sS -K - --connect-timeout 30 --max-time "$limit" -X "$method" -D "$tmp/headers.txt" -o "$out" -w '%{http_code}' "$@" "$url"
}

# "message":"..." out of a flattened JSON body, the way release_field pulls id and url.
error_message() {
  grep -o '"message":"[^"]*"' "$1" | head -n 1 | cut -d'"' -f4
}

# Same as call, but retries once after a 5xx or a dropped connection (not after a timeout: the
# server may still be working), and once on a 429 whose Retry-After is 60s or less.
call_retry() {
  method=$1; url=$2; out=$3; shift 3
  status=$(call "$method" "$url" "$out" "$@"); rc=$?
  if [ "$rc" -ne 28 ]; then
    case $rc:$status in
      0:5??|[1-9]*:*)
        echo "Connection failed or server answered HTTP $status (curl exit $rc). Waiting 5s and retrying once." >&2
        sleep 5
        status=$(call "$method" "$url" "$out" "$@"); rc=$?
        ;;
    esac
  fi
  if [ "$rc" -eq 0 ] && [ "$status" = 429 ]; then
    retry_after=$(grep -i '^retry-after:' "$tmp/headers.txt" | tail -n 1 | tr -d '\r' | cut -d: -f2 | tr -d ' \t')
    case $retry_after in
      ''|*[!0-9]*) retry_after= ;;
    esac
    if [ -n "$retry_after" ] && [ "$retry_after" -le 60 ]; then
      tr -d ' \t\r\n' < "$out" > "$tmp/retry-flat.json" 2>/dev/null || : > "$tmp/retry-flat.json"
      msg=$(error_message "$tmp/retry-flat.json")
      if [ -n "$msg" ]; then
        printf '%s\n' "$msg Waiting ${retry_after}s and retrying once." | redact >&2
      else
        echo "Rate limited (HTTP 429). Waiting ${retry_after}s and retrying once." >&2
      fi
      sleep "$retry_after"
      status=$(call "$method" "$url" "$out" "$@"); rc=$?
    fi
  fi
  printf '%s' "$status"
  return $rc
}

echo "Thresholds: DECLARABLE_FAIL_ON_GAP=$fail_gap (blocker|major|minor|none); DECLARABLE_FAIL_ON_EXPLOITED=$fail_exploited (true|false)."

[ -n "$key" ] || stop 2 "No API key. Set DECLARABLE_API_KEY from your CI's secret store."
[ -n "$product" ] || stop 2 "No product id. Set DECLARABLE_PRODUCT_ID."
[ -n "$sbom" ] || stop 2 "No SBOM path. Set DECLARABLE_SBOM_PATH."
if [ -z "$version" ]; then
  version=$(git rev-parse HEAD 2>/dev/null) || stop 2 "No version. Set DECLARABLE_VERSION."
fi
# The date goes to the server untouched: it reads it as a UTC day and refuses one in the future.
case $released_at in
  '') ;;
  [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]) ;;
  *) stop 2 "DECLARABLE_RELEASED_AT must look like 2026-09-30 (YYYY-MM-DD); got \"$released_at\"." ;;
esac
# The same checks, in the same words, as the releases route (lib/products/releases.ts
# parseBuildIdentity), so a value the server would refuse stops the job before any upload.
image_hash=$(printf '%s' "$image_sha" | sed 's/^[sS][hH][aA]256://')
if [ -n "$image_sha" ]; then
  case $image_hash in
    *[!0-9a-fA-F]*) stop 2 "DECLARABLE_IMAGE_SHA256: A firmware image hash is 64 hex characters." ;;
  esac
  [ "${#image_hash}" -eq 64 ] || stop 2 "DECLARABLE_IMAGE_SHA256: A firmware image hash is 64 hex characters."
fi
[ "${#build_id}" -le 200 ] || stop 2 "DECLARABLE_BUILD_ID: Keep the build id under 200 characters."
[ "${#commit}" -le 200 ] || stop 2 "DECLARABLE_COMMIT: Keep the commit under 200 characters."
case $supported_until in
  '') ;;
  [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9])
    # A real calendar day: month 01-12, day 01 up to that month's length (February by leap year).
    y=${supported_until%%-*}; rest=${supported_until#*-}; mo=${rest%%-*}; d=${rest#*-}
    mo=${mo#0}; d=${d#0}; y=${y#"${y%%[!0]*}"}; y=${y:-0}
    case $mo in 1|3|5|7|8|10|12) dim=31 ;; 4|6|9|11) dim=30 ;; 2)
      if [ $((y % 400)) -eq 0 ] || { [ $((y % 4)) -eq 0 ] && [ $((y % 100)) -ne 0 ]; }; then dim=29; else dim=28; fi ;;
      *) dim=0 ;;
    esac
    [ "$d" -ge 1 ] && [ "$d" -le "$dim" ] || stop 2 "DECLARABLE_SUPPORTED_UNTIL: Give the support date as YYYY-MM-DD."
    ;;
  *) stop 2 "DECLARABLE_SUPPORTED_UNTIL: Give the support date as YYYY-MM-DD." ;;
esac
case $fail_gap in
  blocker|major|minor|none) ;;
  *) stop 2 "DECLARABLE_FAIL_ON_GAP must be blocker, major, minor or none; got \"$fail_gap\"." ;;
esac

# One -F per file, each file once. The sbom words are split first with globbing off, then each
# word is globbed on its own here in the shell, so a pattern that matches nothing can be named.
# `**` is the shell's: plain sh treats it like `*` and does not recurse.
# shellcheck disable=SC2086
set --
n=0
total=0
seen='
'
unmatched=
set -f
for word in $sbom; do
  set +f
  found=no
  for f in $word; do
    [ -f "$f" ] || continue
    found=yes
    case $seen in
      *"
$f
"*) ;;
      *)
        seen="$seen$f
"
        if [ "$keep_paths" = true ]; then
          rel=${f#./}
          case $rel in
            /*|[A-Za-z]:*|..|../*|*/../*) stop 2 "DECLARABLE_KEEP_PATHS is on but $f is not under the folder the script runs in. Run it from the project folder." ;;
            *[\;,\"]*) stop 2 "DECLARABLE_KEEP_PATHS cannot send $f: a path with ; , or a quote in it breaks curl's form syntax." ;;
          esac
          # promise: the releases route keeps a part's file name as sent, path included (measured 11 Oct 2026)
          set -- "$@" -F "files[]=@$f;filename=$rel"
        else
          set -- "$@" -F "files[]=@$f"
        fi
        n=$((n + 1))
        size=$(wc -c < "$f" 2>/dev/null | tr -d ' ')
        total=$((total + ${size:-0}))
        ;;
    esac
  done
  [ "$found" = yes ] || unmatched="$unmatched \"$word\""
  set -f
done
set +f
[ -z "$unmatched" ] || stop 2 "No file matches$unmatched. Check that the SBOM step ran first and wrote there."
[ "$n" -gt 0 ] || stop 2 "No file in DECLARABLE_SBOM_PATH."
echo "Sending $n file(s), $total bytes total."

product_url="$base/api/v1/products/$product"

if [ "$replace" = true ]; then
  set -- "$@" --form-string replace=true
fi
# promise: releasedAt is read by the releases route and stored by setReleasedAt
if [ -n "$released_at" ]; then
  set -- "$@" --form-string "releasedAt=$released_at"
fi

# promise: commit, buildId, imageSha256 and supportedUntil are read by the releases route and stored with the release
[ -z "$commit" ] || set -- "$@" --form-string "commit=$commit"
[ -z "$build_id" ] || set -- "$@" --form-string "buildId=$build_id"
[ -z "$image_sha" ] || set -- "$@" --form-string "imageSha256=$image_hash"
[ -z "$supported_until" ] || set -- "$@" --form-string "supportedUntil=$supported_until"

# --form-string, not -F: curl reads a -F value starting with @ or < as a file, and a commit sha,
# build id or hash could start with either.
limit=330
status=$(call_retry POST "$product_url/releases" "$tmp/release.json" --form-string "version=$version" "$@"); rc=$?
if [ "$rc" -eq 28 ]; then
  stop 2 "Upload failed: no answer after ${limit}s. The release may have been recorded; sending the same files again is safe."
elif [ "$rc" -ne 0 ]; then
  stop 2 "Upload failed: curl could not reach $base (curl exit $rc)."
fi
limit=30
cat "$tmp/release.json" | redact
echo
tr -d ' \t\r\n' < "$tmp/release.json" > "$tmp/release-flat.json"
release_field() { grep -o "\"$1\":\"[^\"]*\"" "$tmp/release-flat.json" | head -n 1 | cut -d'"' -f4; }
release_url=
release_id=
# A 2xx answer only counts as an upload when its body holds a release object with an id, as the
# Node client requires. A wrong base URL, a proxy or a captive portal can answer 200 with a page.
case $status in
  2??)
    release_obj=$(grep -o '"release":{[^}]*}' "$tmp/release-flat.json" | head -n 1)
    release_id=$(printf '%s' "$release_obj" | grep -o '"id":"[^"]*"' | head -n 1 | cut -d'"' -f4)
    release_url=$(printf '%s' "$release_obj" | grep -o '"url":"[^"]*"' | head -n 1 | cut -d'"' -f4)
    [ -n "$release_id" ] || stop 2 "Upload answered without a release in the body. HTTP $status from $product_url/releases; check DECLARABLE_API_BASE_URL ($base)."
    ;;
esac
case $status in
  200)
    if grep -q '"unchanged":true' "$tmp/release-flat.json"; then
      echo "Release $version already uploaded with the same files; nothing new was recorded."
    else
      echo "Uploaded $n file(s) as $version."
    fi
    ;;
  2??)
    echo "Uploaded $n file(s) as $version."
    replaced=$(grep -o '"replaced":{[^}]*}' "$tmp/release-flat.json" | head -n 1)
    if [ -n "$replaced" ]; then
      replaced_version=$(printf '%s' "$replaced" | grep -o '"version":"[^"]*"' | head -n 1 | cut -d'"' -f4)
      replaced_id=$(printf '%s' "$replaced" | grep -o '"id":"[^"]*"' | head -n 1 | cut -d'"' -f4)
      if printf '%s' "$replaced" | grep -q '"kept":true'; then
        echo "Replaced release $replaced_version ($replaced_id): it was kept under a new label rather than deleted."
      else
        echo "Replaced release $replaced_version ($replaced_id)."
      fi
    fi
    ;;
  409)
    if grep -q '"code":"duplicate_version"' "$tmp/release-flat.json"; then
      # The id sits under "release" now; a self-hosted copy older than that sends a top-level
      # "releaseId". Drop the fallback one release cycle after every copy has updated.
      release=$(grep -o '"release":{[^}]*}' "$tmp/release-flat.json" | head -n 1)
      if [ -n "$release" ]; then
        release_id=$(printf '%s' "$release" | grep -o '"id":"[^"]*"' | head -n 1 | cut -d'"' -f4)
        release_url=$(printf '%s' "$release" | grep -o '"url":"[^"]*"' | head -n 1 | cut -d'"' -f4)
      else
        release_id=$(release_field releaseId)
        release_url="$base/app/products/$product/releases/$release_id"
      fi
      echo "Release $version already exists ($release_id) with different files. Nothing was replaced; set DECLARABLE_REPLACE=true to replace it. $release_url"
    else
      msg=$(error_message "$tmp/release-flat.json")
      stop 2 "Upload refused: ${msg:-HTTP $status}."
    fi
    ;;
  413)
    if grep -q '"error":{' "$tmp/release-flat.json"; then
      msg=$(error_message "$tmp/release-flat.json")
      stop 2 "Upload refused: ${msg:-HTTP 413}."
    else
      stop 2 "Upload refused: the host refused the request for its size before Declarable read it ($total bytes)."
    fi
    ;;
  *)
    msg=$(error_message "$tmp/release-flat.json")
    stop 2 "Upload refused: ${msg:-HTTP $status}."
    ;;
esac

# --- what is open in the release just uploaded -----------------------------------------------
# The reply is flattened to one line and read with grep: no jq to install.
checked=yes
unchecked_reason=
status=$(call_retry GET "$product_url" "$tmp/product.json") || status=000
tr -d ' \t\r\n' < "$tmp/product.json" > "$tmp/flat.json" 2>/dev/null || : > "$tmp/flat.json"
case $status in
  2??) ;;
  *)
    checked=no
    msg=$(error_message "$tmp/flat.json")
    reason=${msg:-"HTTP $status"}
    unchecked_reason="the product read failed ($reason)"
    note="The findings could not be read ($reason)."
    [ "$status" = 403 ] && note="$note Give the key the products:read scope to check them."
    echo "$note"
    ;;
esac

# Does the read say anything about THIS release? If not, none of its numbers are used.
# promise: GET /api/v1/products/:id answers currentRelease {id, version, checked} (api/v1/products/[id]/route.ts)
if [ "$checked" = yes ]; then
  tr -d '\r\n' < "$tmp/product.json" > "$tmp/lines.json"
  if grep -q '"currentRelease":null' "$tmp/flat.json"; then
    unchecked_reason="the product has no current release"
  elif ! grep -q '"currentRelease":{' "$tmp/flat.json"; then
    unchecked_reason="this Declarable server does not say whether the release has been matched"
  else
    current=$(grep -o '"currentRelease":{[^}]*}' "$tmp/flat.json" | head -n 1)
    current_id=$(printf '%s' "$current" | grep -o '"id":"[^"]*"' | head -n 1 | cut -d'"' -f4)
    if [ -n "$release_id" ] && [ "$current_id" != "$release_id" ]; then
      current_version=$(grep -o '"currentRelease": *{[^}]*}' "$tmp/lines.json" | head -n 1 | grep -o '"version": *"[^"]*"' | head -n 1 | cut -d'"' -f4)
      unchecked_reason="release $version is not the product's current release ($current_version is), and findings are read for the current release only"
    elif ! printf '%s' "$current" | grep -q '"checked":true'; then
      unchecked_reason="the release has not been matched yet"
    fi
  fi
  [ -z "$unchecked_reason" ] || checked=no
fi
if [ "$checked" = no ]; then
  echo "Gaps: Not checked yet ($unchecked_reason)"
fi

failures=
if [ "$checked" = yes ]; then
  # "exploited":<digits> occurs once in the body, so it is read by itself: carving out the
  # openVulnerabilities object first stops at the first closing brace (byStatus') and misses it.
  exploited=$(grep -o '"exploited":[0-9]*' "$tmp/flat.json" | head -n 1 | cut -d: -f2)
  if [ -n "$exploited" ]; then
    echo "Open vulnerabilities known to be exploited: $exploited"
    if [ "$fail_exploited" != false ] && [ "$exploited" -gt 0 ]; then
      failures="$exploited open vulnerabilities are known to be exploited. "
    fi
  else
    if grep -q '"openVulnerabilities"' "$tmp/flat.json"; then
      missing='the answer has "openVulnerabilities" but no "exploited" count in it'
    else
      missing='the answer has no "openVulnerabilities" field'
    fi
    echo "Open vulnerabilities known to be exploited: Not checked yet ($missing)"
    unchecked_reason="exploited vulnerabilities were not checked ($missing)"
  fi

  if [ "$fail_gap" != none ]; then
    if grep -q '"gaps":null' "$tmp/flat.json"; then
      echo "Gaps: Not checked yet (the product has no current release)"
      [ -n "$unchecked_reason" ] || unchecked_reason="gaps were not checked (the product has no current release)"
    elif grep -q '"gaps"' "$tmp/flat.json"; then
      blockers=$(grep -o '"severity":"blocker"' "$tmp/flat.json" | wc -l | tr -d ' ')
      majors=$(grep -o '"severity":"major"' "$tmp/flat.json" | wc -l | tr -d ' ')
      minors=$(grep -o '"severity":"minor"' "$tmp/flat.json" | wc -l | tr -d ' ')
      echo "Gaps: $blockers blocker, $majors major, $minors minor"
      # Titles come from "severity":"x","title":"..." pairs, which the API writes side by side. The
      # reply is re-read with spaces kept, because titles have them; if the pairs are not found
      # the counts above and the release link below are all that is printed. No gap title holds a
      # quote today; one that did would be cut at it.
      tr -d '\r\n' < "$tmp/product.json" > "$tmp/lines.json"
      case $fail_gap in
        blocker) shown='blocker' ;;
        major) shown='blocker|major' ;;
        minor) shown='blocker|major|minor' ;;
        none) shown='^$' ;;
      esac
      grep -oE '"severity": *"(blocker|major|minor)", *"title": *"[^"]*"' "$tmp/lines.json" |
        sed -e 's/^"severity": *"//' -e 's/", *"title": *"/: /' -e 's/"$//' |
        grep -E "^($shown):" > "$tmp/titles.txt" || : > "$tmp/titles.txt"
      listed=$(wc -l < "$tmp/titles.txt" | tr -d ' ')
      head -n 10 "$tmp/titles.txt" | sed 's/^/  /' | redact
      if [ "$listed" -gt 10 ]; then
        echo "  and $((listed - 10)) more"
      fi
      case $fail_gap in
        blocker) over=$blockers ;;
        major) over=$((blockers + majors)) ;;
        minor) over=$((blockers + majors + minors)) ;;
      esac
      if [ "$over" -gt 0 ]; then
        failures="$failures$over gap(s) at \"$fail_gap\" or worse. "
      fi
    else
      echo "DECLARABLE_FAIL_ON_GAP is \"$fail_gap\", but this reply has no gap report, so gaps were not checked."
      [ -n "$unchecked_reason" ] || unchecked_reason="gaps were not checked (this reply has no gap report)"
    fi
  fi
fi

[ -z "$release_url" ] || echo "Release: $release_url"
echo "Draft findings from your SBOM, for review. Not legal advice and not a certification."
if [ -n "$failures" ]; then
  stop 1 "Failed: $failures"
fi
if [ -n "$unchecked_reason" ]; then
  # Exit stays 0: an upload that worked is not a failed build.
  echo "Uploaded. Findings not checked: $unchecked_reason."
else
  echo "Inside your thresholds."
fi

Download upload.sh