diff --git a/.gitignore b/.gitignore
index e456e06..540ecc8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -71,3 +71,6 @@ code/include
*/__pycache__
.vercel
__pycache__/
+
+# Playwright MCP scratch output
+.playwright-mcp/
diff --git a/ontology/dprod/dprod-ontology.ttl b/ontology/dprod/dprod-ontology.ttl
index 1f34281..78bf049 100644
--- a/ontology/dprod/dprod-ontology.ttl
+++ b/ontology/dprod/dprod-ontology.ttl
@@ -18,6 +18,7 @@ dct:description a owl:AnnotationProperty .
dct:contributor a owl:AnnotationProperty .
dct:license a owl:AnnotationProperty .
dct:modified a owl:AnnotationProperty .
+dct:issued a owl:AnnotationProperty .
dprod:
a owl:Ontology ;
@@ -28,6 +29,18 @@ dprod:
"The ontology utilizes the notion of Datasets as defined from DCAT vocabulary."@en ;
rdfs:comment "DPROD is an OWL ontology designed to facilitate interoperability between data product descriptors."@en ;
dct:license ;
+ # The publication date of this version of the specification, and the single
+ # source of truth for the date in the generated spec's header. Authored on
+ # purpose: deriving it from the last commit let maintenance changes — a URL
+ # retirement, a typo fix — silently republish the standard (issue #253).
+ # Bump it when a version is issued, in step with owl:versionIRI.
+ #
+ # Once a version is published by OMG, its OMG document number is
+ # authoritative for this date: ptc/YY-MM-NN encodes the month. DPROD 1.0
+ # beta 1 is ptc/25-02-01, hence February 2025 in the frozen archive. The
+ # value below is for the working draft, which has no OMG document number
+ # yet, so it tracks owl:versionIRI instead.
+ dct:issued "2026-06-01"^^xsd:date ;
dct:modified "2026-08-04"^^xsd:date ;
dct:publisher ;
dct:contributor ;
diff --git a/respec/template.html b/respec/template.html
index 867def6..e347c87 100644
--- a/respec/template.html
+++ b/respec/template.html
@@ -14,6 +14,10 @@
latestVersion: "https://www.omg.org/spec/DPROD/dprod/",
edDraftURI: "https://ekgf.org/spec/{{ branch }}/",
specStatus: "base",
+ // Pinned to the branch's last commit by the spec generator. Without it
+ // ReSpec uses the date the page is viewed, since it runs in the reader's
+ // browser (issue #253).
+ publishDate: "{{ publish_date }}",
editors: [
{
name: "Tony Seale, Chair",
diff --git a/site/public/spec/archive/1.0/index.html b/site/public/spec/archive/1.0/index.html
index 839c836..bf7ba89 100644
--- a/site/public/spec/archive/1.0/index.html
+++ b/site/public/spec/archive/1.0/index.html
@@ -13,6 +13,20 @@
// group: "Semantic Data Products Working Group",
latestVersion: "https://ekgf.org/dprod/spec/main/",
specStatus: "base",
+ // Frozen: DPROD 1.0 beta 1. Hard-coded rather than derived, because this
+ // document is immutable and its date must not move when a maintenance
+ // commit touches the branch. Without it ReSpec would stamp the day the
+ // page is viewed, since it runs in the reader's browser (issue #253).
+ //
+ // The OMG document number is authoritative for the date of a published
+ // DPROD specification. This is ptc/25-02-01, whose YY-MM-NN form gives
+ // February 2025, and the document's own cover agrees ("Date: February
+ // 2025").
+ //
+ // The OMG catalog page instead records "Publication Date: January 2025".
+ // It is not followed: the document number wins. Neither source states a
+ // day, so the 01 is a convention rather than a fact.
+ publishDate: "2025-02-01",
editors: [
{
name: "Tony Seale, Chair",
diff --git a/spec-generator/main.py b/spec-generator/main.py
index 92aa219..abd2993 100644
--- a/spec-generator/main.py
+++ b/spec-generator/main.py
@@ -30,6 +30,29 @@ def detect_branch() -> str:
return "main"
+def detect_publish_date(g) -> str:
+ """The publication date of this version, from `dct:issued` on the ontology.
+
+ ReSpec runs in the reader's browser, and with no `publishDate` it defaults
+ to the day the page is *viewed*. Every build therefore claimed to have been
+ published today — including the frozen 1.0 archive, whose header changed
+ date every morning (issue #253).
+
+ The date is read from a triple rather than derived from git history. A
+ commit date would mean any maintenance change — retiring a URL, fixing a
+ typo — silently republished the standard. Issuing a version is a decision,
+ so it is recorded as one.
+ """
+ issued = g.value(URIRef(ontology_namespace_iri), DCTERMS.issued)
+ if issued is None:
+ raise RuntimeError(
+ f"<{ontology_namespace_iri}> has no dct:issued. The spec's "
+ "publication date is authored in dprod-ontology.ttl; add the "
+ "triple rather than letting ReSpec fall back to today's date."
+ )
+ return str(issued)
+
+
# Define a function to add classes and properties to the context
def add_to_context(g, node_shape_iri, node_shapes: dict):
if not is_node_shape(g, node_shape_iri): # we're at this point only interested in the NodeShapes
@@ -100,7 +123,12 @@ def main():
# generate_class_and_property_pages(classes)
- generate_spec_page({'classes': classes, 'examples': examples, 'branch': detect_branch()})
+ generate_spec_page({
+ 'classes': classes,
+ 'examples': examples,
+ 'branch': detect_branch(),
+ 'publish_date': detect_publish_date(g),
+ })
g_ontology = load_dprod_ontology()
g_shapes = load_dprod_shapes()
diff --git a/tests/test_spec_publish_date.py b/tests/test_spec_publish_date.py
new file mode 100644
index 0000000..7aae937
--- /dev/null
+++ b/tests/test_spec_publish_date.py
@@ -0,0 +1,76 @@
+"""The spec's publication date must be authored, never inferred.
+
+ReSpec runs in the reader's browser. With no `publishDate` in its config it
+stamps the day the page is *viewed*, so every DPROD document — including the
+frozen 1.0 archive — reported that it had been published today, and the date
+changed every morning (issue #253).
+
+Deriving the date from git history does not fix it either: a maintenance
+commit that retires a URL or fixes a typo would silently republish the
+standard. The date is therefore a triple, `dct:issued`, and these tests keep
+it that way.
+"""
+
+from __future__ import annotations
+
+import re
+import unittest
+from pathlib import Path
+
+from rdflib import DCTERMS, Graph, URIRef
+
+REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
+ONTOLOGY_FILE = REPOSITORY_ROOT / "ontology" / "dprod" / "dprod-ontology.ttl"
+SPEC_TEMPLATE = REPOSITORY_ROOT / "respec" / "template.html"
+ARCHIVE_SPEC = (
+ REPOSITORY_ROOT / "site" / "public" / "spec" / "archive" / "1.0" / "index.html"
+)
+ONTOLOGY_IRI = URIRef("https://www.omg.org/spec/DPROD/dprod/")
+ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
+
+
+class SpecPublishDateTest(unittest.TestCase):
+ def test_ontology_carries_exactly_one_issued_date(self) -> None:
+ graph = Graph()
+ graph.parse(ONTOLOGY_FILE, format="ttl")
+
+ issued = list(graph.objects(ONTOLOGY_IRI, DCTERMS.issued))
+
+ self.assertEqual(
+ 1,
+ len(issued),
+ "The DPROD ontology must carry exactly one dct:issued; it is the "
+ "single source of the spec's publication date.",
+ )
+ self.assertRegex(str(issued[0]), ISO_DATE)
+
+ def test_template_pins_the_publish_date(self) -> None:
+ template = SPEC_TEMPLATE.read_text(encoding="utf-8")
+
+ self.assertIn(
+ 'publishDate: "{{ publish_date }}"',
+ template,
+ "respec/template.html must pin publishDate. Without it ReSpec "
+ "falls back to the date the page is viewed.",
+ )
+
+ def test_frozen_archive_pins_a_literal_publish_date(self) -> None:
+ """The 1.0 archive is immutable, so its date is a literal, not a build value."""
+ archive = ARCHIVE_SPEC.read_text(encoding="utf-8")
+
+ match = re.search(r'publishDate:\s*"(\d{4}-\d{2}-\d{2})"', archive)
+
+ self.assertIsNotNone(
+ match,
+ "The frozen 1.0 archive must pin its own publishDate, otherwise an "
+ "immutable standard reports a new publication date every day.",
+ )
+ self.assertNotIn(
+ "{{",
+ match.group(0),
+ "The archive is a static snapshot and must not be templated.",
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()