Metanorma: Aequitate Verum

From zero to portal: organization-scale document publishing with Metanorma

Author’s picture Ronald Tse on 13 May 2026

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-cc for CalConnect)

  • The gh CLI 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: CalConnect

This 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: inherit

That 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

  1. The reusable workflow runs metanorma site generate to compile the AsciiDoc source into HTML, PDF, XML, and RXL.

  2. It parses the RXL file to extract document metadata (ID, title, stage, edition).

  3. It matches cc-18011 against the pattern: "cc-*" rule and resolves the channel to public/standards.

  4. It computes a content hash. If the hash matches the previous release, it skips (no-op). If different, it proceeds.

  5. 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-51015public/standards

  • cc-r-1903public/reports

  • cc-a-0001public/admin

  • cc-adv-100public/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-release

The 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@v3

The 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 templates

For 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 display_category_slug (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

  1. Check the document repo: gh release list --repo CalConnect/cc-datetime-explicit

  2. Check the portal build logs for the aggregate step output (document count).

  3. Visit the portal and verify the document appears on the correct page.

Tip

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:

  1. Publisher gate (manifest): Documents not matched by metanorma.release.yml are not released. If it is not in the manifest, it does not exist.

  2. Channel assignment (release action): The publisher decides the channel. The portal cannot override it.

  3. Aggregation filter (aggregate action): The portal sees only the channels it asks for. A public portal never sees members or internal channels.

Important

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