Skip to content

Commit 366b9a2

Browse files
build(docs): add versioned documentation with auto-detection
Generates the versioned documentation and publishes it to S3. Only runs after a successful releases unless the release is on the "next" branch. In general, runs on main push to /latest/, while release branches (e.g., release/48.x) push to /v48/ Close #675 Co-authored-by: Maximilian Köller <maximilian.koeller@siemens.com>
1 parent 1c8b49a commit 366b9a2

File tree

5 files changed

+329
-8
lines changed

5 files changed

+329
-8
lines changed

.github/workflows/release.yaml

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ jobs:
1616
- build-and-test
1717
permissions:
1818
id-token: write
19+
outputs:
20+
new_release: ${{ steps.check_release.outputs.new_release }}
21+
release_tag: ${{ steps.check_release.outputs.release_tag }}
1922
steps:
2023
- uses: actions/checkout@v6
2124
with:
@@ -30,6 +33,12 @@ jobs:
3033
name: dist
3134
path: dist
3235
- run: npm ci --prefer-offline --no-audit
36+
37+
- id: before_release
38+
run: |
39+
BEFORE_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
40+
echo "before_tag=$BEFORE_TAG" >> $GITHUB_OUTPUT
41+
3342
- run: npx semantic-release
3443
env:
3544
SKIP_COMMIT: ${{ github.ref_name == 'next' && 'true' || '' }}
@@ -38,3 +47,147 @@ jobs:
3847
GIT_COMMITTER_NAME: 'Siemens Element Bot'
3948
GIT_COMMITTER_EMAIL: 'simpl.si@siemens.com'
4049
GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_GITHUB_TOKEN }}
50+
51+
- id: check_release
52+
run: |
53+
BEFORE_TAG="${{ steps.before_release.outputs.before_tag }}"
54+
AFTER_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
55+
56+
if [[ -n "$AFTER_TAG" && "$AFTER_TAG" != "$BEFORE_TAG" ]]; then
57+
echo "release_tag=$AFTER_TAG" >> $GITHUB_OUTPUT
58+
echo "new_release=true" >> $GITHUB_OUTPUT
59+
else
60+
echo "new_release=false" >> $GITHUB_OUTPUT
61+
echo "release_tag=" >> $GITHUB_OUTPUT
62+
fi
63+
64+
# Generates the versioned documentation and publishes it to S3.
65+
# This job only runs after a successful release unless the release is on the "next" branch.
66+
# In general, runs on main push to /latest/, while release branches (e.g., release/48.x) push to /v48/
67+
# There are two special cases:
68+
# 1. Release on a release branch while there is no new major release on main.
69+
# This happens, when we already merged breaking changes, but do a release for an older version before releasing from main.
70+
# In this case, we still want to update the /latest/ although we are on a release branch.
71+
# 2. The first release of a new major version.
72+
# In this case, we need to move the existing /latest/ to the previous major version folder.
73+
publish-documentation-release:
74+
runs-on: ubuntu-24.04
75+
needs:
76+
- publish
77+
- build-and-test
78+
if: success() && needs.publish.outputs.new_release == 'true' && github.ref_name != 'next'
79+
permissions:
80+
id-token: write
81+
env:
82+
VERSIONED_BUCKET_NAME: simpl-element-release
83+
CLOUDFRONT_DOMAIN: d2uqfzn4lxgtwv.cloudfront.net
84+
steps:
85+
- uses: actions/checkout@v4
86+
with:
87+
fetch-depth: 0
88+
- uses: actions/download-artifact@v7
89+
with:
90+
name: pages
91+
path: pages
92+
- uses: aws-actions/configure-aws-credentials@v5.1.1
93+
with:
94+
role-to-assume: arn:aws:iam::974483672234:role/simpl-element-release
95+
role-session-name: element-release-docs
96+
aws-region: eu-west-1
97+
# Prepare gathers the necessary information:
98+
# - the version / major version we are releasing
99+
# - to which directory we need to deploy (latest or v123)
100+
# - latest-version.txt contains the current version of the "latest" object in the S3 bucket
101+
# If we create a new major version, we also move the existing latest/ to the previous major version folder.
102+
# This outputs three values:
103+
# - major_version: the major version we are releasing (e.g., v49)
104+
# - deploy_latest: whether we need to deploy to latest/ (true/false)
105+
# - latest: the current version of latest/ before this release (e.g., v48)
106+
- id: prepare
107+
run: |
108+
DEPLOY_RELEASE="${{ needs.publish.outputs.release_tag }}"
109+
VERSION="${DEPLOY_RELEASE#v}"
110+
MAJOR_VERSION="v${VERSION%%.*}"
111+
112+
echo "major_version=$MAJOR_VERSION" >> "$GITHUB_OUTPUT"
113+
114+
aws s3 cp "s3://${{ env.VERSIONED_BUCKET_NAME }}/latest-version.txt" latest-version.txt || true
115+
116+
LATEST_VERSION=""
117+
if [[ -f latest-version.txt ]]; then
118+
LATEST_VERSION=$(tr -d '\r\n' < latest-version.txt)
119+
fi
120+
121+
DEPLOY_LATEST="false"
122+
123+
# if [[ "${{ github.ref_name }}" == "${{ github.event.repository.default_branch }}" ]]; then
124+
if [[ "a" == "a" ]]; then
125+
DEPLOY_LATEST="true"
126+
if [[ -n "$LATEST_VERSION" && "$LATEST_VERSION" != "$MAJOR_VERSION" ]]; then
127+
aws s3 sync --quiet --no-progress --delete \
128+
"s3://${{ env.VERSIONED_BUCKET_NAME }}/latest/" \
129+
"s3://${{ env.VERSIONED_BUCKET_NAME }}/$LATEST_VERSION/"
130+
fi
131+
elif [[ "$LATEST_VERSION" == "$MAJOR_VERSION" ]]; then
132+
DEPLOY_LATEST="true"
133+
fi
134+
135+
echo "deploy_latest=$DEPLOY_LATEST" >> "$GITHUB_OUTPUT"
136+
echo "latest=$LATEST_VERSION" >> "$GITHUB_OUTPUT"
137+
138+
aws s3 ls s3://${{ env.VERSIONED_BUCKET_NAME }}/ | grep "PRE v" | awk '{print $2}' | sed 's/\/$//' | sed 's/^v//' > s3-versions.txt || true
139+
140+
# Generate versions.json based on the existing versions in S3 and the new release
141+
- uses: actions/github-script@v7
142+
name: Generate versions.json
143+
with:
144+
script: |
145+
const fs = require('fs');
146+
147+
const rawVersions = fs
148+
.readFileSync('s3-versions.txt', 'utf8')
149+
.split(/\r?\n/)
150+
.map(line => line.trim())
151+
.filter(Boolean);
152+
153+
const payload = rawVersions
154+
.sort((a, b) => Number(b) - Number(a))
155+
.map(versionName => ({
156+
version: `v${versionName}`,
157+
title: `${versionName}.x`
158+
}));
159+
160+
if (${{ steps.prepare.outputs.deploy_latest == 'true' }}) {
161+
const majorVersion = "${{ steps.prepare.outputs.major_version }}";
162+
const numericTitle = majorVersion.replace(/^v/i, '');
163+
payload.unshift({ version: '', title: `${numericTitle}.x` });
164+
} else {
165+
const latestFallback = "${{ steps.prepare.outputs.latest }}";
166+
const numericTitle = latestFallback.replace(/^v/i, '');
167+
payload.unshift({ version: '', title: `${numericTitle}.x` });
168+
}
169+
170+
fs.writeFileSync('versions.json', JSON.stringify(payload, null, 2));
171+
172+
# Uploads the generated documentation to the appropriate S3 bucket and path.
173+
- run: |
174+
SITE_URL="https://element.siemens.io/"
175+
176+
# Update canonical URLs to point to versioned URLs instead of root
177+
# This ensures search engines index the correct versioned documentation (only one version)
178+
MAJOR_VERSION="${{ steps.prepare.outputs.major_version }}"
179+
180+
if [[ "${{ steps.prepare.outputs.deploy_latest }}" == "true" ]]; then
181+
aws s3 sync --quiet --no-progress --delete "pages/" "s3://${{ env.VERSIONED_BUCKET_NAME }}/latest/"
182+
echo "$MAJOR_VERSION" > latest-version.txt
183+
aws s3 cp latest-version.txt "s3://${{ env.VERSIONED_BUCKET_NAME }}/latest-version.txt"
184+
else
185+
aws s3 sync --quiet --no-progress --delete "pages/" "s3://${{ env.VERSIONED_BUCKET_NAME }}/$MAJOR_VERSION/"
186+
fi
187+
188+
# Upload versions.json with short cache-control for quick updates
189+
if [[ ! -f "deploy-site/versions.json" ]]; then
190+
echo "Error: deploy-site/versions.json file does not exist"
191+
exit 1
192+
fi
193+
aws s3 cp versions.json s3://${{ env.VERSIONED_BUCKET_NAME }}/versions.json

docs/_src/version-selector.js

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/**
2+
* Version Selector for Siemens Element Documentation
3+
*
4+
* This script implements a version switcher that:
5+
* - Fetches versions.json from the root of the domain
6+
* - Supports absolute version URLs
7+
* - Preserves current page path when switching versions
8+
* - Gracefully degrades if versions.json is not found (no errors, just no selector)
9+
*
10+
* If versions.json is not available (404), the page loads normally without the version selector.
11+
* No errors are thrown to ensure documentation remains accessible.
12+
*
13+
* Based on MkDocs Material's version selector implementation
14+
* https://github.com/squidfunk/mkdocs-material
15+
*/
16+
17+
(function () {
18+
'use strict';
19+
20+
/**
21+
* Get the base URL of the site (root domain)
22+
*/
23+
function getBaseURL() {
24+
const location = window.location;
25+
return `${location.protocol}//${location.host}`;
26+
}
27+
28+
/**
29+
* Get current version from URL path
30+
* Only recognizes versions that exist in versions.json
31+
* Returns empty string if at root (no version in path)
32+
*/
33+
function getCurrentVersion(versions) {
34+
const path = window.location.pathname;
35+
36+
// If we don't have versions yet, try to extract from path
37+
if (!versions) {
38+
const match = path.match(/\/([^/]+)\//);
39+
return match ? match[1] : '';
40+
}
41+
42+
// Check if any known version is in the path
43+
for (const version of versions) {
44+
if (version.version && path.includes(`/${version.version}/`)) {
45+
return version.version;
46+
}
47+
}
48+
49+
// No version found in path, assume root-level version
50+
return '';
51+
}
52+
53+
/**
54+
* Get current page path relative to version
55+
*/
56+
function getCurrentPagePath(currentVersion) {
57+
const path = window.location.pathname;
58+
59+
// If no version (root-level), return the full path
60+
if (!currentVersion || currentVersion === '') {
61+
return path.substring(1); // Remove leading slash
62+
}
63+
64+
// Find version in path and get everything after it
65+
const versionIndex = path.indexOf(`/${currentVersion}/`);
66+
if (versionIndex !== -1) {
67+
return path.substring(versionIndex + currentVersion.length + 2);
68+
}
69+
70+
return '';
71+
}
72+
73+
/**
74+
* Build version URL with current page path
75+
* Supports versions at root (empty string), in subdirectories, or absolute URLs
76+
*/
77+
function buildVersionURL(version, currentVersion, preservePath = true) {
78+
// If version is an absolute URL, return as-is
79+
if (
80+
version.startsWith('http://') ||
81+
version.startsWith('https://') ||
82+
version.startsWith('//')
83+
) {
84+
return version;
85+
}
86+
87+
const baseURL = getBaseURL();
88+
const pagePath = preservePath ? getCurrentPagePath(currentVersion) : '';
89+
90+
// If version is empty string or "/", host at root
91+
if (!version || version === '/' || version === '') {
92+
return `${baseURL}/${pagePath}`;
93+
}
94+
95+
return `${baseURL}/${version}/${pagePath}`;
96+
}
97+
98+
/**
99+
* Render version selector HTML
100+
*/
101+
function renderVersionSelector(versions, currentVersion) {
102+
const current = versions.find(v => v.version === currentVersion) || versions[0];
103+
const visibleVersions = versions.filter(v => !v.hidden);
104+
105+
const html = `<div class="md-version"><button class="md-version__current" aria-label="Select version">${current.title}</button><ul class="md-version__list">${visibleVersions.map(version => `<li class="md-version__item"><a href="${buildVersionURL(version.version, currentVersion)}" class="md-version__link">${version.title}</a></li>`).join('')}</ul></div>`;
106+
107+
return html;
108+
}
109+
110+
/**
111+
* Initialize version selector
112+
*/
113+
function initVersionSelector() {
114+
const baseURL = getBaseURL();
115+
const versionsURL = `${baseURL}/versions.json`;
116+
117+
fetch(versionsURL)
118+
.then(response => {
119+
if (!response.ok) {
120+
if (response.status === 404) {
121+
return null;
122+
}
123+
throw new Error(`Failed to fetch versions.json: ${response.status}`);
124+
}
125+
return response.json();
126+
})
127+
.then(versions => {
128+
if (!versions) {
129+
return;
130+
}
131+
132+
if (!Array.isArray(versions) || versions.length === 0) {
133+
console.warn('[Version Selector] versions.json is empty or invalid');
134+
return;
135+
}
136+
137+
// Get current version after we have the versions list
138+
const currentVersion = getCurrentVersion(versions);
139+
140+
// Find the .md-header element
141+
const header = document.querySelector('.md-header');
142+
if (!header) {
143+
console.warn('[Version Selector] .md-header element not found');
144+
return;
145+
}
146+
147+
// Create .md-header__topic wrapper with version selector inside
148+
const html = renderVersionSelector(versions, currentVersion);
149+
const topicWrapper = document.createElement('div');
150+
topicWrapper.className = 'md-header__topic';
151+
topicWrapper.innerHTML = html;
152+
153+
// Append to .md-header
154+
header.appendChild(topicWrapper);
155+
})
156+
.catch(error => {
157+
console.error('[Version Selector] Failed to load:', error.message);
158+
});
159+
}
160+
161+
// Initialize when DOM is ready
162+
if (document.readyState === 'loading') {
163+
document.addEventListener('DOMContentLoaded', initVersionSelector);
164+
} else {
165+
initVersionSelector();
166+
}
167+
})();

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ extra_javascript:
228228
- '//w3.siemens.com/ote/ote_config.js'
229229
- '//w3.siemens.com/ote/sinet/ote.js'
230230
- 'https://assets.adobedtm.com/5dfc7d97c6fb/f16b45bec907/launch-af252bb19983.min.js'
231+
- '_src/version-selector.js'
231232
extra:
232233
links:
233234
- name: 'GitHub'

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ license = "MIT"
77
requires-python = ">=3.11, <4"
88
readme = "README.md"
99
dependencies = [
10-
"mkdocs-code-siemens-code-docs-theme>=7.7.0,<8",
10+
"mkdocs-code-siemens-code-docs-theme>=7.8.0,<8",
1111
"mkdocs-minify-html-plugin==0.3.9",
1212
"mkdocs-element-docs-builder",
1313
]

0 commit comments

Comments
 (0)