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
37 changes: 37 additions & 0 deletions .github/scripts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Documentation workflow inputs

The public site is published only by `simpaths/SimPaths`, from `develop`.
Keep the deployment workflow identical on `main` and `develop`: pushes to
`main`, fork pushes and pull requests must never deploy a site.

`docs_changes.py` gates the expensive build steps using the event's Git diff.
Documentation checks retain the `check` job on every push/PR so a required check
can succeed even when there is nothing to build. Fork contributions still receive
validation when they change website or API inputs. Manual runs force a build.

The input list covers the MkDocs content, theme, scripts, tests and dependencies,
plus Java sources/resources and Maven configuration for the generated API.
Update it if a new build input is added. Simulation inputs, experiment outputs
and Java tests alone do not require rebuilding the documentation.

Pushes compare their before/after revisions; PRs compare their merge base with
the head revision. Deleted/renamed files are included. Missing history causes a
build rather than an unsafe skip. This avoids the changed-file limits of workflow
path filters and API listings.

Builds use the triggering revision. Only deployment jobs share the Pages queue,
so an unrelated push cannot displace a pending documentation build. The queue
uses `queue: max` to retain pending deployments when builds finish out of order:
an older build must not replace a newer deployment waiting to run. GitHub allows
up to 100 pending jobs in this mode and cancels additional jobs when full; a
manual run can retry publication if that limit is reached. See
[GitHub's concurrency documentation](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency).
Before publishing, a build is rejected if newer website/API changes exist on `develop`;
unrelated commits do not prevent publication. A manual deployment from `develop`
can retry a failed publication without requiring another content edit.

Run the change-detection tests with:

```sh
python3 -m unittest discover -s .github/scripts -p 'test_*.py' -v
```
86 changes: 86 additions & 0 deletions .github/scripts/docs_changes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""Select documentation builds from the complete Git diff, not a truncated API list.

The check job still runs on every event so required checks never stay pending.
Unknown/missing history and manual runs build conservatively.
"""

import json
import os
import re
import subprocess
from pathlib import Path


PREFIXES = (
"documentation/wiki/",
"documentation/overrides/",
"documentation/scripts/",
"documentation/visual-tests/",
"src/main/", # Javadoc and the source-aware syntax highlighting hook.
".mvn/",
".github/scripts/",
)
FILES = {
"mkdocs.yml",
"documentation/requirements.txt",
"pom.xml",
"mvnw",
"mvnw.cmd",
".gitattributes",
".gitmodules",
".github/workflows/deploy-docs.yml",
".github/workflows/docs-checks.yml",
}


def affects_docs(path):
return path in FILES or path.startswith(PREFIXES)


def needs_build(event_name, event, cwd=None):
if event_name == "workflow_dispatch":
return True, "Manual run: build requested."
try:
if event_name == "push":
base, head = event["before"], event["after"]
merge_base = False
elif event_name == "pull_request":
pr = event["pull_request"]
base, head = pr["base"]["sha"], pr["head"]["sha"]
merge_base = True
else:
return True, "Unrecognised event: build conservatively."
for revision in (base, head):
if not re.fullmatch(r"[0-9a-fA-F]{40}", revision) or set(revision) == {"0"}:
return True, "New branch or unavailable revision: build conservatively."
# PRs compare against their merge base, so unrelated base-branch changes
# do not trigger builds. Pushes include every commit since the last tip.
revisions = [f"{base}...{head}"] if merge_base else [base, head]
result = subprocess.run(
["git", "diff", "--name-only", "--no-renames", "-z", *revisions, "--"],
cwd=cwd, check=True, capture_output=True,
)
paths = result.stdout.decode("utf-8", errors="surrogateescape").split("\0")
except (KeyError, TypeError, OSError, subprocess.CalledProcessError):
return True, "Cannot establish the full diff: build conservatively."
if any(affects_docs(path) for path in paths):
return True, "Website, API or documentation build inputs changed."
return False, "No documentation build inputs changed; expensive checks skipped."


def main():
try:
event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text())
changed, reason = needs_build(os.environ.get("GITHUB_EVENT_NAME", ""), event)
except (KeyError, OSError, ValueError):
changed, reason = True, "Cannot read the event: build conservatively."
print(reason)
with open(os.environ["GITHUB_OUTPUT"], "a") as output:
output.write(f"changed={str(changed).lower()}\n")
if summary := os.environ.get("GITHUB_STEP_SUMMARY"):
with open(summary, "a") as output:
output.write(f"Documentation: {reason}\n")


if __name__ == "__main__":
main()
135 changes: 135 additions & 0 deletions .github/scripts/test_docs_changes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""Exercise event diffs against real Git histories, including fork-style PRs."""

import json
import os
import subprocess
import tempfile
import unittest
from pathlib import Path

from docs_changes import affects_docs, needs_build


class DocumentationInputs(unittest.TestCase):
def test_site_and_api_inputs(self):
for path in (
'documentation/wiki/research/index.md',
'documentation/wiki/assets/images/model-structure-uk.svg',
'documentation/overrides/main.html',
'documentation/scripts/code_highlighting.py',
'documentation/visual-tests/package-lock.json',
'documentation/requirements.txt', 'mkdocs.yml', 'pom.xml',
'src/main/java/simpaths/model/Person.java',
'src/main/resources/META-INF/persistence.xml', '.mvn/maven.config',
'.github/workflows/deploy-docs.yml', '.github/workflows/docs-checks.yml',
'.github/scripts/docs_changes.py', '.gitattributes',
):
with self.subTest(path=path):
self.assertTrue(affects_docs(path))

def test_unrelated_files(self):
for path in ('README.md', 'input/reg_education.xlsx', 'config/default.yml',
'src/test/java/Test.java', 'documentation/SimPathsUK_Schedule.xlsx',
'.github/workflows/SimPathsBuild.yml'):
with self.subTest(path=path):
self.assertFalse(affects_docs(path))


class EventDiffs(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.git('init', '-q', '-b', 'main')
self.git('config', 'user.name', 'Workflow tests')
self.git('config', 'user.email', 'tests@example.invalid')
self.base = self.commit('README.md')

def git(self, *args):
return subprocess.check_output(['git', *args], cwd=self.root, stderr=subprocess.PIPE,
text=True).strip()

def commit(self, path):
target = self.root / path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(target.read_text() + '\nchange' if target.exists() else 'content')
self.git('add', '--', path)
self.git('commit', '-qm', 'Fixture')
return self.git('rev-parse', 'HEAD')

def push(self, base, head):
return needs_build('push', {'before': base, 'after': head}, self.root)[0]

def test_unrelated_push_and_unchanged_tip_skip(self):
head = self.commit('input/example.xlsx')
self.assertFalse(self.push(self.base, head))
self.assertFalse(self.push(head, head))

def test_multiple_commit_push_includes_earlier_docs_change(self):
self.commit('documentation/wiki/index.md')
head = self.commit('input/example.xlsx')
self.assertTrue(self.push(self.base, head))

def test_later_unrelated_push_does_not_supersede_docs_build(self):
source = self.commit('documentation/wiki/index.md')
tip = self.commit('input/example.xlsx')
self.assertFalse(self.push(source, tip))
newer = self.commit('src/main/java/Example.java')
self.assertTrue(self.push(source, newer))

def test_deletion_and_rename_out_of_docs_are_relevant(self):
before = self.commit('documentation/wiki/index.md')
self.git('mv', 'documentation/wiki/index.md', 'moved.md')
self.git('commit', '-qm', 'Move outside docs')
self.assertTrue(self.push(before, self.git('rev-parse', 'HEAD')))
before = self.commit('documentation/wiki/old.md')
self.git('rm', 'documentation/wiki/old.md')
self.git('commit', '-qm', 'Delete')
self.assertTrue(self.push(before, self.git('rev-parse', 'HEAD')))

def test_more_than_300_changed_files_and_unusual_names(self):
for i in range(310):
(self.root / f'input-{i}.txt').write_text('data')
self.git('add', '.')
self.git('commit', '-qm', 'Many unrelated files')
head = self.commit('documentation/wiki/a space\nand newline.md')
self.assertTrue(self.push(self.base, head))

def test_pr_uses_merge_base_and_checks_fork_changes(self):
base_tip = self.commit('documentation/wiki/base-only.md')
self.git('checkout', '-qb', 'contribution', self.base)
head = self.commit('input/example.xlsx')
event = {'pull_request': {'base': {'sha': base_tip}, 'head': {'sha': head}}}
self.assertFalse(needs_build('pull_request', event, self.root)[0])
event['pull_request']['head']['sha'] = self.commit('documentation/wiki/proposed.md')
self.assertTrue(needs_build('pull_request', event, self.root)[0])

def test_force_push_compares_old_and_new_tips(self):
old = self.commit('documentation/wiki/index.md')
self.git('checkout', '--detach', self.base)
head = self.commit('input/example.xlsx')
self.assertTrue(self.push(old, head))

def test_manual_new_branch_and_missing_history_build(self):
self.assertTrue(needs_build('workflow_dispatch', {}, self.root)[0])
self.assertTrue(self.push('0' * 40, self.base))
self.assertTrue(self.push('f' * 40, self.base))
self.assertTrue(needs_build('push', {}, self.root)[0])
self.assertTrue(needs_build('unknown', {}, self.root)[0])

def test_command_writes_github_output_and_summary(self):
head = self.commit('input/example.xlsx')
event = self.root / 'event.json'
event.write_text(json.dumps({'before': self.base, 'after': head}))
output, summary = self.root / 'output', self.root / 'summary'
script = Path(__file__).with_name('docs_changes.py')
subprocess.run(['python3', str(script)], cwd=self.root, check=True, capture_output=True,
env={**os.environ, 'GITHUB_EVENT_NAME': 'push',
'GITHUB_EVENT_PATH': str(event), 'GITHUB_OUTPUT': str(output),
'GITHUB_STEP_SUMMARY': str(summary)})
self.assertEqual(output.read_text(), 'changed=false\n')
self.assertIn('skipped', summary.read_text())


if __name__ == '__main__':
unittest.main()
64 changes: 45 additions & 19 deletions .github/workflows/deploy-docs.yml
Original file line number Diff line number Diff line change
@@ -1,48 +1,51 @@
name: Deploy documentation
on:
push:
branches: [main]
branches: [develop]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
if: github.ref == 'refs/heads/main'
if: github.repository == 'simpaths/SimPaths' && github.ref == 'refs/heads/develop'
runs-on: ubuntu-latest
outputs:
main: ${{ steps.revisions.outputs.main }}
changed: ${{ steps.changes.outputs.changed }}
source: ${{ github.sha }}
steps:
- uses: actions/checkout@v6
with:
ref: main
persist-credentials: false
fetch-depth: 0
- name: Freeze main source revision
id: revisions
- name: Check documentation build inputs
id: changes
run: |
git fetch origin main --tags
echo "main=$(git rev-parse origin/main)" >> "$GITHUB_OUTPUT"
git checkout --detach origin/main
python3 -m unittest discover -s .github/scripts -p 'test_*.py'
python3 .github/scripts/docs_changes.py
- uses: actions/setup-python@v6
if: steps.changes.outputs.changed == 'true'
with:
python-version: '3.13'
- run: pip install -r documentation/requirements.txt
if: steps.changes.outputs.changed == 'true'
- uses: actions/setup-java@v5
if: steps.changes.outputs.changed == 'true'
with:
distribution: temurin
java-version: '25'
cache: maven
- name: Build main
run: bash documentation/scripts/build-version.sh main "$RUNNER_TEMP/published"
- name: Build website and matching API
if: steps.changes.outputs.changed == 'true'
run: bash documentation/scripts/build-version.sh develop "$RUNNER_TEMP/published"
- name: Verify publication contents
if: steps.changes.outputs.changed == 'true'
run: python documentation/scripts/check-published-site.py "$RUNNER_TEMP/published"
- uses: actions/setup-node@v4
if: steps.changes.outputs.changed == 'true'
with:
node-version: '22'
- name: Verify published site in a browser
if: steps.changes.outputs.changed == 'true'
working-directory: documentation/visual-tests
run: |
npm ci
Expand All @@ -52,11 +55,20 @@ jobs:
trap 'kill "$server_pid"' EXIT
npx playwright test --config publication.config.mjs
- uses: actions/upload-pages-artifact@v4
if: steps.changes.outputs.changed == 'true'
with:
path: ${{ runner.temp }}/published
deploy:
needs: build
if: github.repository == 'simpaths/SimPaths' && github.ref == 'refs/heads/develop' && needs.build.outputs.changed == 'true'
runs-on: ubuntu-latest
# Only actual deployments share a queue. An unrelated push must not cancel
# a pending documentation build just because its own build is skipped.
concurrency:
group: pages
cancel-in-progress: false
Comment thread
dav-sonn marked this conversation as resolved.
Resolved
# Preserve pending deployments even when builds finish out of order.
queue: max
permissions:
contents: read
pages: write
Expand All @@ -65,12 +77,26 @@ jobs:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Reject a superseded build
- uses: actions/checkout@v6
with:
ref: ${{ needs.build.outputs.source }}
fetch-depth: 0
persist-credentials: false
- name: Reject superseded documentation
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
MAIN_SHA: ${{ needs.build.outputs.main }}
SOURCE_SHA: ${{ needs.build.outputs.source }}
run: |
test "$(gh api "repos/$REPOSITORY/commits/main" --jq .sha)" = "$MAIN_SHA"
git fetch origin develop
python3 - <<'PY'
import os
import subprocess
import sys
sys.path.insert(0, '.github/scripts')
from docs_changes import needs_build
tip = subprocess.check_output(['git', 'rev-parse', 'FETCH_HEAD'], text=True).strip()
changed, _ = needs_build('push', {'before': os.environ['SOURCE_SHA'], 'after': tip})
if changed:
sys.exit('Newer documentation/API changes exist; this build will not be deployed.')
PY
- uses: actions/deploy-pages@v4
id: deployment
Loading
Loading