CI Production Gates
The root CI workflow is the production gate for
the complete repository. Pull requests, schedules, manual dispatches, and
non-main repositories never publish containers. Only a first-party push to
main can hand the exact tested image pair to the isolated
publish-containers job after every required lane succeeds. All build and test
jobs keep read-only repository permissions; registry, identity-token, and
attestation authority exists only on that protected publisher.
The main branch currently has no GitHub branch-protection rule or required
status check. The production-gate fan-in remains visible on every CI run and
still protects container publication. It fails unless every required lane
succeeds; a failed, cancelled, or skipped dependency cannot turn the fan-in
green.
Required Lanes
| Lane | Contract | Timeout |
|---|---|---|
contracts |
Immutable root install; fail-closed production dependency audits for every committed lock domain; CI validator tests; release impact, fingerprint, comparator, packaging-invocation, target, and artifact contracts; generated docs/search freshness, link/navigation integrity, Mermaid rendering; the safe-logging source gate; actionlint over root workflows. | 12 min |
check |
Immutable installs in the root, app, and server projects, followed by the coordinated type, lint, format, and test gate. | 45 min |
build |
A second clean set of immutable installs followed by the coordinated MCP, OpenClaw, app, and server build. | 45 min |
desktop-electron |
A production desktop build followed by the seven real Electron suites under Xvfb. The guarded runner requires the built entry point and cannot silently fall back to skipped headless tests. | 45 min |
container-smoke |
Hadolint, exact clean-commit BuildKit image identity, immutable app/server marker and OCI-label equality, an isolated Compose stack, required encrypted two-editor online/offline convergence, the realtime mint boundaries and Redis push delivery, a cross-device push round trip through the public front door, Chromium app-open checks, Mermaid layout and socket file downloads in Firefox and Chromium with reduced motion on, bounded parallel sync and Redis operations, MariaDB backup/restore, image/container hardening assertions, and a closing gRPC phase that proves SYNC_ITEMS and the oversized-result contract. |
70 min |
production-gate |
Fail-closed fan-in for all five implementation lanes above. | 5 min |
publish-containers |
On an accepted first-party main push only, verify and publish the exact app/server archive produced by container-smoke, re-pull both images, verify their identities and labels, attest both registry digests, and record digest-pinned deployment references. |
45 min |
The required browser selection contains three app-open tests and one combined sync/Redis test. The generated JSON report must contain at least four expected tests and exactly zero skipped, unexpected, or flaky tests. The stack uses a run-specific Compose project and generated credentials, then removes its containers and volumes even after a failure.
Firefox and reduced motion
Those tests run in Chromium with no motion preference, and two defects reached
a Firefox user while they were green: Mermaid diagrams were laid out about
twelve times too large in Firefox whenever prefers-reduced-motion: reduce was
on (a Remote Desktop session turns it on), and file downloads over the socket
file lane failed in Firefox alone. The required stack therefore also runs two
specs in both Firefox and Chromium, with the same assertions in each:
reduced-motion-mermaid-layout.spec.tsturns reduced motion on, asks the page whether it took, and then reads the layout the browser drew for a flowchart, a sequence diagram and a flowchart with long labels: the natural size, the painted size, whether each arrow ends on a box and whether each label is inside the box sized for it.socket-lane-file-download.spec.tsstores three files from one browser and downloads them in a second, and asserts the lane’s own account of each download — asked once, answeredcompleted, every encrypted byte handed over, no HTTP download made — as well as the bytes. The bytes alone are not enough: on the build before the fix the client fell back to HTTP and still produced the right file.
The report must contain twelve expected tests, six of them in the firefox
project and six in chromium, and zero skipped, unexpected, or flaky ones.
The per-project floors are what make “Firefox ran” a checked fact; a total can
be reached by one browser alone. Retries stay off: the download defect was a
race.
The same two specs then run in the runner’s stock Firefox, driven over
WebDriver BiDi through Playwright’s moz-firefox channel with reduced motion
set by the browser’s own preference. That browser belongs to the runner image,
so the step has three outcomes: no firefox on the runner, or one that will
not answer Playwright, produces a warning annotation and passes; a Firefox that
was driven and showed the app wrong fails the lane. Playwright’s own Firefox
remains the gate either way.
This is Playwright’s Firefox, and stock Firefox, on Linux. It is not Firefox on Windows following the operating system’s own setting, and it is not Safari. To run either spec against a local stack in the Firefox you have installed:
cd e2e
export STOCK_FIREFOX="C:/Program Files/Mozilla Firefox/firefox.exe" # /usr/bin/firefox on Linux
APP_URL=http://127.0.0.1:3001 npx playwright test tests/reduced-motion-mermaid-layout.spec.ts --project=stock-firefox
STOCK_FIREFOX_REDUCED_MOTION=0 runs that project with reduced motion off.
Before browser work begins, the required stack proves that the checkout is
clean and exactly GITHUB_SHA, builds both images with the same validated
revision/version, checks their OCI labels and root-owned marker bytes, and then
compares the public app marker with the server’s aggregate-readiness identity.
A null or mixed-revision deployment fails even when its ordinary liveness
checks are otherwise healthy. Scheduled cache restores receive the same build
and runtime identity inputs.
Before browser smoke, the live server container must also pass the encrypted Yjs two-editor drill with gateway availability required. It covers late join, simultaneous online edits, edits made independently during a disconnect, and post-reconnect convergence; an unavailable gateway is a hard failure rather than an optional local skip.
The dependency audit inventories Git-tracked npm, Yarn, and pnpm lockfiles
before it contacts the package registries. An unconfigured lockfile, an audit
transport or parse failure, or a new high or critical production advisory fails the
lane. Compatibility exceptions live in
scripts/production-audit-allowlist.json; every entry binds the registry
advisory ID, package, lock domain, rationale, and expiry. Expired, duplicate,
or no-longer-observed exceptions also fail so the allowlist cannot become a
permanent suppression list. The app graph contract additionally preserves the
loopback-only embedded server patch, patched PDF.js, and both supported
fast-xml-parser major lines.
Realtime proofs in the container lane
Four scripts under server/packages/websocket-gateway/e2e run against the live
disposable stack. Between them they cover the two realtime lanes end to end: the
legacy socket that carries pushes, collaboration and invites, and the worker sync
lane that carries durable commands.
None of it can be quietly removed. The workflow contract in
scripts/validate-ci-contract.mjs pins each of the four scripts by name, the two
origins the mint-boundary drill needs, and the switch that starts the gRPC phase,
so deleting a step fails the contracts lane rather than shrinking the evidence
the container lane produces.
Cross-device push round trip
push-roundtrip.e2e.mjs is the only test that walks the whole delivery chain:
a save reaches the syncing-server, becomes a WEB_SOCKET_MESSAGE_REQUESTED
event, crosses SNS/SQS, is drained by a gateway worker, and lands on another
device’s socket. A broken queue, a filtered subscription, a dead worker, or a
swallowed event fails here and nowhere else.
The script runs from the runner against the public front door, which is the only origin a browser uses. It registers an account, signs a second session in, opens that session’s legacy socket, then makes twenty saves one second apart from the first session. Every save must be accepted, every push must arrive inside the first settle window, and nothing may arrive only in the wider window that follows. A straggler is reported as a late arrival and fails the run rather than passing quietly.
Push payload mode is read off the frames, never off the
WEBSOCKET_SYNC_PUSH_ENABLED flag. Any frame carrying items puts the run in
payload mode, where accounting is by item UUID and a redelivery collapses;
otherwise the run is in notification mode, where each push frame is worth exactly
one unit and nothing can be deduplicated. The same script is therefore correct on
either side of the default, and payload mode can name the item that never
arrived while notification mode does not pretend it can.
Run the parser, the accounting rules, and the socket plumbing with no stack and no Docker:
cd server
yarn workspace @standard-red-notes/websocket-gateway node \
e2e/push-roundtrip.e2e.mjs --self-test
Oversized sync results under gRPC proxies
The worker sync lane carries SYNC_ITEMS only when the api-gateway speaks gRPC
to the syncing-server, so the oversized-result contract is unprovable under the
default HTTP proxies. The lane closes with a gRPC phase: it appends
SERVICE_PROXY_TYPE=grpc, recreates the stack without rebuilding, and reruns the
realtime scripts. This is last because every later step inspects images rather
than containers, so nothing needs the default proxy configuration restored.
sync-items-oversized.e2e.mjs requests a ticket through the front door and reads
the operations it negotiated. With REQUIRE_SYNC_ITEMS=1 a ticket that does not
negotiate SYNC_ITEMS fails the lane instead of skipping, so a silently
misconfigured proxy cannot turn this step green.
It then commits enough notes over the lane that the committed result cannot fit one 512 KiB frame, and asserts the full contract:
- the answer is a
STATUSframe, never anERROR; - its status is
COMMITTEDand its code isRESULT_TOO_LARGE; - it omits the result entirely;
- it keeps the same request, command, and digest identity;
- the socket stays open afterwards.
The client then replays the identical command over HTTP with x-sync-command-id
and x-sync-command-digest. The journal must return every item committed over
the socket, and a second identical replay must return the same result without
double-committing. ERROR RESULT_TOO_LARGE stays reserved for oversized ingress
and must not appear on this path.
The push round trip runs a second time under the gRPC proxies, so the delivery chain is proved in both service-proxy configurations.
Where each realtime script mints its token
WEBSOCKET_GATEWAY_INTERNAL_SECRET can mint a socket token for any user, so the
front doors blank X-Internal-Secret on /sockets and the gateway refuses an
internal mint that arrives proxied or from a non-loopback peer. That refusal is
asserted in CI rather than assumed.
realtime.e2e.mjs runs inside the server container because it is the one
vantage point that reaches both origins at once: the front door on the Compose
network, and loopback. It requires the internal mint through the front door to
return no token, then requires the same mint from loopback to succeed. It also
mints over the cross-service x-auth-token path the api-gateway uses for the web
client and requires a bad token to be rejected. Finally it publishes to the Redis
push channel and asserts the message reaches a connected client, that a push
tagged with the listener’s own originating session is suppressed, and that a push
for a different user is not delivered there.
collab-yjs.e2e.mjs mints with the internal secret as well, so it likewise runs
from loopback inside the container. push-roundtrip.e2e.mjs and
sync-items-oversized.e2e.mjs need no internal secret at all: they register real
accounts and mint with the user’s own access token, which is why they run from
the runner through the public front door.
Protected publication after the gate
The app and server are published together for linux/amd64 as:
ghcr.io/supermarsx/standard-red-notes-appghcr.io/supermarsx/standard-red-notes-server
Both receive the same unique, non-floating, retry-stable
sha-<40-character-commit>-run-<run-id>.<producer-attempt> tag. A failed-job
rerun consumes the successful producer job’s original artifact ID and identity,
so it safely repairs a partial push with the same tested bytes. Protected-main
push runs cannot cancel one another, and other event classes use separate
concurrency groups.
The workflow creates no latest or main tag. The container lane saves the
locally tested image IDs, deployment revision/version labels, platform, archive,
and SHA-256 checksum. The protected publisher does not check out or execute
repository code: it verifies and loads that same-run archive, uses the built-in
GITHUB_TOKEN to push, re-pulls both coordinates, proves the remote image IDs
and OCI labels still match, publishes GitHub build-provenance attestations, and
verifies those attestations before reporting digest-pinned references. A
package owner may make the GHCR packages public once if anonymous pulls are
required; the workflow never relaxes package visibility itself.
The pair becomes consumable only when publish-containers succeeds and the
workflow summary contains both digest-qualified references. A failed partial
tag is not a release. The exact-image handoff is retained for one day; during
that window, use GitHub’s rerun-failed-jobs operation. If it has expired, rerun
the complete CI workflow so container-smoke produces a new coordinated
attempt. This container-only recovery rule does not replace the stricter
same-evidence retry rules for signed product publishers below.
This coordinated container publication is continuous deployment input, not a ninth versioned product release: it creates neither a Git tag nor a GitHub Release. Each of the eight versioned product publishers independently checks out complete history, validates the repository release contract before impact analysis, selects an ancestry-safe product baseline, builds only after a managed source/configuration change, and compares its product-specific release surface plus named packaging contract with the exact prior fingerprint assets. For mobile, that comparison is a deterministic pre-sign source/configuration and toolchain identity; signed APK, AAB, and IPA bytes are validated and checksummed separately before publication.
The release-policy JavaScript is parsed with the public, exact
@babel/parser dependency declared in scripts/package.json and locked in
scripts/package-lock.json. CI, local public commands, and every publisher
install it with the same isolated immutable command immediately before policy
analysis or native fingerprinting. A missing parser fails the gate. Changes to
that manifest or lock are release-significant for all eight products because
they can change how every semantic policy partition is interpreted.
flowchart TD
C[Normal CI contracts lane] --> R[Read-only repository release report]
D[Container smoke image pair] --> G[Production gate]
G --> H{First-party push to protected main?}
H -- No --> S
H -- Yes --> Q[Protected exact-image GHCR publication and attestation]
E[Bounded publisher event] --> V[Validate complete release contract]
V --> I{Owned source or configuration changed?}
I -- No --> S[Skip]
I -- Yes --> B[Prepare product-specific release surface]
B --> F[Compute release-surface plus packaging-contract fingerprints]
F --> P{Exact prior evidence usable?}
P -- Same --> S
P -- Changed or first release --> U[Run product packaging and publication fan-in]
P -- Missing, malformed, draft, or divergent --> X[Fail closed]
The native release tests capture the actual shell-free executable and argument array used for every target, including the pinned packager/runtime/flags and target-specific output name. The fingerprint contains that same canonical invocation set, and home-server migration archive creation is included as a product-specific supplemental invocation. Desktop, mobile, and OpenClaw have equivalent named contracts for their deterministic inputs, toolchains, target matrices, and packaging behavior. Raw workflow text is not hashed, so prose, display-name, and permissions-only edits do not fabricate releases.
Native executor and packaging-policy source is compared as a canonical semantic AST. Formatting and comments affect no product; product-owned semantics affect only that product; native-shared semantics affect the five native products; and cross-product shared semantics affect all eight. The v4 native semantic identity has one conservative transition from the legacy v3 exact-byte baseline: the first comparison may release all five native products once, after which the scoped rules apply. Malformed or ambiguous policy fails closed.
External action references in publisher workflows are restricted to an exact commit-SHA allowlist. Their version comments are validated for readability but excluded from semantic fingerprints; the SHA itself remains release-significant. Runner-image resolution and secret/signing-key rotation are still external-state boundaries. They are documented with the audited manual-force procedure in Releases and Upgrades; the pre-publication fingerprint must not be presented as evidence that those external values stayed unchanged.
The five rolling native publishers allocate one stable release identity and reuse a validated draft on retry. Publication checks the draft identity, uploads with replacement semantics, verifies the exact remote asset set and downloaded hashes, and only then publishes it. Validator mutations reject any return to version allocation inside the final release job or direct one-shot release creation.
Those publishers authorize only the exact protected origin/main head and put
identity plus publication behind the release-production environment. Every
package handoff is retained for 30 days. Before publication, all six Windows,
Linux, and macOS x64/arm64 assets execute on matching runners with network proxy
variables cleared; the job verifies binary format, architecture, and the exact
offline self-test response. The draft is bound to a sorted
name/SHA-256/size-manifest marker before any remote asset deletion or upload.
The root desktop publisher applies the same exact-main authorization, protected environment, and 30-day handoff policy. It requires all twelve signing secrets: before reserving an identity it checks, by presence only, that each one is set, and when any is missing the run ends successfully without reserving or building anything and names the missing secrets in the job summary and an annotation. Desktop fan-in verifies each updater entry’s exact basename, size, and Base64 SHA-512 plus real package format and architecture across NSIS, AppImage, ZIP/DMG, and DEB artifacts. If packaging fails after reserving an automatic draft, that exact draft remains available to a same-source failed-job retry. A later source or intent may supersede at most one such reservation only after complete-inventory and immutable-ID reads agree that it is the uniquely named, bot-authored, tagless, unpublished, mutable, asset-free automatic desktop draft. Any forced, populated, published, immutable, duplicate, malformed, tagged, or concurrently mutated reservation detected by those checks remains untouched and blocks allocation for manual reconciliation. GitHub exposes no conditional release DELETE, so the final tag and immutable-ID checks narrow but cannot eliminate a mutation in the last check-to-delete interval; release-write authority must remain tightly restricted. The embedded Snap-capable recovery path must pass an exact-main protected gate before calling its reusable workflow. OpenClaw rolling sources likewise require exact main; explicit tags must resolve to the checked-out commit on protected-main ancestry, and identity, attestation, and publication are protected production jobs with 30-day artifacts.
Mobile validation requires the exact four-ABI Android matrix
(armeabi-v7a, arm64-v8a, x86, x86_64), symmetric native-library path
sets in APK and AAB, and valid APK/AAB signatures. iOS validation deep-checks
the code signature and scans every extracted Mach-O for an exact device-only
arm64 architecture. Signing secrets use restrictive file modes and immediate
always() cleanup; every later publication stage rechecks the validated
artifact checksums and exact app/build identity.
Mobile branch analysis and manual publication require the exact fetched
protected origin/main head. A web-tag release must resolve to the event commit,
that commit must be an ancestor of protected main, and its stable SemVer must
match the web manifest. Release reservation, signed Android/iOS builds, every
store mutation, and final GitHub publication are protected by the
mobile-production environment. The marker-bound GitHub draft is reserved
before any store mutation and only that reserved prerelease can be published
after both store branches finish.
Validated payloads and the same-run GitHub/store intent evidence are retained for exactly 30 days. Recovery must use GitHub’s rerun-failed-jobs operation so only the failed job and its dependent jobs resume. Never rerun all jobs and never rebuild signed bytes under the same version/build identity; absent, expired, foreign, duplicate, or ambiguous markers fail closed.
Extended Profiles
The Sunday schedule runs both extended lanes after the required container lane has passed. A manual run accepts one of these profiles:
| Profile | Additional work |
|---|---|
required |
Run only the pull-request production gate. |
load |
Run 250 encrypted notes through four parallel browser clients while four Redis workers perform 500 verified operation loops each. |
exhaustive |
Run every non-load Playwright spec across Chromium, Firefox, and WebKit. Intentional engine-specific skips must include a reason and remain below the asserted ceiling. |
all |
Run both load and exhaustive. |
The load lane is capped at 75 minutes and the browser matrix at 120 minutes.
Both always upload Playwright JSON, retained traces, Compose status/logs, and a
container resource snapshot. Required artifacts use if-no-files-found: error;
missing evidence is a failure rather than a silent pass.
Docker Assertions
The default production stack is validated at three levels:
- Every tracked Dockerfile passes a digest-pinned Hadolint image.
- Compose configuration must drop all capabilities, enable
no-new-privileges, set memory and PID ceilings, keep infrastructure ports internal, and keep the raw Docker socket out of the server container. - Built app/server images must declare a non-root runtime user and healthcheck. Live stack containers must be healthy, unprivileged, capability-dropped, and constrained by memory and PID limits.
BuildKit’s GitHub Actions cache is shared by the required and extended lanes.
The image build steps have push: false; they cannot write to a registry. On an
accepted first-party main run, container-smoke exports the exact images only
after their live acceptance checks pass. The later no-checkout publisher alone
can push that verified archive to GHCR.
Local Validation
Run the workflow policy and its failure-case tests without starting Docker:
yarn deps:security:production
yarn ci:contracts
actionlint .github/workflows/ci.yml
These public Yarn commands install the isolated parser dependency from
scripts/package-lock.json automatically. ci:contracts installs it once and
then invokes the direct internal :run scripts; callers should use the public
commands rather than invoking a :run alias without that install boundary.
For a focused release-contract iteration, the same gate is split into explicit behavior and mutation checks:
yarn test:release-impact
yarn test:release-contract
yarn release:contract
test:release-impact covers source ancestry, product scoping, tree
normalization, canonical packaging inputs, exact native invocations, and
fail-closed fingerprint comparison. test:release-contract mutates protected
workflow/helper fragments and requires the validator to reject each drift.
Run the same desktop artifact and live Electron suite locally on a machine with a display:
cd app
yarn build:desktop
yarn workspace @standardnotes/desktop ava:electron
On headless Linux, prefix the final command with xvfb-run --auto-servernum.
The runner sets its own test gate and fails when the desktop entry point is
missing, so this command cannot report success by substituting skipped tests.
Validate the current Compose model:
yarn ci:docker-hardening
After starting a disposable stack, run the same required operations commands:
APP_URL=http://127.0.0.1:3001 \
OPS_LOAD_NOTES=25 \
OPS_LOAD_CLIENTS=2 \
OPS_REDIS_WORKERS=2 \
OPS_REDIS_OPS_PER_WORKER=50 \
npm --prefix e2e run test:ops-load
yarn ops:backup-restore
The root, app, and server dependency graphs are lockfile-backed and installed
with --immutable. The E2E package does not currently commit an npm lockfile,
so CI names the exact Playwright package version instead of resolving its
declared range. Changing that version requires updating the workflow contract
and rerunning all browser lanes.
Runtime Tradeoffs
On a cold runner, dependency installation, native app packages, and two Docker image builds dominate elapsed time. The required lanes are separated so checks, builds, contracts, and container integration run concurrently; warm Yarn and BuildKit caches reduce later runs without allowing a cache miss to skip work.
The normal pull-request path intentionally keeps the live test corpus small but still crosses the browser, encryption/sync, MariaDB, Redis, SNS/SQS emulator, backup, restore, and hardened-container boundaries. Larger note counts and the three-engine suite run on schedule or explicit dispatch because placing them on every commit would consume substantially more runner time and make feedback less useful without increasing the breadth of the required boundary checks.