Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,34 @@ on:
branches: [main]
pull_request:

# The job builds and inspects; it never writes to the repository. Without this
# block it would inherit whatever the repository default happens to be, which is
# the only privileged path in the file.
permissions:
contents: read

jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

# The vendored OpenAPI spec is a copy of the engine's. Without the source
# beside it there is nothing to compare against, and a drift check that
# cannot see the source is a check that always passes.
#
# Full history, because the check reads the spec at the commit recorded in
# openapi-source.json rather than at whatever is currently on main. See
# that script's header for why the pin exists.
- name: Checkout dpp-engine (source of the vendored API spec)
uses: actions/checkout@v4
with:
repository: odal-node/dpp-engine
path: .dpp-engine
fetch-depth: 0
persist-credentials: false

- name: Enable Corepack
run: corepack enable

Expand All @@ -29,3 +50,40 @@ jobs:

- name: Check
run: pnpm -r check

# Markdown link targets are opaque strings to `astro check`. This reads
# the built output, so it sees what is actually published — including
# cross-site links, which neither site's own tooling can resolve.
- name: Check links
run: pnpm run check:links

# This repository is public. Internal decision-record numbers and paths
# into the private docs repo must not appear in it — including inside
# `public/`, which is served verbatim.
- name: Check for internal-vocabulary leakage
run: pnpm run check:leakage

- name: Check the vendored API spec against the engine
run: pnpm run check:openapi
env:
DPP_ENGINE_DIR: ${{ github.workspace }}/.dpp-engine

# Reports, without failing, how far the pin is behind the engine's main.
# Deliberately not a gate: the pinned copy being *correct* is this repo's
# problem and is enforced above, but the pin being *old* is a release-
# cadence judgement, and failing on it would redden every pull request
# here every time the engine merges anything. The number is printed on
# every run so the drift that started this — a published spec fifteen
# endpoints behind, with nothing to reveal it — cannot go unnoticed again.
- name: Report how far the API-spec pin is behind
if: always()
run: |
PIN=$(node -p "require('./site/dpp-docs/openapi-source.json').commit")
cd .dpp-engine
BEHIND=$(git rev-list --count "$PIN"..origin/main -- api/openapi.yaml 2>/dev/null || echo "?")
if [ "$BEHIND" = "0" ]; then
echo "API spec pin is current with the engine's main branch."
else
echo "::notice::The vendored API spec is pinned $BEHIND commit(s) behind changes to api/openapi.yaml on the engine's main. Run 'pnpm run sync:openapi' to bring it forward."
git --no-pager log --oneline "$PIN"..origin/main -- api/openapi.yaml || true
fi
81 changes: 81 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
name: Deploy

on:
workflow_dispatch:
push:
branches:
- main
paths:
- 'site/**'
- 'packages/**'
- 'public/**'
- '.github/workflows/deploy.yml'

jobs:
purge-cloudflare-cache-landing:
name: Purge Cloudflare Cache (landing)
runs-on: ubuntu-latest
environment: ${{ vars.CLOUDFLARE_ENVIRONMENT_LANDING || 'landing' }}

steps:
- name: Check Cloudflare secrets
id: cloudflare
env:
CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
if [ -n "$CLOUDFLARE_ZONE_ID" ] && [ -n "$CLOUDFLARE_API_TOKEN" ]; then
echo "ready=true" >> "$GITHUB_OUTPUT"
else
echo "ready=false" >> "$GITHUB_OUTPUT"
fi

- name: Purge Cloudflare cache
if: steps.cloudflare.outputs.ready == 'true'
env:
CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
curl --fail --silent --show-error \
-X POST "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
-H "Content-Type: application/json" \
--data '{"purge_everything":true}'

- name: Skip purge if secrets are missing
if: steps.cloudflare.outputs.ready != 'true'
run: echo "Skipping Cloudflare cache purge because the required secrets are not configured for the landing environment."

purge-cloudflare-cache-docs:
name: Purge Cloudflare Cache (docs)
runs-on: ubuntu-latest
environment: ${{ vars.CLOUDFLARE_ENVIRONMENT_DOCS || 'docs' }}

steps:
- name: Check Cloudflare secrets
id: cloudflare
env:
CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
if [ -n "$CLOUDFLARE_ZONE_ID" ] && [ -n "$CLOUDFLARE_API_TOKEN" ]; then
echo "ready=true" >> "$GITHUB_OUTPUT"
else
echo "ready=false" >> "$GITHUB_OUTPUT"
fi

- name: Purge Cloudflare cache
if: steps.cloudflare.outputs.ready == 'true'
env:
CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
curl --fail --silent --show-error \
-X POST "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
-H "Content-Type: application/json" \
--data '{"purge_everything":true}'

- name: Skip purge if secrets are missing
if: steps.cloudflare.outputs.ready != 'true'
run: echo "Skipping Cloudflare cache purge because the required secrets are not configured for the docs environment."
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,7 @@ Thumbs.db
# CI / deploy artefacts
.wrangler/
.cloudflare/

# local-only assets (never publish, never commit — this repo is public)
deprecated/
.claude/
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
"build:docs": "pnpm --filter dpp-docs build",
"build": "pnpm -r build",
"check": "pnpm -r check",
"check:links": "node scripts/check-links.mjs",
"check:leakage": "node scripts/check-leakage.mjs",
"check:openapi": "pnpm --filter dpp-docs run check:openapi",
"sync:openapi": "pnpm --filter dpp-docs run sync:openapi",
"preview:landing": "pnpm --filter dpp-landing preview",
"preview:docs": "pnpm --filter dpp-docs preview",
"clean": "pnpm -r exec rm -rf dist .astro node_modules && rm -rf node_modules"
Expand Down
4 changes: 2 additions & 2 deletions packages/brand-tokens/src/colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
* Starlight CSS-variable overrides import from here (directly, or via the
* mirrored CSS custom properties in `tokens.css`).
*
* See BRAND.md section 4.1 for the editorial rationale behind each scale.
* Each scale is tuned for a specific surface; see the contrast notes below.
*/

/**
* Primary scale — navy/ice blue family, anchored on the logo
* (decision 2026-06-10, docs/redesign/DESIGN_SPEC.md §1).
* (decision 2026-06-10).
* 50–300 are ice tints (the logo stroke is 300); 500/600 are the interactive
* action blues (AA on white); 800/900 are the navy surfaces (logo field = 900).
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/brand-tokens/src/spacing.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Odal Node — spacing and radius tokens.
*
* 8-pixel base scale with a 12px outlier (see BRAND.md section 4.3).
* 8-pixel base scale with a 12px outlier.
*/

export const spacing = {
Expand Down
2 changes: 1 addition & 1 deletion packages/brand-tokens/src/tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* that does `@import "tailwindcss"; @import "@odal/brand-tokens/tokens.css";`
* gets utility classes for the full brand palette without further config.
*
* Palette decision 2026-06-10 (docs/redesign/DESIGN_SPEC.md §1): the brand
* Palette decision 2026-06-10: the brand
* colour system follows the logo — navy field (#080C2C) + ice-blue strokes
* (#B7D4F0) — with a darkened action blue for interactive elements so links
* and buttons hold AA contrast on white. The former green scale is retired.
Expand Down
2 changes: 1 addition & 1 deletion packages/brand-tokens/src/typography.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Odal Node — typography tokens.
*
* System-stack-first. No web fonts. See BRAND.md section 4.2 for rationale.
* System-stack-first. No web fonts, so no page issues a third-party font request.
*/

export const fontFamily = {
Expand Down
112 changes: 112 additions & 0 deletions scripts/check-leakage.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Fail the build if internal planning vocabulary or private-repo paths appear
// anywhere in this repository.
//
// This repo is public. Two classes leak here, and they need different scopes:
//
// * Decision-record numbers. One reached `public/openapi.yaml`, which is
// *served* at docs.odal-node.io/openapi.yaml and rendered into /api. A
// convention that only reads the source tree would never have caught it,
// which is why `public/` is explicitly in scope below.
// * Paths into the private documentation repository. Several are clickable
// relative links in READMEs that 404 for anyone browsing GitHub, and they
// disclose that repo's internal structure.
//
// Public artefacts must be self-contained: restate the design inline rather
// than pointing at something the reader cannot open.
//
// Usage: node scripts/check-leakage.mjs
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { execSync } from 'node:child_process';
import { join, relative, extname, basename } from 'node:path';

const SKIP_DIRS = new Set([
'node_modules',
'.git',
'.claude',
'dist',
'.astro',
'.pnpm-store',
'.dpp-engine',
'deprecated',
]);
// This file necessarily contains the patterns it searches for.
const SKIP_FILES = new Set(['check-leakage.mjs']);
const BINARY = new Set(['.png', '.jpg', '.jpeg', '.webp', '.ico', '.woff', '.woff2', '.pdf']);

const RULES = [
{
// eslint-disable-next-line no-useless-escape
pattern: /ADR-\d+/g,
why: 'a decision-record number — meaningless outside the private repo and stale inside it',
},
{ pattern: /WEB_CONTENT_STRATEGY/g, why: 'a private-repo document path' },
{ pattern: /DESIGN_SPEC/g, why: 'a private-repo document path' },
{ pattern: /\bBRAND\.md\b/g, why: 'a private-repo document path' },
{ pattern: /\.\.\/\.\.\/docs\//g, why: 'a relative path into the private repo' },
];

const walk = (dir, out = []) => {
for (const entry of readdirSync(dir)) {
if (SKIP_DIRS.has(entry)) continue;
const full = join(dir, entry);
if (statSync(full).isDirectory()) walk(full, out);
else if (!BINARY.has(extname(full)) && !SKIP_FILES.has(basename(full))) out.push(full);
}
return out;
};

const hits = [];
const files = walk(process.cwd());

for (const file of files) {
let text;
try {
text = readFileSync(file, 'utf8');
} catch {
continue; // unreadable or genuinely binary
}
const lines = text.split('\n');
for (const rule of RULES) {
lines.forEach((line, i) => {
const match = line.match(rule.pattern);
if (match) {
hits.push({
file: relative(process.cwd(), file),
line: i + 1,
found: match[0],
why: rule.why,
});
}
});
}
}

// The directories above are skipped because they are meant to be untracked.
// That assumption is worth testing: a stray `git add -A` on a branch that
// predates the .gitignore entry commits them to a public repository, and the
// content scan would never look. Ask git what is actually tracked.
const tracked = execSync('git ls-files', { encoding: 'utf8' })
.split('\n')
.filter((p) => p.startsWith('deprecated/') || p.startsWith('.claude/'));

if (tracked.length > 0) {
console.error(`leakage check: ${tracked.length} local-only file(s) are tracked.\n`);
for (const p of tracked.slice(0, 10)) console.error(` ${p}`);
if (tracked.length > 10) console.error(` … and ${tracked.length - 10} more`);
console.error('\n These directories are gitignored because they must not be published.');
console.error(' Untrack them with `git rm -r --cached <dir>` before committing.');
process.exit(1);
}

if (hits.length === 0) {
console.log(`leakage check: ${files.length} files scanned, clean. No local-only files tracked.`);
process.exit(0);
}

console.error(`leakage check: ${hits.length} occurrence(s) of internal vocabulary.\n`);
for (const h of hits) {
console.error(` ${h.file}:${h.line} "${h.found}" — ${h.why}`);
}
console.error('\n Restate the mechanism inline. If provenance matters, "an internal');
console.error(' decision record, dated X" is the most that may be said.');
process.exit(1);
Loading
Loading