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()