Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/docs-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# *******************************************************************************
# Copyright (c) 2026 Contributors to the Eclipse Foundation
#
# See the NOTICE file(s) distributed with this work for additional
# information regarding copyright ownership.
#
# This program and the accompanying materials are made available under the
# terms of the Apache License Version 2.0 which is available at
# https://www.apache.org/licenses/LICENSE-2.0
#
# SPDX-License-Identifier: Apache-2.0
# *******************************************************************************

name: Publish Docs

on:
workflow_run:
workflows: ["Test and Build Docs"]
types: [completed]

jobs:
docs-publish:
if: github.event.workflow_run.conclusion == 'success'
uses: eclipse-score/cicd-workflows/.github/workflows/docs-publish.yml@f77ed751fa587e3a1142b2d85320917e00c888ae # main (2026-10)
permissions:
actions: read
contents: write
id-token: write
pages: write
pull-requests: write
30 changes: 8 additions & 22 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,46 +14,32 @@
name: Test and Build Docs

permissions:
contents: write
pages: write
pull-requests: write
id-token: write
contents: read

on:
pull_request_target:
types: [opened, reopened, synchronize] # Allows forks to trigger the docs build
pull_request:
push:
branches:
- main
merge_group:
types: [checks_requested]

jobs:
docs-verify:
uses: eclipse-score/cicd-workflows/.github/workflows/docs-verify.yml@c1c90b1a82a1fab0fc202979dde6686b2162d5a8 # v0.0.0
permissions:
pull-requests: write
contents: read
with:
bazel-docs-verify-target: "--lockfile_mode=error //:docs_check"

run-tests:
uses: ./.github/workflows/coverage_report.yml
permissions:
contents: read
pull-requests: read

build-docs:
needs: [run-tests, docs-verify]
needs: [run-tests]
if: ${{ needs.run-tests.result == 'success' }}
uses: eclipse-score/cicd-workflows/.github/workflows/docs.yml@c1c90b1a82a1fab0fc202979dde6686b2162d5a8 # v0.0.0
uses: eclipse-score/cicd-workflows/.github/workflows/docs.yml@f77ed751fa587e3a1142b2d85320917e00c888ae # main (2026-10)
permissions:
contents: write
pages: write
pull-requests: write
id-token: write
contents: read
with:
# the bazel-target depends on your repo specific docs_targets configuration (e.g. "suffix")
bazel-target: "--lockfile_mode=error //:docs -- --github_user=${{ github.repository_owner }} --github_repo=${{ github.event.repository.name }}"
# Appended to `bazel run`; setup-bazel-cache applies the caches to every bazel call in the job.
bazel-target: >-
--lockfile_mode=error //:docs -- --github_user=${{ github.repository_owner }} --github_repo=${{ github.event.repository.name }} && bazel run --lockfile_mode=error //:publish_dependable_element_reports
retention-days: 3
tests-report-artifact: logging_cpp_coverage
32 changes: 32 additions & 0 deletions BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
#
# SPDX-License-Identifier: Apache-2.0
# *******************************************************************************
load("@rules_python//python:defs.bzl", "py_binary", "py_test")
load("@score_docs_as_code//:docs.bzl", "docs")
load("@score_tooling//:defs.bzl", "copyright_checker", "setup_starpls")
load("@score_tooling//third_party/format:macros.bzl", "use_format_targets")
Expand Down Expand Up @@ -46,6 +47,37 @@ exports_files([
".clang-tidy",
])

_DEPENDABLE_ELEMENT = "dependable_element_mw_log_backend_doc"

# Public URL folder; docs/dependable_elements/index.rst links to it.
_PUBLISH_NAME = "mw_log_backend"

_DEPENDABLE_ELEMENT_DOC = "//score/mw/log/dependability:" + _DEPENDABLE_ELEMENT

# Must run after `:docs`, which owns and may wipe `_build`.
py_binary(
name = "publish_dependable_element_reports",
testonly = True,
srcs = ["scripts/publish_dependable_element_reports.py"],
data = [_DEPENDABLE_ELEMENT_DOC],
env = {
"DEPENDABLE_ELEMENT_HTML": "$(rlocationpath " + _DEPENDABLE_ELEMENT_DOC + ")",
"DEPENDABLE_ELEMENT_NAME": _PUBLISH_NAME,
},
main = "scripts/publish_dependable_element_reports.py",
deps = ["@rules_python//python/runfiles"],
)

py_test(
name = "publish_dependable_element_reports_test",
srcs = [
"scripts/publish_dependable_element_reports.py",
"scripts/publish_dependable_element_reports_test.py",
],
main = "scripts/publish_dependable_element_reports_test.py",
deps = ["@rules_python//python/runfiles"],
)

# Creates all documentation targets:
# - `:docs` for building documentation at build-time
docs(
Expand Down
20 changes: 20 additions & 0 deletions docs/dependable_elements/index.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
..
# *******************************************************************************
# Copyright (c) 2026 Contributors to the Eclipse Foundation
#
# See the NOTICE file(s) distributed with this work for additional
# information regarding copyright ownership.
#
# This program and the accompanying materials are made available under the
# terms of the Apache License Version 2.0 which is available at
# https://www.apache.org/licenses/LICENSE-2.0
#
# SPDX-License-Identifier: Apache-2.0
# *******************************************************************************

Safety Report
===========================================

The work products for the ``mw::log`` backend dependable-element can be found in the report:

`mw::log Backend Dependable Element Report <mw_log_backend/index.html>`_
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ The Components documentation provides detailed documentation for each individual

components/datarouter/index.rst
components/mw_log/index.rst
dependable_elements/index

Quick Start
-----------
Expand Down
99 changes: 99 additions & 0 deletions scripts/publish_dependable_element_reports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import os
import shutil
import stat
import sys
from pathlib import Path

from python.runfiles import Runfiles


def _add_owner_write(path: str) -> None:
# Skip symlinks: chmod would follow them and modify the target.
if os.path.islink(path):
return
os.chmod(path, stat.S_IMODE(os.stat(path).st_mode) | stat.S_IWUSR)


def _make_writable(path: Path) -> None:
for root, dirs, files in os.walk(path):
for name in dirs + files:
_add_owner_write(os.path.join(root, name))
_add_owner_write(str(path))


def publish(src: Path, build_dir: Path, name: str) -> int:
"""Copies the pre-built HTML at `src` to `build_dir/dependable_elements/<name>`."""
# The docs build may wipe _build, so it has to run before this script.
if not (build_dir / "index.html").is_file():
print(
f"error: {build_dir}/index.html not found; run //:docs before publishing",
file=sys.stderr,
)
return 1

if not src.is_dir():
print(f"error: expected {name} output at {src}", file=sys.stderr)
return 1

# Catches a mismatch between the published folder and the link in docs/dependable_elements.
landing = build_dir / "dependable_elements" / "index.html"
link = f'href="{name}/index.html"'
if not landing.is_file() or link not in landing.read_text(encoding="utf-8"):
print(f"error: {landing} does not contain {link}", file=sys.stderr)
return 1

dest = build_dir / "dependable_elements" / name
tmp = dest.parent / f".{name}.tmp"
dest.parent.mkdir(parents=True, exist_ok=True)

try:
if tmp.exists():
_make_writable(tmp)
shutil.rmtree(tmp)
# Bazel outputs are read-only; make the copy writable so reruns can replace it.
shutil.copytree(src, tmp)
_make_writable(tmp)

if not (tmp / "index.html").is_file():
print(f"error: {name}/index.html missing in {src}", file=sys.stderr)
return 1

if dest.exists():
_make_writable(dest)
shutil.rmtree(dest)
os.replace(tmp, dest)
finally:
if tmp.exists():
_make_writable(tmp)
shutil.rmtree(tmp)

print(f"Published {name} to {dest}")
return 0


def main() -> int:
workspace_dir = os.environ.get("BUILD_WORKSPACE_DIRECTORY")
if not workspace_dir:
print("error: this script must be run via 'bazel run'", file=sys.stderr)
return 1

name = os.environ.get("DEPENDABLE_ELEMENT_NAME")
html_rlocation = os.environ.get("DEPENDABLE_ELEMENT_HTML")
if not name or not html_rlocation:
print(
"error: DEPENDABLE_ELEMENT_NAME and DEPENDABLE_ELEMENT_HTML must be set",
file=sys.stderr,
)
return 1

runfiles = Runfiles.Create()
src = runfiles.Rlocation(html_rlocation) if runfiles else None
if not src:
print(f"error: cannot resolve runfile {html_rlocation}", file=sys.stderr)
return 1

return publish(Path(src), Path(workspace_dir) / "_build", name)


if __name__ == "__main__":
sys.exit(main())
106 changes: 106 additions & 0 deletions scripts/publish_dependable_element_reports_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import os
import tempfile
import unittest
from pathlib import Path

from scripts.publish_dependable_element_reports import publish

_NAME = "element_doc"


class PublishTest(unittest.TestCase):
def setUp(self) -> None:
tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._cleanup, tmp)
self.root = Path(tmp.name)
self.build = self.root / "_build"
self.landing_dir = self.build / "dependable_elements"
self.dest = self.landing_dir / _NAME

self.landing_dir.mkdir(parents=True)
(self.build / "index.html").write_text("main")
(self.landing_dir / "index.html").write_text(
f'<a href="{_NAME}/index.html">report</a>'
)

self.src = self._make_source(
"src", {"index.html": "v1", "sub/page.html": "page"}
)

def _make_source(self, name: str, files: dict[str, str]) -> Path:
"""Creates a read-only tree, like Bazel outputs."""
root = self.root / name
for rel, text in files.items():
path = root / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)
path.chmod(0o444)
for dirpath, _, _ in os.walk(root, topdown=False):
os.chmod(dirpath, 0o555)
return root

@staticmethod
def _cleanup(tmp: tempfile.TemporaryDirectory) -> None:
for dirpath, dirs, _ in os.walk(tmp.name):
for d in dirs:
os.chmod(os.path.join(dirpath, d), 0o755)
tmp.cleanup()

def _published_names(self) -> list[str]:
return sorted(p.name for p in self.landing_dir.iterdir())

def test_publishes_tree_without_stray_files(self) -> None:
self.assertEqual(publish(self.src, self.build, _NAME), 0)

self.assertEqual((self.dest / "index.html").read_text(), "v1")
self.assertEqual((self.dest / "sub" / "page.html").read_text(), "page")
self.assertEqual(self._published_names(), sorted(["index.html", _NAME]))

def test_republish_replaces_previous_output(self) -> None:
publish(self.src, self.build, _NAME)
new_src = self._make_source("src_v2", {"index.html": "v2"})

self.assertEqual(publish(new_src, self.build, _NAME), 0)

self.assertEqual((self.dest / "index.html").read_text(), "v2")
self.assertFalse((self.dest / "sub").exists())

def test_published_files_are_not_executable(self) -> None:
publish(self.src, self.build, _NAME)

files = [p for p in self.dest.rglob("*") if p.is_file()]
self.assertTrue(files)
for path in files:
self.assertEqual(path.stat().st_mode & 0o111, 0, path)

def _assert_rejected(self, src: Path) -> None:
self.assertEqual(publish(src, self.build, _NAME), 1)
self.assertFalse(self.dest.exists())

def test_rejects_when_docs_not_built(self) -> None:
(self.build / "index.html").unlink()
self._assert_rejected(self.src)

def test_rejects_missing_source(self) -> None:
self._assert_rejected(self.root / "missing")

def test_rejects_landing_page_without_link_to_report(self) -> None:
(self.landing_dir / "index.html").write_text('<a href="other/index.html">x</a>')
self._assert_rejected(self.src)

def test_rejects_missing_landing_page(self) -> None:
(self.landing_dir / "index.html").unlink()
self._assert_rejected(self.src)

def test_failed_publish_keeps_previous_output(self) -> None:
publish(self.src, self.build, _NAME)
without_index = self._make_source("broken", {"page.html": "x"})

self.assertEqual(publish(without_index, self.build, _NAME), 1)

self.assertEqual((self.dest / "index.html").read_text(), "v1")
self.assertEqual(self._published_names(), sorted(["index.html", _NAME]))


if __name__ == "__main__":
unittest.main()
Loading