mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-01 05:07:59 +00:00
feat: add canonical tier catalog with cross-repo drift detection (#1873)
* feat(tier-reconcile): seed tier-catalog with validated inventory Verified current-state catalog (29 entries) with cross-field invariant (tier: internal iff availability: internal). No internal Linear IDs in committed file; publicRoadmapKey slugs used instead. Canonical validator (scripts/website-catalog/canonical-validate.mjs) passes. Refs: SEN-549 * feat(tier-reconcile): add canonical catalog, sync scripts, and CI drift check Add canonical feature catalog (29 entries, no SEN-NNN identifiers) with cross-field invariant (tier:internal iff availability:internal). Sencho-owned scripts: - canonical-validate.mjs: schema + invariant validation - sync-feature-catalog.mjs: builds sanitized public projection - check-website-drift.mjs: checksum-based drift detection - test-drift-detection.mjs: unit tests for drift logic - test-catalog-no-leak.mjs: no prohibited identifiers GitHub Actions catalog-drift.yml: pull_request required check + push safeguard. Refs: SEN-549 * fix(tier-reconcile): correct relative paths in scripts for standalone runs Use fileURLToPath to resolve paths relative to script directory rather than cwd. Fixes PA-01/PA-02 script execution from any directory. Also removes SEN-NNN references from docs/feature-catalog.yaml entries and updates limitation text per audit. * ci(catalog-drift): authenticate the cross-repo website checkout The drift check reads the website repository, which is private, so the ambient workflow token cannot see it and the checkout failed with a not-found error before any validation ran. Mint a GitHub App installation token scoped to that one repository with read-only contents access, matching the pattern the docs sync workflow already uses. Also declare contents: read at the workflow level so the job stops inheriting the repository default token permissions. * fix(catalog-drift): make the drift check able to fail The job reported success no matter what the website repository contained, for two compounding reasons. The root checkout ran after the website checkout. actions/checkout cleans its destination, so it deleted website-checkout before any script ran. Reorder so the root checkout comes first. The verify step then regenerated the snapshot into that directory before comparing against it, so the comparison only ever read back what it had just written, recreating the deleted tree along the way. Drop the sync call and compare against what the website has actually committed. The comparison also trusted the checksum recorded in the snapshot metadata without checking that it described the snapshot file sitting next to it, so a hand-edited or stale snapshot passed beside fresh metadata. Require both to agree. Round out the surrounding tooling: a catalog with no entries array now fails validation instead of reporting zero entries, the unused clone branch no longer calls require from an ES module, and the failure output names the regeneration command, which is now reachable as an npm script. * ci(catalog-drift): check for website-side drift on a daily schedule The path filters only fire on changes inside this repository, so an edited or reverted snapshot in the website repository left the check green while the two were genuinely out of sync. A daily run closes that window without waiting for someone to touch the canonical catalog. * fix(catalog-scripts): check every prohibited key and drop an inert test The leak check listed five prohibited keys but only tested three by hand, so an entry carrying route or service would have reached the public catalog unnoticed. Drive the loop from the list instead. Remove test-drift-detection.mjs. Nothing invoked it, and it asserted against a reimplemented normalizer rather than the drift script it named, so it reported coverage it did not provide.
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
name: Catalog Drift Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'docs/feature-catalog.yaml'
|
||||
- 'scripts/website-catalog/**'
|
||||
- '.github/workflows/catalog-drift.yml'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'docs/feature-catalog.yaml'
|
||||
- 'scripts/website-catalog/**'
|
||||
# Drift can also be introduced from the website side, which changes nothing
|
||||
# in this repository and so triggers none of the filters above. Check daily
|
||||
# so an edited or reverted snapshot cannot sit undetected.
|
||||
schedule:
|
||||
- cron: '17 6 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Mint a read-only installation token scoped to the website repository
|
||||
# only. The default GITHUB_TOKEN cannot read Studio-Saelix/sencho-website.
|
||||
- name: Generate GitHub App installation token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: sencho-website
|
||||
permission-contents: read
|
||||
|
||||
# The root checkout must come first. actions/checkout cleans its
|
||||
# destination, so running it after the nested one deletes website-checkout.
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ github.repository_owner }}/sencho-website
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
path: website-checkout
|
||||
- name: Install root dependencies
|
||||
run: npm ci
|
||||
- name: Canonical validation
|
||||
run: node scripts/website-catalog/canonical-validate.mjs
|
||||
# Compare the canonical catalog against the snapshot the website has
|
||||
# actually committed. Do not run sync-feature-catalog here: it rewrites
|
||||
# that snapshot from the canonical file, so the comparison would only
|
||||
# ever read back what it just wrote and could never report drift.
|
||||
- name: Compare against the committed website snapshot
|
||||
run: node scripts/website-catalog/check-website-drift.mjs --website-dir website-checkout
|
||||
- name: Verify no internal identifiers in canonical file
|
||||
run: node scripts/website-catalog/test-catalog-no-leak.mjs
|
||||
@@ -0,0 +1,374 @@
|
||||
# Canonical feature catalog — Sencho tier reconciliation
|
||||
# Source of truth. No internal Linear IDs stored here.
|
||||
# Internal planning provenance lives in Linear, not in committed files.
|
||||
# Schema: id, name, tier (community|admiral|internal), availability (shipped|planned|internal),
|
||||
# category, publicName, publicRoadmapKey, summary, description, limitation,
|
||||
# featured (bool), homepageOrder (int when featured=true)
|
||||
# Cross-field invariant: tier: internal iff availability: internal.
|
||||
|
||||
version: '1'
|
||||
schema: 'canonical-v1'
|
||||
|
||||
entries:
|
||||
# ===== Core Compose & Deployments (Community) =====
|
||||
- id: compose-editor
|
||||
name: Compose editor and stack management
|
||||
tier: community
|
||||
availability: shipped
|
||||
category: compose-deploy
|
||||
publicName: Compose editor, templates, Git sources
|
||||
publicRoadmapKey: compose-editor
|
||||
summary: Manage, edit, and deploy Compose stacks from YAML or templates.
|
||||
description: Monaco YAML editor with syntax validation, multi-file Compose from Git sources, drift detection for documentation, and ordered multi-file Compose flows.
|
||||
limitation: ''
|
||||
featured: true
|
||||
homepageOrder: 1
|
||||
|
||||
- id: git-pull-preview
|
||||
name: Git pull preview and apply flow
|
||||
tier: community
|
||||
availability: shipped
|
||||
category: compose-deploy
|
||||
publicName: Git pull preview and apply
|
||||
publicRoadmapKey: git-pull-preview
|
||||
summary: Preview and apply changes from Git source before deploying.
|
||||
featured: false
|
||||
|
||||
- id: atomic-deploy-rollback
|
||||
name: Atomic deployments and rollback
|
||||
tier: community
|
||||
availability: shipped
|
||||
category: compose-deploy
|
||||
publicName: Atomic deploys + rollback
|
||||
publicRoadmapKey: atomic-deploy-rollback
|
||||
summary: Atomic updates with health-gated rollback support.
|
||||
description: Standard and atomic deploy/update workflows, rollback support, health-gated updates, stalled update detection and recovery.
|
||||
limitation: ''
|
||||
featured: true
|
||||
homepageOrder: 2
|
||||
|
||||
# ===== Fleet & Orchestration (Community) =====
|
||||
- id: multi-node-fleet
|
||||
name: Multi-node fleet visibility
|
||||
tier: community
|
||||
availability: shipped
|
||||
category: fleet-orchestration
|
||||
publicName: Multi-node fleet visibility
|
||||
publicRoadmapKey: multi-node-fleet
|
||||
summary: Visibility into nodes, stacks, and containers across a fleet.
|
||||
description: Multi-node support, proxy-connected nodes, pilot agent-connected nodes, node enrollment, node compatibility checks.
|
||||
limitation: ''
|
||||
featured: true
|
||||
homepageOrder: 3
|
||||
|
||||
- id: fleet-sync-baseline
|
||||
name: Fleet Sync (policy replication)
|
||||
tier: community
|
||||
availability: shipped
|
||||
category: fleet-orchestration
|
||||
publicName: Fleet Sync (policy replication)
|
||||
publicRoadmapKey: fleet-sync-baseline
|
||||
summary: Replicate security policies, suppressions, and acknowledgements across nodes.
|
||||
limitation: 'Baseline Fleet Sync is Community; additional governance enforcement is Admiral-planned.'
|
||||
featured: true
|
||||
homepageOrder: 4
|
||||
|
||||
- id: blueprint-reconcile
|
||||
name: Blueprints and drift reconciliation
|
||||
tier: community
|
||||
availability: shipped
|
||||
category: fleet-orchestration
|
||||
publicName: Blueprints + drift detection + reconciliation
|
||||
publicRoadmapKey: blueprint-reconcile
|
||||
summary: Declarative fleet state with drift detection, manual reconciliation, and label-based node targeting.
|
||||
description: Blueprints, label-based targeting, drift detection, drift notification and correction, manual reconciliation with Apply Now, stateful deployment review.
|
||||
limitation: ''
|
||||
featured: false
|
||||
|
||||
# ===== Security Foundations (Community) =====
|
||||
- id: rbac-five-role
|
||||
name: Full built-in RBAC
|
||||
tier: community
|
||||
availability: shipped
|
||||
category: security-foundation
|
||||
publicName: Full built-in RBAC (Admin, Viewer, Deployer, Node Admin, Auditor)
|
||||
publicRoadmapKey: rbac-five-role
|
||||
summary: Five built-in roles with per-resource scoped assignments.
|
||||
description: Admin, Viewer, Deployer, Node Admin, Auditor roles; per-resource scoped assignments; unlimited users.
|
||||
limitation: ''
|
||||
featured: true
|
||||
homepageOrder: 5
|
||||
|
||||
- id: fleet-secrets
|
||||
name: Fleet Secrets (encrypted env bundles)
|
||||
tier: community
|
||||
availability: shipped
|
||||
category: security-foundation
|
||||
publicName: Fleet Secrets (encrypted env bundles)
|
||||
publicRoadmapKey: fleet-secrets
|
||||
summary: Encrypted versioned environment bundles for fleet-wide secrets.
|
||||
limitation: ''
|
||||
featured: true
|
||||
homepageOrder: 6
|
||||
|
||||
- id: deploy-enforcement
|
||||
name: Deploy enforcement policies
|
||||
tier: community
|
||||
availability: shipped
|
||||
category: security-foundation
|
||||
publicName: Deploy enforcement policies (CVE gate)
|
||||
publicRoadmapKey: deploy-enforcement
|
||||
summary: Block deploys based on vulnerability scanning results.
|
||||
featured: false
|
||||
|
||||
# ===== Identity & Access (Community / Admiral split) =====
|
||||
- id: custom-oidc-ssenders
|
||||
name: Custom OIDC and SSO presets
|
||||
tier: community
|
||||
availability: shipped
|
||||
category: identity-access
|
||||
publicName: Custom OIDC + Google/GitHub/Okta SSO
|
||||
publicRoadmapKey: custom-oidc-sso
|
||||
summary: Self-hosted identity with any OIDC IdP and one-click presets.
|
||||
description: Custom OIDC SSO, Google, GitHub, Okta presets; 2FA/MFA; recovery codes.
|
||||
limitation: ''
|
||||
featured: true
|
||||
homepageOrder: 7
|
||||
|
||||
- id: ldap-ad
|
||||
name: LDAP / Active Directory
|
||||
tier: admiral
|
||||
availability: shipped
|
||||
category: identity-access
|
||||
publicName: LDAP / Active Directory
|
||||
publicRoadmapKey: ldap-ad
|
||||
summary: Enterprise identity integration for organizational assurance.
|
||||
description: LDAP / Active Directory identity provider integration.
|
||||
limitation: 'Requires Admiral (paid) license; handled by requireTierForSsoProvider in backend middleware.'
|
||||
featured: true
|
||||
homepageOrder: 8
|
||||
|
||||
# ===== Audit & Evidence (Community partial / Admiral full) =====
|
||||
- id: audit-log-14day
|
||||
name: Recent activity log (14-day window)
|
||||
tier: community
|
||||
availability: shipped
|
||||
category: identity-access
|
||||
publicName: Recent audit window (14 days)
|
||||
publicRoadmapKey: audit-log-14day
|
||||
summary: Basic audit visibility for recent actions.
|
||||
description: Basic audit log with 14-day retention; visible to Community users.
|
||||
limitation: 'Full audit log with export, anomaly detection, and extended retention requires Admiral.'
|
||||
featured: false
|
||||
|
||||
- id: audit-log-full
|
||||
name: Full audit log (export, anomaly detection, retention)
|
||||
tier: admiral
|
||||
availability: shipped
|
||||
category: identity-access
|
||||
publicName: Full audit log (export + anomaly detection)
|
||||
publicRoadmapKey: audit-log-full
|
||||
summary: Durable audit evidence with export, anomaly detection, and retention.
|
||||
description: Full audit log with export, anomaly detection, retention policies; managed audit evidence.
|
||||
limitation: 'Paid-only; requires Admiral license. Enforced by requirePaid in auditLog routes.'
|
||||
featured: true
|
||||
homepageOrder: 9
|
||||
|
||||
# ===== Security Scanning & Policy (Community core / Admiral planned) =====
|
||||
- id: scan-on-demand
|
||||
name: On-demand vulnerability scanning
|
||||
tier: community
|
||||
availability: shipped
|
||||
category: security-foundation
|
||||
publicName: On-demand vulnerability scanning
|
||||
publicRoadmapKey: scan-on-demand
|
||||
summary: Scan stacks and nodes for CVEs with suppression and acknowledgment.
|
||||
description: On-demand scanning, node-wide scanning, CVE suppressions, misconfiguration acknowledgements, deploy enforcement policies, SARIF export, scan policy packs.
|
||||
limitation: ''
|
||||
featured: true
|
||||
homepageOrder: 10
|
||||
|
||||
- id: change-review-planned
|
||||
name: Compose Change Review
|
||||
tier: admiral
|
||||
availability: planned
|
||||
category: security-foundation
|
||||
publicName: Change Review (planned)
|
||||
publicRoadmapKey: change-review-planned
|
||||
summary: Required approval workflow for Compose changes before deployment.
|
||||
description: Governance layer requiring approvals before deploy.
|
||||
limitation: 'Approved roadmap; not yet shipped. Explicit Planned label in all public surfaces.'
|
||||
featured: true
|
||||
homepageOrder: 11
|
||||
|
||||
# ===== Fleet Operations (Community core / Admiral planned) =====
|
||||
- id: fleet-actions-bulk
|
||||
name: Fleet Actions and bulk operations
|
||||
tier: community
|
||||
availability: shipped
|
||||
category: fleet-orchestration
|
||||
publicName: Fleet Actions + bulk operations
|
||||
publicRoadmapKey: fleet-actions-bulk
|
||||
summary: Bulk stack lifecycle actions across nodes.
|
||||
description: Bulk actions on stacks, labels, fleet-wide schedules.
|
||||
limitation: ''
|
||||
featured: true
|
||||
homepageOrder: 12
|
||||
|
||||
- id: protected-stacks-planned
|
||||
name: Protected Stacks / Nodes
|
||||
tier: admiral
|
||||
availability: planned
|
||||
category: fleet-orchestration
|
||||
publicName: Protected stacks / nodes (planned)
|
||||
publicRoadmapKey: protected-stacks-planned
|
||||
summary: Protected stacks and nodes with maintenance windows and break-glass.
|
||||
description: Protected workflows, maintenance windows, freezes, break-glass.
|
||||
limitation: 'Planned Admiral capability; not yet available in public matrix as shipped.'
|
||||
featured: true
|
||||
homepageOrder: 13
|
||||
|
||||
- id: fleet-readiness-planned
|
||||
name: Fleet Readiness Score
|
||||
tier: admiral
|
||||
availability: planned
|
||||
category: fleet-orchestration
|
||||
publicName: Fleet Readiness Score (planned)
|
||||
publicRoadmapKey: fleet-readiness-planned
|
||||
summary: Organizational readiness score for fleet health and compliance.
|
||||
limitation: 'Planned Admiral capability; not yet available in public matrix as shipped.'
|
||||
featured: false
|
||||
|
||||
# ===== Governance & Policy (Community core / Admiral planned) =====
|
||||
- id: api-tokens
|
||||
name: API tokens
|
||||
tier: community
|
||||
availability: shipped
|
||||
category: identity-access
|
||||
publicName: API tokens for CI/CD
|
||||
publicRoadmapKey: api-tokens
|
||||
summary: Long-lived machine credentials for automation.
|
||||
limitation: ''
|
||||
featured: true
|
||||
homepageOrder: 14
|
||||
|
||||
- id: policy-pack-planned
|
||||
name: Policy Pack Assignment
|
||||
tier: admiral
|
||||
availability: planned
|
||||
category: governance
|
||||
publicName: Policy pack assignment (planned)
|
||||
publicRoadmapKey: policy-pack-planned
|
||||
summary: Organization-wide policy pack enforcement with governed exceptions.
|
||||
limitation: 'Planned Admiral capability; not yet available in public matrix as shipped.'
|
||||
featured: false
|
||||
|
||||
# ===== Recovery (Community basic / Admiral managed) =====
|
||||
- id: manual-snapshots
|
||||
name: Manual fleet snapshots
|
||||
tier: community
|
||||
availability: shipped
|
||||
category: recovery
|
||||
publicName: Manual fleet snapshots
|
||||
publicRoadmapKey: manual-snapshots
|
||||
summary: User-initiated fleet-wide backups.
|
||||
limitation: ''
|
||||
featured: false
|
||||
|
||||
- id: recovery-vault
|
||||
name: Recovery Vault (managed off-site snapshots)
|
||||
tier: admiral
|
||||
availability: shipped
|
||||
category: recovery
|
||||
publicName: Recovery Vault (managed off-site)
|
||||
publicRoadmapKey: recovery-vault
|
||||
summary: Managed recovery service with durable off-site storage.
|
||||
description: Recovery vault with managed storage, verification, monitoring, retention, and restore-readiness evidence.
|
||||
limitation: 'Requires Admiral; managed continuity pillar.'
|
||||
featured: true
|
||||
homepageOrder: 15
|
||||
|
||||
# ===== Business Assurance (Admiral only) =====
|
||||
- id: hardened-build
|
||||
name: Hardened Build image channel
|
||||
tier: admiral
|
||||
availability: shipped
|
||||
category: assurance
|
||||
publicName: Hardened Build image channel
|
||||
publicRoadmapKey: hardened-build
|
||||
summary: Supported release channel with defined supply chain and support commitment.
|
||||
limitation: 'Admiral entitlement; never a headline reason to purchase.'
|
||||
featured: true
|
||||
homepageOrder: 16
|
||||
|
||||
- id: priority-support
|
||||
name: Priority Studio Saelix support
|
||||
tier: admiral
|
||||
availability: shipped
|
||||
category: assurance
|
||||
publicName: Priority email support
|
||||
publicRoadmapKey: priority-support
|
||||
summary: Accountable support commitment for Admiral customers.
|
||||
limitation: 'Requires Admiral.'
|
||||
featured: false
|
||||
|
||||
- id: incident-timeline-planned
|
||||
name: Incident Timeline
|
||||
tier: admiral
|
||||
availability: planned
|
||||
category: assurance
|
||||
publicName: Incident Timeline (planned)
|
||||
publicRoadmapKey: incident-timeline-planned
|
||||
summary: Durable incident timeline and service context.
|
||||
limitation: 'Planned Admiral capability; not yet available in public matrix as shipped.'
|
||||
featured: false
|
||||
|
||||
# ===== Registration & Registry (Community core / Admiral ECR temporary) =====
|
||||
- id: docker-hub-ghcr-custom
|
||||
name: Local registry credentials (Docker Hub, GHCR, custom)
|
||||
tier: community
|
||||
availability: shipped
|
||||
category: security-foundation
|
||||
publicName: Local registry credentials
|
||||
publicRoadmapKey: docker-hub-ghcr-custom
|
||||
summary: Store and manage registry authentication.
|
||||
limitation: ''
|
||||
featured: false
|
||||
|
||||
- id: ecr-admiral-temporary
|
||||
name: AWS ECR registry credentials
|
||||
tier: admiral
|
||||
availability: shipped
|
||||
category: security-foundation
|
||||
publicName: AWS ECR registry credentials (temporary availability)
|
||||
publicRoadmapKey: ecr-admiral-temporary
|
||||
summary: AWS Elastic Container Registry authentication.
|
||||
description: Temporary Admiral access to AWS ECR registry credentials; not positioned as core value.
|
||||
limitation: 'Temporary availability; must not be positioned as core Admiral value in public copy.'
|
||||
featured: false
|
||||
|
||||
# ===== Internal / Experimental (not public) =====
|
||||
- id: mesh-routing
|
||||
name: Mesh / Routing
|
||||
tier: internal
|
||||
availability: internal
|
||||
category: internal
|
||||
publicName: Mesh / Routing (internal)
|
||||
publicRoadmapKey: mesh-routing
|
||||
summary: ''
|
||||
description: Internal experimental feature; not public tier; separate graduation decision required.
|
||||
limitation: 'Internal; excluded from all public matrices. Must remain hidden until graduation decision lands.'
|
||||
featured: false
|
||||
homepageOrder: 99
|
||||
- id: experimental-flag
|
||||
name: SENCHO_EXPERIMENTAL
|
||||
tier: internal
|
||||
availability: internal
|
||||
category: internal
|
||||
publicName: 'SENCHO_EXPERIMENTAL (internal discovery gate)'
|
||||
publicRoadmapKey: experimental-flag
|
||||
summary: Internal feature discovery gate; not a tier.
|
||||
description: Feature flag controlling internal feature visibility; must not be used as user-facing marketing.
|
||||
limitation: 'Not a public tier; never advertised in public comparison or docs.'
|
||||
featured: false
|
||||
homepageOrder: 100
|
||||
@@ -7,6 +7,9 @@
|
||||
"test": "cd backend && npm test",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"catalog:validate": "node scripts/website-catalog/canonical-validate.mjs",
|
||||
"catalog:sync": "node scripts/website-catalog/sync-feature-catalog.mjs",
|
||||
"catalog:drift": "node scripts/website-catalog/check-website-drift.mjs",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"repository": {
|
||||
@@ -29,6 +32,7 @@
|
||||
"@commitlint/config-conventional": "^21.0.2",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"husky": "^9.1.7",
|
||||
"js-yaml": "^4.3.1",
|
||||
"otplib": "^13.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Sencho tier-reconciliation: canonical catalog validator
|
||||
* Verifies docs/feature-catalog.yaml against the canonical schema and
|
||||
* the required cross-field invariant (tier: internal iff availability: internal).
|
||||
* Exits 0 on valid, exits 1 with diagnostic lines on invalid.
|
||||
*/
|
||||
import yaml from 'js-yaml';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { fileURLToPath } from 'url';
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CATALOG_FILE = path.resolve(__dirname, '../../docs/feature-catalog.yaml');
|
||||
|
||||
const VALID_TIERS = new Set(['community', 'admiral', 'internal']);
|
||||
const VALID_AVAILABILITY = new Set(['shipped', 'planned', 'internal']);
|
||||
const VALID_CATEGORIES = new Set([
|
||||
'compose-deploy', 'fleet-orchestration', 'security-foundation',
|
||||
'automation-operations', 'recovery', 'identity-access',
|
||||
'governance', 'assurance', 'internal',
|
||||
]);
|
||||
|
||||
const errors = [];
|
||||
|
||||
function error(msg) { errors.push('ERROR: ' + msg); }
|
||||
|
||||
function readCatalog() {
|
||||
const text = fs.readFileSync(CATALOG_FILE, 'utf8');
|
||||
const doc = yaml.load(text, { schema: yaml.CORE_SCHEMA });
|
||||
if (!doc || typeof doc !== 'object') {
|
||||
throw new Error('catalog is not a YAML mapping');
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const doc = readCatalog();
|
||||
if (!Array.isArray(doc.entries)) {
|
||||
console.error('FAIL: catalog has no entries array.');
|
||||
process.exit(1);
|
||||
}
|
||||
const entries = doc.entries;
|
||||
const ids = new Set();
|
||||
|
||||
for (const [i, entry] of entries.entries()) {
|
||||
const prefix = `entry[${i}].id=${entry?.id ?? '(missing)'}`;
|
||||
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
error(`${prefix}: entry is not an object`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.id) error(`${prefix}: missing id`);
|
||||
else if (ids.has(entry.id)) error(`${prefix}: duplicate id "${entry.id}"`);
|
||||
else ids.add(entry.id);
|
||||
|
||||
if (!entry.name) error(`${prefix}: missing name`);
|
||||
if (!VALID_TIERS.has(entry.tier))
|
||||
error(`${prefix}: invalid tier "${entry.tier}"; must be one of community/admiral/internal`);
|
||||
if (!VALID_AVAILABILITY.has(entry.availability))
|
||||
error(`${prefix}: invalid availability "${entry.availability}"; must be one of shipped/planned/internal`);
|
||||
|
||||
// Cross-field invariant: tier: internal iff availability: internal
|
||||
if (entry.tier === 'internal' && entry.availability !== 'internal')
|
||||
error(`${prefix}: tier: internal requires availability: internal`);
|
||||
if (entry.tier !== 'internal' && entry.availability === 'internal')
|
||||
error(`${prefix}: non-internal tier requires non-internal availability`);
|
||||
|
||||
if (entry.availability === 'planned') {
|
||||
if (!entry.publicRoadmapKey) error(`${prefix}: planned entry must have publicRoadmapKey`);
|
||||
}
|
||||
|
||||
if (!VALID_CATEGORIES.has(entry.category))
|
||||
error(`${prefix}: unknown category "${entry.category}"`);
|
||||
|
||||
// Internal-only fields must not leak into committed catalog.
|
||||
// The canonical file IS public, so we enforce: no linear, no evidence with internal identifiers,
|
||||
// no internalNote. Public-name fields only.
|
||||
if (entry.linear) {
|
||||
// Reject any internal Linear identifier in committed file.
|
||||
if (/SEN-[0-9]/.test(String(entry.linear)))
|
||||
error(`${prefix}: committed catalog contains internal Linear identifier in linear field ("${entry.linear}"); use publicRoadmapKey instead`);
|
||||
}
|
||||
// No evidence field allowed in canonical committed file (evidence stays internal).
|
||||
if (entry.evidence)
|
||||
error(`${prefix}: evidence field must not appear in committed canonical catalog (use Linear/non-public record for evidence); got: ${entry.evidence}`);
|
||||
if (entry.internalNote)
|
||||
error(`${prefix}: internalNote field must not appear in committed canonical catalog`);
|
||||
}
|
||||
|
||||
if (errors.length === 0) {
|
||||
console.log(`VALID: catalog has ${entries.length} entries; all invariants pass.`);
|
||||
process.exit(0);
|
||||
} else {
|
||||
for (const msg of errors) console.error(msg);
|
||||
console.error(`FAIL: ${errors.length} error(s) found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (e) {
|
||||
console.error('FAIL: ' + (e.message || e));
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Sencho tier-reconciliation: website drift detector
|
||||
* Compares the current canonical catalog to the website's committed
|
||||
* catalog-snapshot. Drift identity is the normalized content checksum.
|
||||
* Commit SHA is NOT part of drift identity.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/website-catalog/check-website-drift.mjs --website-dir <path>
|
||||
* node scripts/website-catalog/check-website-drift.mjs --website-ref <git-ref>
|
||||
*/
|
||||
import yaml from 'js-yaml';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { execFileSync } from 'child_process';
|
||||
import os from 'os';
|
||||
import { createHash } from 'crypto';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CANONICAL_FILE = path.resolve(__dirname, '../../docs/feature-catalog.yaml');
|
||||
const PUBLIC_FIELDS = new Set([
|
||||
'id', 'publicName', 'summary', 'description', 'category',
|
||||
'tier', 'availability', 'featured', 'homepageOrder', 'publicRoadmapKey',
|
||||
]);
|
||||
|
||||
function normalizeYaml(obj) {
|
||||
return yaml.dump(obj, { sortKeys: true, lineWidth: -1 });
|
||||
}
|
||||
|
||||
function computeChecksum(content) {
|
||||
return createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
function loadSnapshot(websiteDir) {
|
||||
const snapshotPath = path.join(websiteDir, 'src/data/catalog-snapshot.yaml');
|
||||
if (!fs.existsSync(snapshotPath)) {
|
||||
return null;
|
||||
}
|
||||
const text = fs.readFileSync(snapshotPath, 'utf8');
|
||||
const doc = yaml.load(text, { schema: yaml.CORE_SCHEMA });
|
||||
if (!doc || !Array.isArray(doc.entries)) return null;
|
||||
return { doc, text, snapshotPath };
|
||||
}
|
||||
|
||||
function buildProjection(canonical) {
|
||||
return canonical.entries
|
||||
.filter((e) => e.tier !== 'internal' && e.availability !== 'internal')
|
||||
.map((e) => {
|
||||
const pub = {};
|
||||
for (const key of PUBLIC_FIELDS) {
|
||||
if (key in e) pub[key] = e[key];
|
||||
}
|
||||
return pub;
|
||||
});
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
let websiteDir = null;
|
||||
let websiteRef = null;
|
||||
let cleanup = null;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--website-dir' && args[i + 1]) {
|
||||
websiteDir = args[i + 1]; i++;
|
||||
} else if (args[i] === '--website-ref' && args[i + 1]) {
|
||||
websiteRef = args[i + 1]; i++;
|
||||
}
|
||||
}
|
||||
|
||||
if (websiteRef && !websiteDir) {
|
||||
// Clone to temp dir
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-website-'));
|
||||
execFileSync('git', ['clone', '--depth', '1', '--branch', websiteRef,
|
||||
'https://github.com/Studio-Saelix/sencho-website.git', tmp],
|
||||
{ stdio: 'pipe' });
|
||||
websiteDir = tmp;
|
||||
cleanup = tmp;
|
||||
}
|
||||
|
||||
if (!websiteDir) {
|
||||
console.error('ERROR: --website-dir or --website-ref is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Read canonical catalog.
|
||||
const canonicalText = fs.readFileSync(CANONICAL_FILE, 'utf8');
|
||||
const canonical = yaml.load(canonicalText, { schema: yaml.CORE_SCHEMA });
|
||||
const projection = buildProjection(canonical);
|
||||
const projectionText = normalizeYaml({ entries: projection });
|
||||
const currentChecksum = computeChecksum(projectionText);
|
||||
|
||||
// Read committed snapshot.
|
||||
const snap = loadSnapshot(websiteDir);
|
||||
if (!snap) {
|
||||
console.error(`FAIL: no catalog-snapshot.yaml at ${path.join(websiteDir, 'src/data/')}`);
|
||||
if (cleanup) fs.rmSync(cleanup, { recursive: true, force: true });
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const metaPath = path.join(websiteDir, 'src/data/catalog-snapshot.meta.json');
|
||||
let committedChecksum = null;
|
||||
if (fs.existsSync(metaPath)) {
|
||||
try {
|
||||
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
|
||||
committedChecksum = meta.checksum;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Compute checksum of committed snapshot text (the actual file content).
|
||||
const snapChecksum = computeChecksum(snap.text);
|
||||
|
||||
if (cleanup) fs.rmSync(cleanup, { recursive: true, force: true });
|
||||
|
||||
// The snapshot file must match the canonical projection, and the metadata
|
||||
// must describe that same file. Trusting the metadata alone would let a
|
||||
// hand-edited or stale snapshot pass beside a freshly written meta.json.
|
||||
if (currentChecksum === committedChecksum && snapChecksum === committedChecksum) {
|
||||
console.log(`OK: no drift. checksum=${currentChecksum.slice(0, 12)}...`);
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error('DRIFT DETECTED:');
|
||||
console.error(` current canonical checksum: ${currentChecksum}`);
|
||||
console.error(` committed snapshot checksum: ${committedChecksum ?? '(none)'}`);
|
||||
console.error(` committed file checksum: ${snapChecksum}`);
|
||||
console.error('');
|
||||
console.error('Regenerate the website snapshot and commit src/data/ in the website repo:');
|
||||
console.error(' npm run catalog:sync -- --website-dir <path-to-sencho-website>');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Sencho tier-reconciliation: sync feature catalog to website
|
||||
* Reads docs/feature-catalog.yaml (canonical, public-safe), builds a sanitized
|
||||
* public projection, and writes it to <website-dir>/src/data/.
|
||||
*
|
||||
* Usage: node scripts/website-catalog/sync-feature-catalog.mjs --website-dir <path>
|
||||
*
|
||||
* The canonical file contains only public-safe identifiers (no SEN-NNN Linear IDs).
|
||||
* The public projection contains only shipped/planned community|admiral entries
|
||||
* with public presentation fields. Internal entries are excluded.
|
||||
*/
|
||||
import yaml from 'js-yaml';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { createHash } from 'crypto';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CANONICAL_FILE = path.resolve(__dirname, '../../docs/feature-catalog.yaml');
|
||||
const SCHEMA_VERSION = '1';
|
||||
const OUTPUT_SNAPSHOT = 'catalog-snapshot.yaml';
|
||||
const OUTPUT_META = 'catalog-snapshot.meta.json';
|
||||
|
||||
// Fields allowed in the public projection.
|
||||
const PUBLIC_FIELDS = new Set([
|
||||
'id', 'publicName', 'summary', 'description', 'category',
|
||||
'tier', 'availability', 'featured', 'homepageOrder', 'publicRoadmapKey',
|
||||
]);
|
||||
|
||||
function normalizeYaml(obj) {
|
||||
return yaml.dump(obj, { sortKeys: true, lineWidth: -1, commentString: '' });
|
||||
}
|
||||
|
||||
function computeChecksum(content) {
|
||||
return createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
let websiteDir = null;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--website-dir' && args[i + 1]) {
|
||||
websiteDir = args[i + 1];
|
||||
i++;
|
||||
} else if (args[i] === '--help') {
|
||||
console.log('Usage: node sync-feature-catalog.mjs --website-dir <path>');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
if (!websiteDir) {
|
||||
console.error('ERROR: --website-dir is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Read and parse canonical catalog.
|
||||
const catText = fs.readFileSync(CANONICAL_FILE, 'utf8');
|
||||
const doc = yaml.load(catText, { schema: yaml.CORE_SCHEMA });
|
||||
if (!doc || !Array.isArray(doc.entries)) {
|
||||
console.error('ERROR: canonical catalog is missing entries array');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Build sanitized public projection.
|
||||
const projection = doc.entries
|
||||
.filter((e) => e.tier !== 'internal' && e.availability !== 'internal')
|
||||
.map((e) => {
|
||||
const pub = {};
|
||||
for (const key of PUBLIC_FIELDS) {
|
||||
if (key in e) pub[key] = e[key];
|
||||
}
|
||||
return pub;
|
||||
});
|
||||
|
||||
const projectionText = normalizeYaml({ entries: projection });
|
||||
const checksum = computeChecksum(projectionText);
|
||||
|
||||
// Write snapshot.
|
||||
const outDir = path.join(websiteDir, 'src', 'data');
|
||||
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(path.join(outDir, OUTPUT_SNAPSHOT), projectionText, 'utf8');
|
||||
|
||||
// Write metadata (checksum-based provenance, no commit SHA).
|
||||
const meta = {
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
checksum,
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(outDir, OUTPUT_META),
|
||||
JSON.stringify(meta, null, 2) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
console.log(`Synced ${projection.length} public entries to ${outDir}/`);
|
||||
console.log(`Checksum: ${checksum}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'fs';
|
||||
import yaml from 'js-yaml';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CATALOG_FILE = path.resolve(__dirname, '../../docs/feature-catalog.yaml');
|
||||
const PROHIBITED_KEYS = ['linear', 'evidence', 'internalNote', 'route', 'service'];
|
||||
|
||||
const text = fs.readFileSync(CATALOG_FILE, 'utf8');
|
||||
const doc = yaml.load(text, { schema: yaml.CORE_SCHEMA });
|
||||
|
||||
let failed = false;
|
||||
function fail(msg) { console.error('NO-LEAK FAIL: ' + msg); failed = true; }
|
||||
|
||||
if (!doc || !Array.isArray(doc.entries)) fail('catalog has no entries array');
|
||||
else {
|
||||
for (const entry of doc.entries) {
|
||||
const id = entry?.id || '(unknown)';
|
||||
for (const key of PROHIBITED_KEYS) {
|
||||
if (entry[key]) fail(`entry[${id}] contains prohibited ${key}`);
|
||||
}
|
||||
for (const [k, v] of Object.entries(entry)) {
|
||||
if (typeof v === 'string' && /SEN-[0-9]+/.test(v))
|
||||
fail(`entry[${id}] key "${k}" has SEN-NNN: "${v}"`);
|
||||
}
|
||||
if (entry.tier === 'internal' && entry.availability !== 'internal')
|
||||
fail(`entry[${id}] tier internal requires availability internal`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) process.exit(1);
|
||||
console.log('NO-LEAK PASS');
|
||||
process.exit(0);
|
||||
Reference in New Issue
Block a user