A GitHub Action that publishes compiled Metanorma documents as per-document GitHub Releases.
Part of the actions-mn ecosystem — alongside site-gen, compile, and build-and-publish.
Note: This action does not compile documents. Use site-gen for compilation first, then run this action on the compiled output.
- Per-document releases: Each document gets its own tag, asset, and GitHub Release
- Channel-based publication: Route documents to specific portals via
audience/categorychannels - Pattern-based manifests: Auto-assign channels by document ID pattern — zero per-document config
- Stage gating: Restrict releases to specific stages (e.g. only
publisheddocuments) - Content-hash change detection: Only re-releases documents whose compiled output actually changed
- Immutable published releases: Published tags are created once; draft tags are updated in-place
- Selective force-replace: Re-release specific documents without affecting others in the same repo
- Universal flavor support: Data-driven tag naming from RXL metadata — works with all Metanorma flavors
- Parallel processing: Fault-tolerant
Promise.allSettled— one failure doesn't block others - TypeScript: Written in TypeScript with full type safety and 80%+ test coverage
# .github/workflows/release.yml
name: Release
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions-mn/site-gen@v1 # compile first
- uses: actions-mn/release@v1 # then release
with:
token: ${{ secrets.GITHUB_TOKEN }}- Discovers compiled documents from their RXL metadata files
- Filters by visibility (from
metanorma.release.yml) and include pattern - Detects changes (content hash vs. last release)
- Packages each changed document as a zip
- Publishes as per-document GitHub Releases
Downstream portals discover participating repos via the metanorma-release GitHub topic and aggregate released artifacts — no submodules, no compilation needed on the index side.
| Input | Description | Required | Default |
|---|---|---|---|
source-path |
Source path containing the metanorma configuration | No | . |
output-dir |
Output directory containing compiled documents | No | _site |
release-config |
Release manifest file | No | metanorma.release.yml |
default-visibility |
Default visibility for unlisted documents (public, private, members) |
No | public |
force |
Force release even if content hash matches last release | No | false |
force-replace |
Comma-separated doc IDs or glob patterns to force-replace | No | '' |
include-pattern |
Glob pattern to filter documents for release (e.g. cc-*) |
No | * |
stages |
Comma-separated stages to release. Empty = all. | No | '' |
channels |
Override channels for all documents. Empty = use manifest. | No | '' |
concurrency |
Max parallel document processing | No | 4 |
token |
GitHub token for creating releases | No | ${{ github.token }} |
| Output | Description |
|---|---|
released-documents |
JSON array of released document identifiers |
skipped-documents |
JSON array of skipped document identifiers (unchanged) |
failed-documents |
JSON array of failed document identifiers |
total-documents |
Total number of documents processed |
released-artifacts |
JSON array of { id, tag, url, channels } for released documents |
Add a metanorma.release.yml to control which documents are publicly released:
# metanorma.release.yml
documents:
- source: sources/cc-51015.adoc
- source: sources/cc-51024.adoc
- source: sources/cc-51026.adoc
visibility: private # not ready for public release# .github/workflows/release.yml
name: Release
on:
push:
branches: [main]
paths: ['sources/**', 'metanorma.yml', 'metanorma.release.yml']
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions-mn/site-gen@v1
- uses: actions-mn/release@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}Push a release/* tag to trigger a forced release:
# .github/workflows/release.yml
on:
push:
tags: ['release/**']
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions-mn/site-gen@v1
- uses: actions-mn/release@v1
with:
force: true
token: ${{ secrets.GITHUB_TOKEN }}Tag and asset naming is determined by naming behavior, not publisher identity. Strategies are named after what they DO (how they format tags), not WHO they're for (which SDO published the document).
Publisher-based naming (IeeeNamingStrategy, IhoNamingStrategy, OgcNamingStrategy) is wrong for several reasons:
-
Publisher ≠ naming convention. The naming convention is determined by the identifier format and edition format, not the publisher's identity. IHO and OGC use identical version-based naming — giving them separate classes duplicates identical behavior.
-
One publisher, multiple conventions. IEEE uses
DraftSuffixNamingStrategyfor draft identifiers (extracting-d{N}) but falls back toEditionNamingStrategyfor published documents. A single "IEEE strategy" would conflate two behaviors into one class. -
Wrong abstraction level. The strategy pattern should abstract over the WHAT (how to format a tag), not the WHO (which publisher).
EditionNamingStrategydescribes behavior.IeeeNamingStrategydescribes an organization. -
Unnecessary enum proliferation. Most publishers (ISO, IEC, ITU, BIPM, OIML, UN, CSA, etc.) all use edition-based naming. Creating
DocumentType.Iso,DocumentType.Iec, etc. purely to dispatch to the same strategy adds complexity without value.
| Strategy | Tag format | Asset format | Used by |
|---|---|---|---|
EditionNamingStrategy |
{id}/ed{N}[-{stage}] |
{id}-ed{N}[-{stage}].zip |
CC, ISO, IEC, ITU, BIPM, OIML, UN, CSA, M3AAWG, MPFA, PDFA, Ribose, unknown |
VersionNamingStrategy |
{id}/v{N} |
{id}-v{N}.zip |
IHO, OGC |
InternetDraftNamingStrategy |
id-{name}/{draftN} |
draft-ietf-{name}-{draftN}.zip |
IETF Internet-Drafts |
RfcNamingStrategy |
{id}/ed{N} |
{id}.zip |
IETF RFCs |
DraftSuffixNamingStrategy |
{base}/{N} (from -d{N} suffix) |
{id}.zip |
IEEE Drafts |
DocumentType (detected from identifier prefix) maps to a naming strategy in createDefaultRegistry(). Multiple DocumentType values can map to the same strategy instance — e.g., Iho and Ogc share one VersionNamingStrategy.
Each document gets its own release tag and asset, independent of other documents in the same repo. Tag naming is data-driven from RXL metadata — normalized docidentifier + edition + stage — so it works for any Metanorma flavor without special-casing.
| Document | Stage | Tag | Asset | Strategy |
|---|---|---|---|---|
| CC standard | Published | cc-51015/ed1 |
cc-51015-ed1.zip |
Edition |
| CC standard | Working Draft | cc-51015/ed2-wd |
cc-51015-ed2-wd.zip |
Edition |
| ISO | Published | iso-8601-1-2019/ed1 |
iso-8601-1-2019-ed1.zip |
Edition |
| ISO | WD | iso-wd-8601-1-2026/ed2-wd |
iso-wd-8601-1-2026-ed2-wd.zip |
Edition |
| IETF I-D | — | id-calext-jscalendar/32 |
draft-ietf-calext-jscalendar-32.zip |
InternetDraft |
| IETF RFC | Published | rfc-8984/ed1 |
rfc-8984.zip |
Rfc |
| IEEE Draft | — | ieee-draft-std-987-6-2020/3 |
ieee-draft-std-987-6-2020-d3.zip |
DraftSuffix |
| IHO | Published | s-102/v2.1.0 |
s-102-v2.1.0.zip |
Version |
| OGC | Published | 17-069r3/v1.0 |
17-069r3-v1.0.zip |
Version |
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.
The metanorma.release.yml file controls which documents in a repo are eligible for release, their channels, and stage constraints. If this file is absent, all documents are released.
Use pattern to auto-assign channels by document ID:
# metanorma.release.yml
documents:
- pattern: "cc-s-*"
channels: [public/standards]
- pattern: "cc-r-*"
channels: [public/reports]
- pattern: "cc-a-*"
channels: [public/admin]When an author adds a new document like cc-s-51020, the pattern cc-s-* automatically assigns it to public/standards. No manifest update needed.
For single-document repos or exceptions, use source:
documents:
- source: sources/cc-10001.adoc
channels: [public/directives]Restrict releases to specific stages:
documents:
- pattern: "cc-s-*"
stages: [published] # only published stage creates a release
channels: [public/standards]Working drafts and committee drafts never create a GitHub Release with this constraint.
| Value | Effect |
|---|---|
public (default) |
Document is packaged and released |
private |
Document is not released publicly |
members |
Reserved for future use (member-only access) |
When a manifest exists but a document doesn't match any pattern or source, it defaults to the default-visibility input (default: public).
The action uses content hashing to avoid re-releasing unchanged documents:
- SHA-256 hash all files in each document's output directory
- Compare against the hash stored in the previous GitHub Release body
- If the hash matches → skip (unchanged)
- If the hash differs → package and release
The hash is stored in the first line of the release body: content-hash:{sha256hex}.
A channel is an audience/category pair that determines where a document appears:
- audience:
public,members, orinternal— who can see it - category: free-form identifier — where it appears in the portal
public/standards ← published standards, visible to everyone
public/reports ← conference and technical reports
members/internal-review ← only visible to organization members
internal/working-draft ← never aggregated by any external portal
The publisher sets the channel. The aggregator (downstream portal) filters by it. A portal cannot override or discover channels the publisher didn't assign.
Published releases are immutable by default — the action will not overwrite an existing release. To selectively re-release a specific document (e.g. to fix bad metadata), use the force-replace input:
- uses: actions-mn/release@v1
with:
force-replace: 'cc-s-51015' # exact doc ID
# or: force-replace: 'cc-s-*' # glob pattern
token: ${{ secrets.GITHUB_TOKEN }}Only matched documents are deleted and recreated. Other documents in the same repo are completely unaffected.
Each GitHub Release carries structured metadata in its body for downstream consumers:
content-hash:abc123...
<!-- mn-release-metadata
{"version":1,"id":"cc-s-51015","channels":["public/standards"],
"stage":"published","edition":"1","title":"My Standard"}
-->
## CC/S 51015
| Field | Value |
|---|---|
| Document | cc-s-51015 |
| Edition | 1 |
| Status | published |
| Channels | public/standards |
The mn-release-metadata JSON block (inside an HTML comment) is parsed by actions-mn/aggregate for channel filtering and indexing.
Add the metanorma-release topic to your repository to opt in to portal discovery:
gh api repos/{owner}/{repo}/topics -X PUT --field names='["metanorma-release"]'Portals discover participating repositories:
curl "https://api.github.com/search/repositories?q=topic:metanorma-release+org:CalConnect"The action follows a pipeline pattern with pluggable interfaces at each stage:
Discover → Filter → Detect → Package → Publish
(RXL) (manifest) (hash) (zip) (GitHub)
Each stage is defined by an interface (IDocumentExtractor, IVisibilityFilter, IChangeDetector, IArtifactPackager, IReleasePublisher), making the pipeline extensible without modifying existing code.
This project is licensed under the MIT License - see the LICENSE file for details.