Release lifecycle management for Metanorma documents.
Overview
metanorma-release manages the full release lifecycle of Metanorma documents:
- Release (producer side)
-
Discover compiled documents → extract metadata from RXL → detect changes → package as zip → publish to a platform (GitHub Releases, local filesystem).
- Aggregate (consumer side)
-
Discover repositories → fetch published releases → filter by channel and stage → extract zip assets → generate
index.jsonwith a file tree for any site generator.
The output is platform-agnostic: a directory containing index.json and a tree of document files. Any site generator (Jekyll, Hugo, Vite) consumes that output independently.
Installation
Add to your Gemfile:
gem "metanorma-release"
Or install directly:
gem install metanorma-release
Requires Ruby >= 3.2.
Quick start
CLI
The gem ships three commands:
# Package compiled documents as zip archives
mn-release package --output-dir _site --manifest metanorma.release.yml
# Package and publish to a platform
mn-release publish --platform github --output-dir _site --token $GITHUB_TOKEN
# Aggregate published releases into a file tree + index.json
mn-release aggregate --source github --organizations my-org --output-dir _site/cc
Rake tasks
Register tasks in your Rakefile:
This provides:
-
rake mn:package— package compiled documents -
rake mn:publish— package and publish documents -
rake mn:aggregate— aggregate published releases
Ruby API
Use the pipelines directly for fine-grained control:
deps = Metanorma::Release::ReleasePipeline::Dependencies.new(
extractor: Metanorma::Release::RxlExtractor.new,
filters: [],
change_detector: Metanorma::Release::ContentHashChangeDetector.new(previous_releases: {}),
packager: Metanorma::Release::ZipPackager.new,
publisher: Metanorma::Release::PlatformFactory.build_publisher("null", {}),
naming_registry: Metanorma::Release::NamingRegistry.default_registry,
manifest: nil,
channel_override: nil,
channel_config: nil
)
config = Metanorma::Release::ReleasePipeline::Config.new(
output_dir: "_site",
manifest_path: nil,
force: false,
force_replace_patterns: nil,
concurrency: 4,
default_visibility: "public"
)
result = Metanorma::Release::ReleasePipeline.new(deps).run(config)
result.released # => [#<DocumentMetadata ...>]
result.skipped # => [#<DocumentMetadata ...>]
result.failed # => [{ document: ..., error: "..." }]
CLI reference
mn-release package
Package compiled documents into zip archives without publishing.
mn-release package [options]
| Option | Description |
|---|---|
|
Directory containing compiled documents (default: |
|
Destination for zip packages (default: |
|
Release manifest file (default: |
|
Channel config file or platform ref |
mn-release publish
Package and publish documents to a platform.
mn-release publish [options]
| Option | Description |
|---|---|
|
Target platform: |
|
Compiled docs directory (default: |
|
Release manifest file (default: |
|
Force release even if unchanged |
|
Glob pattern for forced replacement (repeatable) |
|
Override channels (comma-separated) |
|
Parallel workers (default: 4) |
|
Platform auth token |
|
Channel config file or platform ref |
mn-release aggregate
Aggregate published releases from multiple repositories into a unified file tree.
mn-release aggregate [options]
| Option | Description |
|---|---|
|
Discovery source: |
|
Comma-separated organization list |
|
Repository topic filter (default: |
|
Explicit repo list (comma-separated) |
|
Filter channels (comma-separated) |
|
Filter stages (comma-separated) |
|
Output directory (default: |
|
File layout: |
|
Cache directory for delta state |
|
Include draft releases |
|
Parallel repos (default: 4) |
|
Fail if fewer documents found (default: 0) |
|
Platform auth token |
Concepts
Channels
A channel is an audience/category pair that controls who can access a document:
Audiences: public, members, internal. When omitted, audience defaults to public.
Channel configuration
A channel config defines the set of allowed channels for a project or organization, along with default visibility. This lets you enforce a channel taxonomy across all documents.
-
--configCLI flag (highest priority) -
config:key in the release manifest -
Directory walk:
.metanorma.yml,.metanorma.yaml, or.metanorma/channels.yml -
No config — all channels allowed
Config file format
# .metanorma.yml
channels:
- public/standards
- public/reports
- members/early-access
- internal/working-drafts
defaults:
visibility: public
channels:
- public/standards
The channels list defines the taxonomy — only these channels are valid. The defaults section sets fallback visibility and channels when a document doesn’t match any manifest entry.
Specifying config in the manifest
Add a config key to metanorma.release.yml:
config: local:/path/to/config.yml
defaults:
visibility: public
documents:
- source: sources/cc-18011.adoc
channels:
- public/standards
The config source can be:
-
local:/path/to/config.yml— local file path -
myorg/myrepo— GitHub repo (readschannels.ymlfrom root) -
myorg/myrepo#path/to/config.yml— GitHub repo with explicit path
Ruby API
# Parse from YAML
config = Metanorma::Release::ChannelConfig.from_yaml(File.read(".metanorma.yml"))
# Permissive config (all channels allowed)
config = Metanorma::Release::ChannelConfig.empty
# Validate a channel
config.registry.valid?(Channel.parse("public/standards")) # => true
config.registry.valid?(Channel.parse("public/secret")) # => false
# Locate config by walking up from a directory
config = Metanorma::Release::ConfigLocator.find("/path/to/project")
Naming strategies
Tag and file naming varies by document type. Strategies are resolved via a registry:
| Document type | Strategy | Tag format |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Register custom strategies:
registry = Metanorma::Release::NamingRegistry.default_registry
registry.register("my-type", MyCustomNaming.new)
File routing
The aggregation pipeline supports three file layout modes:
| Mode | Example path |
|---|---|
|
|
|
|
|
|
Release manifest
A metanorma.release.yml file controls which documents are published and to which channels:
config: myorg/.metanorma
defaults:
visibility: public
channels:
- public/standards
documents:
- source: sources/cc-18011.adoc
channels:
- public/standards
- source: sources/cc-19060.adoc
visibility: members
channels:
- members/early-access
- pattern: "sources/draft-*.adoc"
channels:
- internal/working-drafts
stages:
- working-draft
- committee-draft
Documents not listed in the manifest use the defaults section. If no manifest exists, all documents are released as public/standards.
Key fields:
| Field | Description |
|---|---|
|
Exact path match (highest priority) |
|
Glob pattern match |
|
|
|
List of target channels |
|
Allow-list of document stages |
|
Channel config source (see [channel-configuration]) |
Value objects
All domain types are immutable, frozen, and use value-based equality:
-
DocumentId— normalized document identifier (CC 18011→cc-18011) -
DocumentVersion— edition + stage + pre-release flag -
DocumentStage— published, draft, working-draft, committee-draft, etc. -
Channel— audience/category pair -
ReleaseTag— tag string with pre-release flag -
ContentHash— SHA-256 content fingerprint -
RepoRef— owner/repo reference
Bibliography enrichment
RelatonEnricher generates index.json and index.yaml from RXL (Relaton XML) files found in aggregated documents. It auto-detects the Relaton flavor from document metadata:
enricher = Metanorma::Release::RelatonEnricher.new(flavor: "calconnect")
result = enricher.enrich(document_index, output_dir)
# writes: output_dir/relaton/index.json
# output_dir/relaton/index.yaml
Flavor detection tries these gems in order: relaton-calconnect, relaton-iso, relaton-iec, relaton-ogc, relaton-ietf, and others. If a flavor gem is not installed, it falls back to Relaton::Bib::Item from the relaton-bib runtime dependency.
Architecture
Dependency flow
Unidirectional, no cycles:
domain/ -> release/ -> platform/
-> aggregation/ -> platform/
-> cli/
-
domain/has zero knowledge of pipelines, platforms, or CLI -
Pipelines depend on domain + interfaces, not platform implementations
-
Platform adapters depend on interfaces + domain, not pipelines
-
CLI delegates to command classes; commands depend on pipelines + platform factory
-
Commands use
ConfigResolvermixin for channel config resolution
Patterns
- Value Objects
-
Immutable, frozen, value-based equality via
eql?/hash. All fields included in equality comparison. - Strategy Pattern
-
Pluggable algorithms resolved via registry. Adding a new document type or platform requires zero changes to existing code.
- Pipeline with DI
-
Pipelines receive all dependencies through constructors. No global state, no service locators.
- Null Object
-
Disabled features inject null implementations (
NullDeltaState,NullPublisher,NullCacheStore) instead of adding conditional checks. - Result Types
-
Pipelines return frozen Structs. Errors are collected, not raised. The caller decides whether to abort.
- Command Pattern
-
CLI delegates to
PackageCommand,PublishCommand, andAggregateCommandclasses. Each command encapsulates pipeline construction and configuration resolution via theConfigResolvermixin.
Extending
| To add… | Do this |
|---|---|
A new platform |
Create a directory under |
A new naming strategy |
Create a class that includes |
A new file routing mode |
Create a class with |
A new filter |
Create a class that includes |
A new channel config source |
Create a class that includes |
Development
bundle install
bundle exec rspec
License
BSD-2-Clause. See LICENSE for details.