Skip to main content

Supply Chain

How Merlon's dependencies, build inputs, and published artifacts are controlled, and what evidence exists for each control. Written for a reviewer who needs to record findings, not for a maintainer.

Pinning

Every build input is pinned to an immutable identifier.

InputPinned asWhere
Base images (Go, Node.js, Alpine, PostgreSQL)Tag and sha256 digestapi/Dockerfile, compose files, .github/workflows/ci.yml
GitHub ActionsFull commit SHA, with the version in a trailing commentAll workflows
Go modulesgo.sumapi/go.sum
npm packagespackage-lock.jsonui/, website/
Wrangler (docs deploy)Exact versionwebsite/package.json, docs-deploy.yml

A pin is only worth what enforces it. The same version is deliberately declared in more than one place — a Dockerfile and the workflow that tests it, for example — and guard scripts fail the build when the copies disagree:

GuardEnforces
scripts/check-container-pins.shThe PostgreSQL image digest is identical across every compose file and CI
scripts/check-toolchain-pins.shGo and Node.js versions match across the Dockerfile, all workflows, go.mod, and the dev container
scripts/check-wrangler-pin.shThe Wrangler version in package.json matches the one the deploy workflow runs
scripts/check-env-vars.shEvery environment variable the code reads is documented, and every documented variable is read
scripts/check-doc-image-assets.mjsDocumentation inputs under docs/ and website/ contain no ICNS, JPEG XL, HEIF/HEIC, or AVIF files, including renamed files detected by their signatures
scripts/ruleset-baseline.shThe committed ruleset baselines carry every field the drift check compares, bypass_actors above all, and are in canonical export form

Each guard fails if it finds zero occurrences of what it is checking, not just on a mismatch. A control that silently stops checking anything when a step is renamed is worse than no control, because it reports success.

The last row is that rule applied to a guard that had broken it. The rulesets API omits bypass_actors for a caller without repository Administration, so the drift check was comparing a field it could not see — zero occurrences read as agreement rather than as a failure.

These run as required checks on every pull request.

Dependency updates

Dependabot runs monthly in three review lanes — application, documentation, infrastructure — each limited to one open pull request, with cooldown periods before a newly published version is proposed (14 days by default, 60 for majors). Security updates are enabled separately and are not delayed by these schedules.

Runtime end-of-life dates are tracked explicitly in Dependency Lifecycle, reviewed quarterly, and re-checked against upstream sources on every release date.

Vulnerability scanning

Runs on every pull request and weekly on a schedule:

ScanCovers
gitleaksCommitted secrets
govulncheckGo dependencies, reachability-aware
npm audit (via scripts/check-npm-audit.mjs)ui/ and website/ dependencies
scripts/check-doc-image-assets.mjs (via make audit-npm)Image formats that would reach the vulnerable image-size parser in the documentation build
go-licenses / license-checkerLicence allowlist for Go and npm dependencies
anchore/sbom-actionCycloneDX SBOM for the API, UI, and website

Accepted npm advisories

Advisories that cannot be resolved immediately are recorded in scripts/npm-audit-exceptions.json. An entry is not a suppression; it must carry a reachability rationale, the dependents it was assessed against, and an expiry date.

The gate fails when an exception expires, when the advisory's scope has changed from what was assessed, or when the advisory no longer exists — that last case matters because it means a stale exception is silently covering nothing.

The two current image-size exceptions are paired with a fail-closed input guard. It scans every tracked or non-ignored file under docs/ and website/ and rejects the risky formats by extension and by the signatures recognized by the parser. It also fails if either root or the file list disappears, so a renamed build input or a broken scan cannot turn the accepted risk into a vacuous pass. The guard runs before npm audit in make audit-npm and in the Security workflow.

Build and release

Publishing is triggered only by an annotated Git tag, and the workflow refuses to proceed unless:

  1. The tag is strict SemVer, vMAJOR.MINOR.PATCH. A pre-release identifier is rejected: this project publishes one channel.
  2. The tag is annotated, not lightweight.
  3. The tagged commit is an ancestor of main.
  4. CHANGELOG.md has a section for that version.

Nothing is published from a branch head. There is no latest or rolling tag.

The checks that can reject a release all run before the image is pushed, deliberately: once an image is public under a release tag, a later failure cannot unpublish it.

What each release produces

ArtifactPurpose
Multi-architecture image (linux/amd64, linux/arm64)The software
GitHub build provenance attestation, pushed to the registryTies the image digest to the workflow, repository, and commit that built it
CycloneDX SBOM of the imageComponent inventory for your own scanning
release-manifest.jsonTag, commit, image, digest, SBOM hash, provenance URL, and a governance block stating what the release does not assert
SHA256SUMSIntegrity of the attached files

Upgrading has the consumer-side verification procedure. Verify before deploying; the artifacts are only worth something if somebody checks them.

Reproducing the image

The image builds from the repository with no private inputs:

docker build -f api/Dockerfile \
--build-arg VERSION=vX.Y.Z \
--build-arg REVISION="$(git rev-parse HEAD)" \
-t merlon:verify .

Neither builder stage runs under emulation — the Go binary cross-compiles and the UI bundle is architecture-neutral — so the linux/arm64 image is produced by the same code path as linux/amd64, not by a separate emulated build.

Known gaps

Recorded here rather than omitted, because a reviewer will find them anyway:

GapStatus
Images are attested but not signed with cosign/sigstoreProvenance attestation covers origin; a detached signature does not currently exist
SBOMs are generated but not scanned in CIThey are published for you to scan; no gate consumes them yet
No static application security testing (CodeQL or equivalent)Not currently configured
No published container-image CVE scanScan the published SBOM or image yourself
One active maintainerStated in MAINTAINERS.md, and disclosed on every release in release-manifest.json and the image labels. Merges require a self-review record enforced by Governance Required (ADR-0016)

See Accepted Risks for the ones that are deliberate rather than pending.