Skip to content

Commit 952fe90

Browse files
authored
Merge pull request #10 from Arcadia-Science/kcc/add-build-workflow
Add a build workflow
2 parents b8727a1 + 18516d5 commit 952fe90

File tree

9 files changed

+400
-158
lines changed

9 files changed

+400
-158
lines changed

.github/workflows/build.yml

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
on:
2+
# Run the workflow when new tags are created.
3+
push:
4+
tags:
5+
- v*
6+
# Allow the workflow to be triggered manually.
7+
workflow_dispatch:
8+
9+
name: Build
10+
11+
permissions:
12+
contents: write
13+
pull-requests: write
14+
15+
jobs:
16+
build:
17+
runs-on: ubuntu-latest
18+
env:
19+
PUBLISH_BRANCH: publish
20+
steps:
21+
- uses: actions/checkout@v4
22+
with:
23+
fetch-depth: 0
24+
25+
- name: Install uv
26+
uses: astral-sh/setup-uv@v5
27+
28+
- name: Set up Python
29+
uses: actions/setup-python@v5
30+
with:
31+
python-version: 3.12
32+
33+
- name: Create a name for the build branch
34+
run: |
35+
git checkout main
36+
echo "BUILD_BRANCH=build-$(git rev-parse --short HEAD)" >> $GITHUB_ENV
37+
38+
# Reset the `publish` branch to point to `main`, because we use the `publish` branch
39+
# only to store the build artifacts, not for version control.
40+
- name: Reset the publish branch
41+
run: |
42+
git checkout -b $PUBLISH_BRANCH || true
43+
git checkout $PUBLISH_BRANCH
44+
git reset --hard origin/main
45+
git push --force origin $PUBLISH_BRANCH
46+
git checkout main
47+
48+
# Delete the build branch if it already exists,
49+
# as the `peter-evans/create-pull-request` action used below will update the branch
50+
# if it exists, which we don't want.
51+
# Note: deleting the build branch will also close the corresponding PR, if it exists.
52+
- name: Delete the build branch
53+
run: |
54+
git branch -D $BUILD_BRANCH || true
55+
git push --force --delete origin $BUILD_BRANCH || true
56+
57+
- name: Build the publication
58+
run: |
59+
git checkout main
60+
uv run --no-project _build.py
61+
62+
# Open a PR to merge the build artifacts into the `publish` branch.
63+
# Note: this action automatically creates the build branch and commits all unstaged changes to it.
64+
- name: Open PR
65+
uses: peter-evans/create-pull-request@v7
66+
env:
67+
PR_BODY: |
68+
This PR creates a new version of the public Quarto publication. It was automatically created by the `build.yml` workflow.
69+
70+
To preview this version of the publication, check out this PR branch locally and run `quarto preview`.
71+
72+
To publish this version of the publication, merge this PR into the `publish` branch.
73+
with:
74+
# `base` is the target branch for the PR.
75+
base: ${{ env.PUBLISH_BRANCH }}
76+
# `branch` is the source branch for the PR.
77+
branch: ${{ env.BUILD_BRANCH }}
78+
commit-message: "Update the public Quarto publication"
79+
title: "[Publication] Update the public Quarto publication"
80+
body: ${{ env.PR_BODY }}

.github/workflows/publish.yml

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
# This workflow is taken from
2-
# https://quarto.org/docs/publishing/github-pages.html#github-action, which
3-
# contains other useful information.
1+
# This workflow is derived from an example workflow in the Quarto docs here:
2+
# https://quarto.org/docs/publishing/github-pages.html#github-action.
3+
44
on:
55
workflow_dispatch:
66
push:
@@ -9,13 +9,35 @@ on:
99
name: Quarto Publish
1010

1111
jobs:
12-
build-deploy:
12+
publish:
1313
runs-on: ubuntu-latest
1414
permissions:
1515
contents: write
1616
steps:
1717
- name: Check out repository
1818
uses: actions/checkout@v4
19+
with:
20+
# Ensure that the `publish` branch is checked out.
21+
ref: publish
22+
23+
# This is needed to create the `gh-pages` branch.
24+
- name: Set up Git user
25+
run: |
26+
git config --global user.name "github-actions[bot]"
27+
git config --global user.email "github-actions[bot]@users.noreply.github.com"
28+
29+
# The `quarto-actions/publish` action does not create the `gh-pages` branch,
30+
# so we need to create it manually here (if it does not exist).
31+
- name: Create the `gh-pages` branch if it does not exist
32+
run: |
33+
git fetch origin
34+
if ! git show-ref --verify --quiet refs/remotes/origin/gh-pages; then
35+
git checkout --orphan gh-pages
36+
git rm -rf --quiet .
37+
git commit --allow-empty -m "Initializing gh-pages branch"
38+
git push origin gh-pages
39+
git checkout publish
40+
fi
1941
2042
- name: Set up Quarto
2143
uses: quarto-dev/quarto-actions/setup@v2

Makefile

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,3 @@ execute:
2929
.PHONY: preview
3030
preview:
3131
quarto preview
32-
33-
.PHONY: bump-version
34-
bump-version:
35-
python _bump_version.py

_build.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
# /// script
2+
# requires-python = ">=3.12"
3+
# dependencies = [
4+
# "pyyaml>=6.0.2",
5+
# ]
6+
# ///
7+
8+
"""
9+
This script gathers all of the files needed to build the publication.
10+
These files do not all exist in any single commit in the repository,
11+
because the publication needs to include versions of the `index.ipynb` notebook
12+
for each git tag.
13+
14+
This script executes the following steps:
15+
- Copies the `index.ipynb` notebook from each tag to a `_freeze/index_v{tag}.ipynb` file.
16+
- Copies the `_freeze/index` directory from each tag to a `_freeze/index_v{tag}` directory.
17+
- Updates the `_quarto.yml` file to include a `version-control` item for each tag.
18+
"""
19+
20+
import argparse
21+
import shutil
22+
import subprocess
23+
from contextlib import contextmanager
24+
from pathlib import Path
25+
26+
import yaml
27+
28+
29+
def get_versioned_notebook_path(tag: str) -> Path:
30+
"""Get the path to the versioned `index.ipynb` notebook for a given tag."""
31+
return Path(f"index_{tag}.ipynb")
32+
33+
34+
def get_versioned_freeze_directory_path(tag: str) -> Path:
35+
"""Get the path to the versioned `_freeze/index` directory for a given tag."""
36+
return Path(f"_freeze/index_{tag}")
37+
38+
39+
@contextmanager
40+
def git_checkout(ref: str):
41+
original_ref = (
42+
subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"])
43+
.decode("utf-8")
44+
.strip()
45+
)
46+
47+
try:
48+
subprocess.run(["git", "checkout", ref], check=True)
49+
yield
50+
finally:
51+
subprocess.run(["git", "checkout", original_ref], check=True)
52+
53+
54+
def get_tags() -> list[str]:
55+
"""Get a list of all git tags."""
56+
result = subprocess.run(["git", "tag"], capture_output=True, text=True, check=True)
57+
return result.stdout.splitlines()
58+
59+
60+
def copy_notebook(tag: str, dry_run: bool) -> None:
61+
"""Copy the `index.ipynb` notebook for a given tag to a versioned notebook."""
62+
src = Path("index.ipynb")
63+
dst = get_versioned_notebook_path(tag)
64+
65+
if dry_run:
66+
print(f"Would copy '{src}' to '{dst}'")
67+
return
68+
69+
shutil.copy2(src, dst)
70+
71+
72+
def copy_freeze_directory(tag: str, dry_run: bool) -> None:
73+
"""Copy the `_freeze/index` directory for a given tag to a versioned directory."""
74+
src = Path("_freeze/index")
75+
dst = get_versioned_freeze_directory_path(tag)
76+
77+
if dry_run:
78+
print(f"Would copy '{src}' to '{dst}'")
79+
return
80+
81+
shutil.copytree(src, dst, dirs_exist_ok=True)
82+
83+
84+
def update_index_notebook_and_freeze_directory(tag: str, dry_run: bool) -> None:
85+
"""
86+
Rename the most recent tagged version of the notebook and its freeze directory
87+
to `index.ipynb` and `_freeze/index`, respectively.
88+
"""
89+
notebook_src = get_versioned_notebook_path(tag)
90+
notebook_dst = Path("index.ipynb")
91+
92+
freeze_src = get_versioned_freeze_directory_path(tag)
93+
freeze_dst = Path("_freeze/index")
94+
95+
if dry_run:
96+
print(f"Would move '{notebook_src}' to '{notebook_dst}'")
97+
print(f"Would move '{freeze_src}' to '{freeze_dst}'")
98+
return
99+
100+
notebook_dst.unlink()
101+
notebook_src.replace(notebook_dst)
102+
103+
shutil.rmtree(freeze_dst, ignore_errors=True)
104+
freeze_src.replace(freeze_dst)
105+
106+
107+
def update_quarto_yaml(most_recent_tag: str, previous_tags: list[str], dry_run: bool) -> None:
108+
"""Update the _quarto.yml file to include menu items for each tagged release."""
109+
yaml_path = Path("_quarto.yml")
110+
content = yaml.safe_load(yaml_path.read_text())
111+
112+
most_recent_version_item = {
113+
"text": f"{most_recent_tag} (latest)",
114+
# The link for the most recent version is always `index.ipynb`.
115+
# (This version of the notebook is renamed to `index.ipynb` elsewhere in this script.)
116+
"href": "index.ipynb",
117+
}
118+
previous_version_items = [
119+
{"text": tag, "href": str(get_versioned_notebook_path(tag))} for tag in previous_tags
120+
]
121+
122+
if dry_run:
123+
print(f"Would update '{yaml_path}' with the following menu items:")
124+
print(f" - {most_recent_version_item}")
125+
for item in previous_version_items:
126+
print(f" - {item}")
127+
return
128+
129+
# Insert the new items.
130+
for item in content["website"]["navbar"]["left"]:
131+
if "version-control" in item.get("text", ""):
132+
item["menu"] = [most_recent_version_item] + previous_version_items
133+
134+
yaml_path.write_text(yaml.dump(content, sort_keys=False, allow_unicode=True))
135+
136+
137+
def main() -> None:
138+
"""
139+
Entrypoint for the script.
140+
141+
CLI args:
142+
--dry-run: Print the actions that would be taken without actually taking them.
143+
This is intended to be used when running this script locally during development,
144+
as a way to preview the changes the script would make to the working directory.
145+
"""
146+
parser = argparse.ArgumentParser()
147+
parser.add_argument("--dry-run", action="store_true")
148+
args = parser.parse_args()
149+
150+
tags = get_tags()
151+
if not tags:
152+
raise ValueError("No tags found")
153+
154+
for tag in tags:
155+
print(f"Processing tag {tag}")
156+
with git_checkout(tag):
157+
copy_notebook(tag, dry_run=args.dry_run)
158+
copy_freeze_directory(tag, dry_run=args.dry_run)
159+
160+
tags = sorted(tags, reverse=True)
161+
most_recent_tag, *previous_tags = tags
162+
163+
update_index_notebook_and_freeze_directory(most_recent_tag, dry_run=args.dry_run)
164+
update_quarto_yaml(most_recent_tag, previous_tags, dry_run=args.dry_run)
165+
166+
167+
if __name__ == "__main__":
168+
main()

0 commit comments

Comments
 (0)