Overview

metanorma-release manages the full release lifecycle of Metanorma documents through two config files:

Per-repo (metanorma.release.yml)

Defines routing rules that map documents to channel labels based on slug pattern, stage, or doctype. Read by the release command to tag releases with channel metadata.

Per-site (metanorma.aggregate.yml)

Defines discovery (which repos to aggregate), channel subscription (which channels to include), display categories, and output layout. Read by the aggregate command to build a file tree + index.json for any site generator.

The output is platform-agnostic: a directory containing index.json and a tree of document files (HTML, PDF, XML, RXL). Any site generator (Jekyll, Hugo, Vite) consumes that output independently.

How it works

  1. Compile — Metanorma compiles .adoc sources to HTML, PDF, XML, and RXL (Relaton metadata).

  2. Package & releasemetanorma-release release discovers compiled docs, extracts metadata from RXL, packages as ZIP, and publishes to GitHub Releases with channel labels derived from metanorma.release.yml routing rules.

  3. Aggregatemetanorma-release aggregate discovers repos by topic/topic, fetches their releases, filters by channel, extracts ZIP assets, enriches with Relaton bibliographic data, and writes index.json + file tree.

  4. Present — A site generator (Jekyll) reads index.json and renders the document registry.

Installation

Add to your Gemfile:

gem "metanorma-release"

Or install directly:

gem install metanorma-release

Requires Ruby >= 3.2. Optional runtime dependencies:

  • relaton-bib — RXL metadata extraction (required for package and release)

  • octokit — GitHub platform adapter (required for GitHub releases/aggregation)

  • rubyzip — zip packaging (required for package and release)

Quick start

In a document repository

Create metanorma.release.yml:

documents:
  - pattern: "cc-*"
    channels: [public/standards]

Run the release:

metanorma-release release --platform github --token $GITHUB_TOKEN

In an aggregator site

Create metanorma.aggregate.yml:

source: github
output_dir: _site/docs
file_routing: flat

channels:
  - public

display_categories:
  - name: Standards
    slug: standards
    doctypes: [standard, specification, report]
  - name: Guides
    slug: guides
    doctypes: [guide, advisory]

github:
  organizations:
    - MyOrg
  topic: metanorma-release

Run the aggregation:

metanorma-release aggregate

Ruby API

# Discover publications from compiled RXL files
publications = Metanorma::Release::Publication.discover("_site")

pub = publications.first
pub.identifier  # => "CC 18011:2018"
pub.slug        # => "cc-18011-2018"
pub.title       # => "Date and time — Explicit representation"
pub.edition     # => "1"
pub.stage       # => "60"
pub.doctype     # => "standard"
pub.formats     # => ["html", "pdf", "xml"]

# Serialize (used in release body)
pub.to_release_body  # => "<!-- mn-release-metadata\n{...}\n-->"

# Parse from release body (used in aggregation)
pub = Publication.from_release_body(body)

CLI reference

metanorma-release package

Package compiled documents into zip archives without publishing.

metanorma-release package [options]
Option Description

--output-dir DIR

Directory containing compiled documents (default: _site)

--dest DIR

Destination for zip packages (default: dist)

--manifest FILE

Release manifest file (default: metanorma.release.yml)

--config SOURCE

Config file

metanorma-release release

Package and release documents to a platform.

metanorma-release release [options]
Option Description

--platform NAME

Target platform: github, local, null (default: github)

--output-dir DIR

Compiled docs directory (default: _site)

--manifest FILE

Release manifest file (default: metanorma.release.yml)

--force

Force release even if unchanged

--force-replace PAT

Glob pattern for forced replacement (repeatable)

--channels CHANS

Override channels (bypasses routing rules)

--concurrency N

Parallel workers (default: 4)

--token TOKEN

Platform auth token

--config SOURCE

Config file

metanorma-release aggregate

Aggregate published releases from multiple repositories into a unified file tree. Reads config from metanorma.aggregate.yml if present (auto-detected). Idempotent: uses cached delta state (.cache/aggregate/) to skip unchanged repos.

metanorma-release aggregate [options]
Option Description

--config FILE

Config file (default: metanorma.aggregate.yml)

--source SOURCE

Discovery source: github, local:PATH (default: github)

--organizations ORGS

Organization list (overrides config)

--topic TOPIC

Repository topic filter (default: metanorma-release)

--repos REPOS

Explicit repo list (overrides discovery)

--channels CHANS

Filter by channel (only aggregate matching channels)

--output-dir DIR

Output directory (default: _site/cc)

--file-routing MODE

File layout: by-document, flat, by-format (default: by-document)

--[no-]include-drafts

Include draft releases (default: false)

--concurrency N

Parallel repos (default: 4)

--min-documents N

Fail if fewer documents found (default: 0)

--token TOKEN

Platform auth token (falls back to GITHUB_TOKEN env)

Config files

Per-repo: metanorma.release.yml

Defines how documents in a repository are routed to channels. Placed in the root of each Metanorma document repository.

Simple single-channel

All documents go to one channel:

documents:
  - pattern: "cc-*"
    channels: [public/standards]

Multi-channel by document pattern

Route different documents to different channels based on their slug:

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]

Pattern matching uses Ruby File.fnmatch glob syntax against the document slug. The slug is derived from the document identifier: CC 51020:2019cc-51020-2019.

Routing by stage and doctype

You can also route by stage or doctype instead of pattern:

documents:
  - stages: ["20", "30"]
    channels: [internal]
  - doctypes: [standard, specification]
    channels: [public/standards]
  - doctypes: [report]
    channels: [public/reports]

Multiple criteria are ANDed (a document must match all specified fields). First matching entry wins. Documents not matching any entry default to ["public"].

Slug strategies

Tag naming varies by publisher. Set the default strategy and per-publisher overrides:

slug:
  default: edition
  strategies:
    ietf: internet-draft
    ieee: draft-suffix
Strategy Tag format Used for

edition (default)

cc-18011-2018/ed1

CalConnect, ISO

version

iho-s44/v1

IHO, OGC

internet-draft

id-ietf-foo/1

IETF drafts

rfc

rfc-1234/ed1

IETF RFCs

draft-suffix

ieee-8021/d1

IEEE

The strategy is resolved from the document identifier prefix (e.g., CC → default, IETFietf).

Per-site: metanorma.aggregate.yml

Defines how an aggregator site discovers, filters, and outputs documents.

source: github
output_dir: _site/docs
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

github:
  organizations:
    - CalConnect
  topic: metanorma-release

Config reference

Key Default Description

source

github

Discovery source: github or local:PATH

output_dir

_site/cc

Where to write extracted files and index.json

file_routing

by-document

File layout: by-document, flat, or by-format

cache_dir

.cache/aggregate

Delta state cache for incremental builds

data_dir

none

If set, writes flattened documents.json for site generators

channels

[]

Channel filter (empty = accept all channels)

include_drafts

false

Whether to include draft-stage releases

display_categories

[]

Maps doctypes to display categories for site output

github.organizations

required

GitHub orgs to scan for repositories

github.topic

metanorma-release

Repository topic filter

CLI flags override config file values.

Concepts

Channels

Channels are hierarchical string labels that control document visibility. They are assigned during release (per-repo config) and filtered during aggregation (per-site config).

Typical channel hierarchy:

public/              → visible on public sites
public/standards     → published standards
public/reports       → technical reports
public/admin         → administrative documents
members/             → members-only content
internal/            → not published to any site

Channel matching is prefix-based: a filter for public matches public/standards, public/reports, etc.

Display categories

Display categories map document types to site sections. Defined in metanorma.aggregate.yml, they group doctypes into user-facing categories:

display_categories:
  - name: Standards, Specifications & Reports
    slug: standards
    doctypes: [standard, specification, report]

The aggregator resolves each document’s display category from its doctype and includes display_category and display_category_slug fields in the output JSON.

File routing

The aggregation pipeline supports three file layout modes:

Mode Example path

by-document (default)

cc-18011/cc-18011.html

flat

cc-18011.html

by-format

html/cc-18011.html

Delta state caching

Aggregation is incremental: a delta state cache tracks which repos/tags have already been processed. On subsequent runs, unchanged repos are skipped entirely. The cache lives in .cache/aggregate/ by default and should be persisted in CI (cache action, artifact upload).

Output format

The aggregator writes:

  • index.json — full document index with metadata, bibliographic data, and file references

  • File tree — extracted document files (HTML, PDF, XML, RXL) organized by file routing mode

  • _data/documents.json — flattened version for Jekyll site generators (if data_dir is set)

Each document in the output includes:

slug, id, title, abstract, stage, doctype, edition, date,
channels, formats, files,
has_html, has_pdf, has_xml, has_rxl,
html_path, pdf_path, xml_path, rxl_path,
stage_css, doctype_class,
display_category, display_category_slug,
authors, committee,
bibliographic

Creating a new document repository

Using the template

For CalConnect documents, use the cc-template repository template:

  1. Click Use this template on GitHub

  2. Name the repo cc-{descriptive-name} (e.g. cc-icalendar-series)

  3. Replace placeholder document numbers in sources/ and metanorma.yml

  4. Add the metanorma-release topic to the repository

  5. Push to main

Manual setup

  1. Create a repository with the metanorma-release GitHub topic

  2. Add metanorma.release.yml with routing rules:

    documents:
      - pattern: "cc-*"
        channels: [public/standards]
  3. Add metanorma.yml with source file list:

    metanorma:
      source:
        files:
          - sources/cc-51020.adoc
      collection:
        name: "My Document Title"
        organization: CalConnect
  4. Add a CI workflow (.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
  5. Write your AsciiDoc source under sources/

The aggregator site will automatically discover your repository and publish its documents.

Architecture

Domain model

All core types are immutable, frozen value objects:

  • Publication — metadata + files + channels + source

  • PublicationFile — format, name, path

  • PublicationSource — owner, repo, tag, url, date

  • Channel — string label wrapper with prefix matching

  • Index — collection of Publications with schema version

  • Site — aggregated output (index + file tree + Relaton enrichment + display categories)

Dependency flow

Unidirectional, no cycles:

domain/  ->  pipelines/  ->  platform/
                          ->  cli/commands/
  • Domain models have zero knowledge of pipelines, platforms, or CLI

  • Pipelines receive all dependencies through constructors (dependency injection)

  • Platform adapters implement interface modules (Publisher, RepoDiscoverer, ReleaseFetcher, etc.)

  • CLI delegates to command classes; commands construct pipelines

Patterns

Value Objects

Immutable, frozen, value-based equality via eql?/hash.

Strategy Pattern

Pluggable slug strategies resolved via registry. Adding a new publisher type requires zero changes to existing code.

Pipeline with DI

Pipelines receive all dependencies through constructors. No global state, no service locators.

Interface Modules

Type contracts using include Module — not duck typing. Dependencies are validated at construction time.

Null Object

Disabled features inject null implementations (NullDeltaState, NullPublisher, NullCacheStore).

Result Types

Pipelines return frozen Structs. Errors are collected, not raised.

Extending

To add…​ Do this

A new platform

Create a directory under platform/ with Publisher, Discoverer, Fetcher, ManifestReader classes that include the corresponding interface modules; register in PlatformFactory

A new slug strategy

Create a class that includes SlugStrategy; register via SlugRegistry#register

A new file routing mode

Create a class that includes FileRouting with a #compute_path method; register in FileRoutingFactory

A new filter

Create a class that includes Filter; pass to the pipeline’s filters array

Development

bundle install
bundle exec rspec
bundle exec rubocop

License

BSD-2-Clause. See LICENSE for details.