From zero to portal: organization-scale document publishing with Metanorma
#What you will build
Push a document to main in any repository. It is compiled, released as a GitHub Release with metadata, and automatically appears on the right page of the organization portal. No submodules, no hardcoded repo lists, no manual updates.
Author pushes to main
│
▼
Per-document repo CI
compile → route via metanorma.release.yml → release (GitHub Release with channel metadata)
│
▼
Portal CI (scheduled or triggered)
discover repos → fetch releases → subscribe by channel → extract → build site
│
▼
Live portal (standards.calconnect.org)This post walks through every file you need to create, using real examples from CalConnect's standards portal — which aggregates 168 documents from 51 repositories across 5 channels.
For the GitHub Actions reference (action inputs, metadata protocol), see GitHub Actions integration. For a permanent setup reference with full config schemas, see Organization-Scale Publishing Setup.
#Prerequisites
- A GitHub organization with one or more document repositories
- A Metanorma flavor installed (e.g.,
metanorma-ccfor CalConnect) - The
ghCLI for adding GitHub topics - Basic familiarity with GitHub Actions
#The two-file architecture
The entire system is driven by two config files — one per document repo, one for the portal:
- Per-repo
metanorma.release.yml— routing. - The document repository declares which channels each document is published to. Pattern matching (
documents[].pattern), stage/doctype matching (documents[].stage,documents[].doctype), and a catch-all default entry all live here. The repo owns its routing decisions. No.metanorma/directory is needed. - Aggregator
metanorma.aggregate.yml— discovery and display. - The portal repository subscribes to channels by prefix, defines display categories for site rendering, and configures output layout. It does not tell document repos where to publish — it only decides what it collects.
Channels are a publisher-side concept: the document repo picks channel names, the aggregator subscribes by prefix. It is convention, not a central registry. There is no org config repo, no shared .metanorma/ directory, and no org: key.
#Step 1: Set up a single-document repository
CalConnect has ~45 standard repositories, each containing one document. cc-datetime-explicit is a typical example — it produces CC 18011:2018 "Date and time — Explicit representation".
Three files are needed (no .metanorma/ directory required):
#`metanorma.yml` (compilation manifest)
# cc-datetime-explicit/metanorma.yml
metanorma:
source:
files:
- sources/cc-18011.adoc
collection:
name: TC-DATETIME
organization: CalConnectThis tells metanorma site generate which source file to compile. See site generation docs for the full schema.
#`metanorma.release.yml` (release manifest and routing)
# cc-datetime-explicit/metanorma.release.yml
documents:
- pattern: "cc-*"
channels: [public/standards]One line of routing: any document with an ID matching the cc-* glob goes to the public/standards channel. When an author adds a new document matching the pattern, it is automatically included — no manifest update needed.
The release manifest is the single point of truth for routing. It uses a unified documents list where each entry can match by pattern (glob against document ID), stage (ISO stage code), doctype (document type), or any combination. Entries are evaluated in order; first match wins. A catch-all entry (just channels:) serves as the default.
#`.github/workflows/release.yml`
# cc-datetime-explicit/.github/workflows/release.yml
name: Release
on:
push:
branches: [main]
paths: ['sources/**', 'metanorma.yml', 'metanorma.release.yml']
workflow_dispatch:
permissions:
contents: write
jobs:
release:
uses: actions-mn/.github/.github/workflows/metanorma-release.yml@main
with:
default-visibility: private
secrets: inheritThat is the entire release workflow — 10 lines. The reusable workflow handles compilation, change detection (via content hash), packaging, and publishing to GitHub Releases.
#Add the `metanorma-release` topic
gh api repos/CalConnect/cc-datetime-explicit/topics -X PUT \
--field names='["metanorma-release"]'This is how the portal discovers your repository. No topic, no aggregation.
#What happens when you push
- The reusable workflow runs
metanorma site generateto compile the AsciiDoc source into HTML, PDF, XML, and RXL. - It parses the RXL file to extract document metadata (ID, title, stage, edition).
- It matches
cc-18011against thepattern: "cc-*"rule and resolves the channel topublic/standards. - It computes a content hash. If the hash matches the previous release, it skips (no-op). If different, it proceeds.
- It creates a GitHub Release with tag
cc-18011-2018/ed1, attaches a zip containing all compiled files, and writes the release body with metadata:
content-hash: abc123...
<!-- mn-release-metadata
{
"version": 1,
"id": "cc-18011",
"title": "Date and time — Explicit representation",
"edition": "1",
"stage": "published",
"doctype": "standard",
"channels": ["public/standards"],
"formats": ["html", "pdf", "xml", "rxl"]
}
-->Published releases are immutable — the tag is created once and never overwritten. Draft releases are rolling — the same tag is updated in-place as the draft evolves.
#Step 2: Set up a multi-document repository
CalConnect's cc-admin-documents repo contains 100+ documents across four types. It demonstrates how pattern-based routing scales to many documents with different channels.
#`metanorma.release.yml` (four channels)
# cc-admin-documents/metanorma.release.yml
documents:
- pattern: "cc-s-*"
channels: [public/standards]
- pattern: "cc-r-*"
channels: [public/reports]
- pattern: "cc-a-*"
channels: [public/admin]
- pattern: "cc-adv-*"
channels: [public/advisories]Four patterns, four channels. Each document is routed based on its ID convention:
cc-s-51015→public/standardscc-r-1903→public/reportscc-a-0001→public/admincc-adv-100→public/advisories
#Release workflow with filter inputs
For multi-document repos, the workflow includes optional inputs for selective re-release:
# cc-admin-documents/.github/workflows/release.yml
name: Release
on:
push:
branches: [main]
paths: ['sources/**', 'metanorma.yml', 'metanorma.release.yml']
workflow_dispatch:
inputs:
include-pattern:
description: 'Glob to filter documents (e.g. cc-1903*)'
required: false
default: '*'
force:
description: 'Force release even if unchanged'
required: false
type: boolean
default: false
jobs:
release:
uses: actions-mn/.github/.github/workflows/metanorma-release.yml@main
with:
default-visibility: private
include-pattern: ${{ github.event.inputs.include-pattern || '*' }}
force: ${{ github.event.inputs.force || 'false' }}
secrets: inherit#Directive repositories
CalConnect's directive repos use a single channel:
# cc-directive-standardization-publication/metanorma.release.yml
documents:
- pattern: "cc-dir-*"
channels: [public/directives]All five directive repos use the same pattern. A new directive added to any of them is automatically routed to public/directives.
#Step 3: Set up the portal
The portal is a single repository that aggregates all published documents. For CalConnect, this is standards.calconnect.org.
It needs only one config file: metanorma.aggregate.yml.
#`metanorma.aggregate.yml`
# standards.calconnect.org/metanorma.aggregate.yml
source: github
output_dir: _site/cc
file_routing: flat
cache_dir: .cache/aggregate
data_dir: _data
channels:
- public
include_drafts: true
display_categories:
- name: Standards, Specifications & Reports
slug: standards
doctypes: [standard, specification, report]
- name: Guides & Advisories
slug: guides
doctypes: [guide, advisory]
- name: Directives
slug: directives
doctypes: [directive]
- name: Administrative
slug: administrative
doctypes: [administrative]
- name: Amendments & Technical Corrigenda
slug: amendments
doctypes: [amendment, technical-corrigendum]
github:
organizations:
- CalConnect
topic: metanorma-releaseThe channels: [public] entry subscribes to all channels starting with public/ — so public/standards, public/reports, public/admin, public/advisories, and public/directives are all included via prefix matching. The portal does not need to know the full channel list; it discovers it from what document repos publish.
The display_categories section maps document types (from Relaton doctype) to human-readable category names used in site rendering. This is an aggregator-side concern — the document repos do not need to know how the portal categorizes them.
The include_drafts: true flag includes working drafts and committee drafts so the portal can show a "drafts" page.
#`.github/workflows/build_deploy.yml`
# standards.calconnect.org/.github/workflows/build_deploy.yml
name: Build and Deploy
on:
push:
branches: [main]
workflow_dispatch:
schedule:
- cron: '0 6 * * *' # daily refresh at 06:00 UTC
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: .cache/aggregate
key: mn-aggregate-${{ github.run_id }}
restore-keys: mn-aggregate-
- name: Aggregate released documents
uses: actions-mn/aggregate@v1
with:
organizations: CalConnect
topic: metanorma-release
output-dir: _site/cc
channels: 'public'
canonicalize: true
cache-dir: .cache/aggregate
token: ${{ secrets.GITHUB_TOKEN }}
- name: Build site
run: bundle exec jekyll build
- uses: actions/upload-pages-artifact@v3The aggregate action uses ETag-based caching. Unchanged repositories are skipped. Daily scheduled builds pick up new releases automatically.
#Aggregate output
After aggregation, the output directory contains index.json with full document metadata, a relaton/ directory with Relaton-enriched bibliography data, and the document files themselves:
_site/cc/
index.json # Full document metadata (168 documents)
relaton/
index.json # Relaton-enriched bibliography
cc-18011-2018.html # Document files (flat routing)
cc-18011-2018.pdf
cc-18011-2018.xml
cc-18011-2018.rxl
... # 600+ files total
_data/
documents.json # Flattened data for Jekyll templatesFor the full output schema and file routing options, see Aggregate output structure.
#Rendering with Jekyll
Each listing page is just front matter:
# _pages/standards.html
---
layout: doc-type
title: Standards
display_category_slug: standards
---The doc-type layout reads site.data.documents.items, filters by displaycategoryslug (set by the display_categories mapping in metanorma.aggregate.yml), and renders document cards with search, filters, and sort — all client-side.
#Step 4: Verify the pipeline
- Check the document repo:
gh release list --repo CalConnect/cc-datetime-explicit - Check the portal build logs for the aggregate step output (document count).
- Visit the portal and verify the document appears on the correct page.
For troubleshooting guidance (missing releases, missing documents, stale data), see Troubleshooting in the setup reference.
#The safety model
The architecture enforces safety at three independent layers:
- Publisher gate (manifest): Documents not matched by
metanorma.release.ymlare not released. If it is not in the manifest, it does not exist. - Channel assignment (release action): The publisher decides the channel. The portal cannot override it.
- Aggregation filter (aggregate action): The portal sees only the channels it asks for. A
publicportal never seesmembersorinternalchannels.
Always set default-visibility: private in production release workflows. This ensures that documents not explicitly listed in the manifest default to private (invisible) rather than public.
#Where to go next
- Organization-Scale Publishing Setup — permanent setup reference with full schemas, stage codes, and checklists
- GitHub Actions integration — action inputs, release metadata protocol, force-replacing releases
- actions-mn — GitHub Action source and issue tracker
- standards.calconnect.org — the live portal