feat(ci): add Forgejo Actions workflow for auto-deploy on LinkML schema changes
Some checks are pending
Deploy Frontend / build-and-deploy (push) Waiting to run

Infrastructure changes to enable automatic frontend deployment when schemas change:

- Add .forgejo/workflows/deploy-frontend.yml workflow triggered by:
  - Changes to frontend/** or schemas/20251121/linkml/**
  - Manual workflow dispatch

- Rewrite generate-schema-manifest.cjs to properly scan all schema directories
  - Recursively scans classes, enums, slots, modules directories
  - Uses singular category names (class, enum, slot) matching TypeScript types
  - Includes all 4 main schemas at root level
  - Skips archive directories and backup files

- Update schema-loader.ts to match new manifest format
  - Add SchemaCategory interface
  - Update SchemaManifest to use categories as array
  - Add flattenCategories() helper function
  - Add getSchemaCategories() and getSchemaCategoriesSync() functions

The workflow builds frontend with updated manifest and deploys to bronhouder.nl
This commit is contained in:
kempersc 2026-01-11 14:16:57 +01:00
parent 329b341bb1
commit 0f7fbf1ca0
4 changed files with 3340 additions and 2950 deletions

View file

@ -0,0 +1,116 @@
# GLAM Frontend Deployment
# Automatically builds and deploys frontend to bronhouder.nl when changes are pushed
#
# Triggered by:
# - Any changes to frontend/ directory
# - Any changes to schemas/20251121/linkml/ directory (LinkML schema updates)
# - Manual workflow dispatch
#
# This workflow:
# 1. Syncs LinkML schemas to frontend/public
# 2. Regenerates the schema manifest (for LinkML viewer)
# 3. Builds the frontend (Vite/React)
# 4. Deploys to server via rsync
name: Deploy Frontend
on:
push:
branches:
- master
paths:
- 'frontend/**'
- 'schemas/20251121/linkml/**'
- '.forgejo/workflows/deploy-frontend.yml'
# Allow manual trigger
workflow_dispatch:
env:
NODE_VERSION: '20'
SERVER_IP: '91.98.224.44'
SERVER_USER: 'root'
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: frontend
run: npm ci
- name: Sync LinkML schemas to frontend
working-directory: frontend
run: npm run sync-schemas
- name: Generate schema manifest
working-directory: frontend
run: npm run generate-manifest
- name: Build frontend
working-directory: frontend
run: npm run build
env:
VITE_OXIGRAPH_URL: https://bronhouder.nl
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H ${{ env.SERVER_IP }} >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Deploy frontend to server
run: |
echo "Deploying frontend build to server..."
rsync -avz --progress --delete \
-e "ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no" \
frontend/dist/ \
${{ env.SERVER_USER }}@${{ env.SERVER_IP }}:/var/www/glam-frontend/
- name: Verify deployment
run: |
echo "Verifying deployment..."
# Check that Caddy is serving the frontend
HTTP_STATUS=$(curl -s -o /dev/null -w '%{http_code}' https://bronhouder.nl/)
if [ "$HTTP_STATUS" = "200" ]; then
echo "Frontend deployment verified: OK (HTTP $HTTP_STATUS)"
else
echo "Warning: Frontend returned HTTP $HTTP_STATUS"
fi
# Check manifest is accessible
MANIFEST_STATUS=$(curl -s -o /dev/null -w '%{http_code}' https://bronhouder.nl/schemas/20251121/linkml/manifest.json)
if [ "$MANIFEST_STATUS" = "200" ]; then
echo "Schema manifest accessible: OK"
# Show file count from manifest
curl -s https://bronhouder.nl/schemas/20251121/linkml/manifest.json | jq '.totalFiles' | xargs -I {} echo "Total schema files: {}"
else
echo "Warning: Schema manifest returned HTTP $MANIFEST_STATUS"
fi
- name: Deployment summary
run: |
echo "============================================"
echo " Frontend Deployment Complete!"
echo "============================================"
echo ""
echo "Server: ${{ env.SERVER_IP }}"
echo "Frontend URL: https://bronhouder.nl/"
echo "LinkML Viewer: https://bronhouder.nl/linkml"
echo "Schema Manifest: https://bronhouder.nl/schemas/20251121/linkml/manifest.json"
echo ""

File diff suppressed because it is too large Load diff

View file

@ -1,19 +1,62 @@
#!/usr/bin/env node
/**
* Generate a manifest of LinkML schema files for the frontend
* This script scans the schemas directory and creates a JSON manifest
* that the frontend can use to dynamically load schema files.
* LinkML Schema Manifest Generator
*
* Run: node scripts/generate-schema-manifest.js
* Generates a manifest.json file for the frontend to dynamically load
* schema files from the public/schemas directory.
*
* This script is run as part of the build process:
* pnpm run generate-manifest
*
* It scans the synced schema files (after sync-schemas) and creates
* a structured manifest that the LinkML viewer can consume.
*/
const fs = require('fs');
const path = require('path');
const SCHEMAS_DIR = path.join(__dirname, '../public/schemas/20251121/linkml');
const OUTPUT_FILE = path.join(__dirname, '../public/schemas/20251121/linkml/manifest.json');
const OUTPUT_FILE = path.join(SCHEMAS_DIR, 'manifest.json');
function scanDirectory(dir, category) {
// Category configuration
const CATEGORIES = [
{
name: 'main',
displayName: 'Main Schemas',
scan: false, // Main schemas are at root level
scanPath: null
},
{
name: 'class',
displayName: 'Classes',
scan: true,
scanPath: 'modules/classes'
},
{
name: 'enum',
displayName: 'Enumerations',
scan: true,
scanPath: 'modules/enums'
},
{
name: 'slot',
displayName: 'Slots',
scan: true,
scanPath: 'modules/slots'
},
{
name: 'module',
displayName: 'Modules',
scan: true,
scanPath: 'modules',
excludeSubdirs: ['classes', 'enums', 'slots', 'archive']
}
];
/**
* Recursively scan a directory for YAML files
*/
function scanDirectory(dir, category, relativePath = '', excludeSubdirs = []) {
const files = [];
if (!fs.existsSync(dir)) {
@ -21,14 +64,33 @@ function scanDirectory(dir, category) {
return files;
}
const entries = fs.readdirSync(dir);
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.endsWith('.yaml') && !entry.endsWith('.bak.yaml') && !entry.includes('.bak')) {
const name = entry.replace('.yaml', '');
// Skip archive directories and backup files
if (entry.name === 'archive' || entry.name.startsWith('archive_')) {
continue;
}
if (entry.isDirectory()) {
// Skip excluded subdirectories
if (excludeSubdirs.includes(entry.name)) {
continue;
}
// Recurse into subdirectories
const subPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
files.push(...scanDirectory(
path.join(dir, entry.name),
category,
subPath,
[] // Don't propagate excludeSubdirs to recursive calls
));
} else if (entry.isFile() && entry.name.endsWith('.yaml') && !entry.name.includes('.bak')) {
const name = entry.name.replace('.yaml', '');
const filePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
files.push({
name,
path: `modules/${category}/${entry}`,
path: filePath,
category
});
}
@ -37,47 +99,95 @@ function scanDirectory(dir, category) {
return files.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* Scan root directory for main schema files
*/
function scanMainSchemas() {
const files = [];
if (!fs.existsSync(SCHEMAS_DIR)) {
console.warn(`Schema directory not found: ${SCHEMAS_DIR}`);
return files;
}
const entries = fs.readdirSync(SCHEMAS_DIR, { withFileTypes: true });
for (const entry of entries) {
if (entry.isFile() && entry.name.endsWith('.yaml') && !entry.name.includes('.bak')) {
const name = entry.name.replace('.yaml', '');
files.push({
name,
path: entry.name,
category: 'main'
});
}
}
return files.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* Generate the manifest
*/
function generateManifest() {
console.log(`Scanning LinkML schemas in: ${SCHEMAS_DIR}`);
const categories = [];
let totalFiles = 0;
const categoryCounts = {};
for (const catConfig of CATEGORIES) {
let files;
if (catConfig.name === 'main') {
// Special handling for main schemas at root level
files = scanMainSchemas();
} else if (catConfig.scan && catConfig.scanPath) {
const scanDir = path.join(SCHEMAS_DIR, catConfig.scanPath);
files = scanDirectory(
scanDir,
catConfig.name,
catConfig.scanPath,
catConfig.excludeSubdirs || []
);
} else {
files = [];
}
if (files.length > 0) {
categories.push({
name: catConfig.name,
displayName: catConfig.displayName,
files
});
categoryCounts[catConfig.name] = files.length;
totalFiles += files.length;
}
}
const manifest = {
generated: new Date().toISOString(),
version: '1.0.0',
categories: [
{
name: 'main',
displayName: 'Main Schema',
files: [{
name: 'Heritage Custodian Ontology',
path: '01_custodian_name_modular.yaml',
category: 'main'
}]
},
{
name: 'class',
displayName: 'Classes',
files: scanDirectory(path.join(SCHEMAS_DIR, 'modules/classes'), 'classes')
},
{
name: 'enum',
displayName: 'Enumerations',
files: scanDirectory(path.join(SCHEMAS_DIR, 'modules/enums'), 'enums')
},
{
name: 'slot',
displayName: 'Slots',
files: scanDirectory(path.join(SCHEMAS_DIR, 'modules/slots'), 'slots')
}
]
schemaRoot: '/schemas/20251121/linkml',
totalFiles,
categoryCounts,
categories
};
// Ensure output directory exists
const outputDir = path.dirname(OUTPUT_FILE);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
// Write manifest
fs.writeFileSync(OUTPUT_FILE, JSON.stringify(manifest, null, 2));
// Print summary
console.log('Schema manifest generated:');
for (const cat of manifest.categories) {
console.log(` ${cat.displayName}: ${cat.files.length} files`);
console.log(`Generated manifest with ${totalFiles} schema files`);
for (const cat of categories) {
console.log(` - ${cat.displayName}: ${cat.files.length}`);
}
console.log(`\nOutput: ${OUTPUT_FILE}`);
console.log(`Output: ${OUTPUT_FILE}`);
}
generateManifest();

View file

@ -2,6 +2,10 @@
* LinkML Schema Loader
*
* Utilities for loading and parsing LinkML schema files.
*
* DYNAMIC MANIFEST LOADING:
* Instead of hardcoding schema files, this module fetches a manifest.json
* that is auto-generated at build time by scripts/generate-linkml-manifest.js
*/
import yaml from 'js-yaml';
@ -68,52 +72,106 @@ export interface SchemaFile {
category: 'main' | 'class' | 'slot' | 'enum' | 'module';
}
/**
* List of all LinkML schema files organized by category
*/
export const SCHEMA_FILES: SchemaFile[] = [
// Main schema
{ name: '01_custodian_name_modular', path: '01_custodian_name_modular.yaml', category: 'main' },
// Classes
{ name: 'Custodian', path: 'modules/classes/Custodian.yaml', category: 'class' },
{ name: 'CustodianName', path: 'modules/classes/CustodianName.yaml', category: 'class' },
{ name: 'CustodianObservation', path: 'modules/classes/CustodianObservation.yaml', category: 'class' },
{ name: 'CustodianLegalStatus', path: 'modules/classes/CustodianLegalStatus.yaml', category: 'class' },
{ name: 'CustodianPlace', path: 'modules/classes/CustodianPlace.yaml', category: 'class' },
{ name: 'CustodianCollection', path: 'modules/classes/CustodianCollection.yaml', category: 'class' },
{ name: 'CustodianType', path: 'modules/classes/CustodianType.yaml', category: 'class' },
{ name: 'ArchiveOrganizationType', path: 'modules/classes/ArchiveOrganizationType.yaml', category: 'class' },
{ name: 'MuseumType', path: 'modules/classes/MuseumType.yaml', category: 'class' },
{ name: 'LibraryType', path: 'modules/classes/LibraryType.yaml', category: 'class' },
{ name: 'GalleryType', path: 'modules/classes/GalleryType.yaml', category: 'class' },
{ name: 'DigitalPlatform', path: 'modules/classes/DigitalPlatform.yaml', category: 'class' },
{ name: 'OrganizationalStructure', path: 'modules/classes/OrganizationalStructure.yaml', category: 'class' },
{ name: 'OrganizationalChangeEvent', path: 'modules/classes/OrganizationalChangeEvent.yaml', category: 'class' },
{ name: 'PersonObservation', path: 'modules/classes/PersonObservation.yaml', category: 'class' },
{ name: 'Identifier', path: 'modules/classes/Identifier.yaml', category: 'class' },
{ name: 'ReconstructionActivity', path: 'modules/classes/ReconstructionActivity.yaml', category: 'class' },
{ name: 'SourceDocument', path: 'modules/classes/SourceDocument.yaml', category: 'class' },
{ name: 'TimeSpan', path: 'modules/classes/TimeSpan.yaml', category: 'class' },
{ name: 'EncompassingBody', path: 'modules/classes/EncompassingBody.yaml', category: 'class' },
{ name: 'Country', path: 'modules/classes/Country.yaml', category: 'class' },
{ name: 'Subregion', path: 'modules/classes/Subregion.yaml', category: 'class' },
{ name: 'Settlement', path: 'modules/classes/Settlement.yaml', category: 'class' },
{ name: 'AnnotationMotivationType', path: 'modules/classes/AnnotationMotivationType.yaml', category: 'class' },
{ name: 'AnnotationMotivationTypes', path: 'modules/classes/AnnotationMotivationTypes.yaml', category: 'class' },
// Enums
{ name: 'CustodianPrimaryTypeEnum', path: 'modules/enums/CustodianPrimaryTypeEnum.yaml', category: 'enum' },
{ name: 'LegalStatusEnum', path: 'modules/enums/LegalStatusEnum.yaml', category: 'enum' },
{ name: 'PlaceSpecificityEnum', path: 'modules/enums/PlaceSpecificityEnum.yaml', category: 'enum' },
{ name: 'OrganizationalUnitTypeEnum', path: 'modules/enums/OrganizationalUnitTypeEnum.yaml', category: 'enum' },
{ name: 'OrganizationalChangeEventTypeEnum', path: 'modules/enums/OrganizationalChangeEventTypeEnum.yaml', category: 'enum' },
{ name: 'StaffRoleTypeEnum', path: 'modules/enums/StaffRoleTypeEnum.yaml', category: 'enum' },
{ name: 'FeatureTypeEnum', path: 'modules/enums/FeatureTypeEnum.yaml', category: 'enum' },
{ name: 'EncompassingBodyTypeEnum', path: 'modules/enums/EncompassingBodyTypeEnum.yaml', category: 'enum' },
];
export interface SchemaCategory {
name: string;
displayName: string;
files: SchemaFile[];
}
export interface SchemaManifest {
generated: string;
schemaRoot: string;
totalFiles: number;
categoryCounts: {
main: number;
class: number;
enum: number;
slot: number;
module: number;
};
categories: SchemaCategory[];
}
const SCHEMA_BASE_PATH = '/schemas/20251121/linkml';
const MANIFEST_PATH = `${SCHEMA_BASE_PATH}/manifest.json`;
// Cache for the manifest
let cachedManifest: SchemaManifest | null = null;
let manifestLoadPromise: Promise<SchemaManifest | null> | null = null;
/**
* Load the schema manifest (cached)
*/
export async function loadManifest(): Promise<SchemaManifest | null> {
// Return cached manifest if available
if (cachedManifest) {
return cachedManifest;
}
// If already loading, wait for that promise
if (manifestLoadPromise) {
return manifestLoadPromise;
}
// Start loading
manifestLoadPromise = (async () => {
try {
const response = await fetch(MANIFEST_PATH);
if (!response.ok) {
console.error(`Failed to load manifest: ${response.status}`);
return null;
}
cachedManifest = await response.json();
return cachedManifest;
} catch (error) {
console.error('Error loading schema manifest:', error);
return null;
} finally {
manifestLoadPromise = null;
}
})();
return manifestLoadPromise;
}
/**
* Helper to flatten categories into a single files array
*/
function flattenCategories(manifest: SchemaManifest | null): SchemaFile[] {
if (!manifest) return [];
return manifest.categories.flatMap(cat => cat.files);
}
/**
* Get all schema files (from manifest)
*/
export async function getSchemaFiles(): Promise<SchemaFile[]> {
const manifest = await loadManifest();
return flattenCategories(manifest);
}
/**
* Get schema files synchronously (returns empty if not yet loaded)
* Useful for initial renders before async data is available
*/
export function getSchemaFilesSync(): SchemaFile[] {
return flattenCategories(cachedManifest);
}
/**
* Get all categories with their files
*/
export async function getSchemaCategories(): Promise<SchemaCategory[]> {
const manifest = await loadManifest();
return manifest?.categories ?? [];
}
/**
* Get all categories synchronously (returns empty if not yet loaded)
*/
export function getSchemaCategoriesSync(): SchemaCategory[] {
return cachedManifest?.categories ?? [];
}
/**
* Load a LinkML schema file by path
@ -154,8 +212,16 @@ export async function loadSchemaRaw(schemaPath: string): Promise<string | null>
/**
* Get all schema files by category
*/
export function getSchemasByCategory(category: SchemaFile['category']): SchemaFile[] {
return SCHEMA_FILES.filter(s => s.category === category);
export async function getSchemasByCategory(category: SchemaFile['category']): Promise<SchemaFile[]> {
const files = await getSchemaFiles();
return files.filter(s => s.category === category);
}
/**
* Get schemas by category synchronously (returns empty if manifest not loaded)
*/
export function getSchemasByCategorySync(category: SchemaFile['category']): SchemaFile[] {
return getSchemaFilesSync().filter(s => s.category === category);
}
/**
@ -179,6 +245,17 @@ export function getCategoryDisplayName(category: SchemaFile['category']): string
return names[category];
}
/**
* Get manifest metadata (generation time, counts, etc.)
*/
export async function getManifestMetadata(): Promise<Omit<SchemaManifest, 'categories'> | null> {
const manifest = await loadManifest();
if (!manifest) return null;
const { categories, ...metadata } = manifest;
return metadata;
}
/**
* Extract class information from a parsed schema
*/
@ -211,3 +288,26 @@ export function extractEnums(schema: LinkMLSchema): LinkMLEnum[] {
name: key,
}));
}
/**
* Search schema files by name (case-insensitive partial match)
*/
export async function searchSchemas(query: string): Promise<SchemaFile[]> {
const files = await getSchemaFiles();
const lowerQuery = query.toLowerCase();
return files.filter(f => f.name.toLowerCase().includes(lowerQuery));
}
/**
* Preload the manifest (call early to reduce latency)
*/
export function preloadManifest(): void {
loadManifest().catch(() => {
// Silently ignore preload errors
});
}
// Legacy export for backwards compatibility
// Components using SCHEMA_FILES directly should migrate to getSchemaFiles()
/** @deprecated Use getSchemaFiles() instead */
export const SCHEMA_FILES: SchemaFile[] = [];