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
3 changes: 3 additions & 0 deletions .github/release-notes.md.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## Release resources

- [Release website](https://mboworks.github.io/bashtest/site/tag/@TAG@/)
22 changes: 21 additions & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,26 @@ name: Test
on: [push]

jobs:
release-site-tests:
name: Release site tests
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: '3.13'
- run: python3 -m unittest discover -s tools -p release_site_test.py
- run: python3 -m unittest discover -s tools -p release_notes_test.py
- name: Verify configured documentation and generated links
env:
GH_TOKEN: ${{ github.token }}
run: |
# This is a disposable build on the runner, never a Pages publication.
python3 tools/release_site.py . "${RUNNER_TEMP}/release-site-check" \
--repository "${GITHUB_REPOSITORY}" --tag 0.0.0-verification --latest ""

pre-commit:
runs-on: ubuntu-latest
steps:
Expand Down Expand Up @@ -37,7 +57,7 @@ jobs:
bazel test //...

done:
needs: [pre-commit, test]
needs: [release-site-tests, pre-commit, test]
if: always()
runs-on: ubuntu-latest
steps:
Expand Down
136 changes: 136 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# SPDX-FileCopyrightText: Copyright (c) M. Boerger, the MBO Works authors
# SPDX-License-Identifier: Apache-2.0
name: Publish release site

on:
# Releases created with GITHUB_TOKEN do not trigger release events.
workflow_run:
workflows: [Release]
types: [completed]
workflow_dispatch:
inputs:
tag:
description: Published release tag (empty selects latest)
type: string
required: false

config_path:
description: Optional config file on main for backfill (empty uses the tag config)
type: string
required: false

permissions: {}

# Share the lock and retained branch with coverage: every deployment includes both.
concurrency:
group: coverage-pages
queue: max
cancel-in-progress: false

jobs:
publish:
if: >-
github.event_name != 'workflow_run' ||
(github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push')
runs-on: ubuntu-latest
permissions:
contents: write
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v7
with:
ref: main
path: source
- name: Resolve published release
id: release
env:
GH_TOKEN: ${{ github.token }}
REQUESTED_TAG: ${{ inputs.tag }}
RUN_TAG: ${{ github.event.workflow_run.head_branch }}
run: |
set -euo pipefail
if gh api "repos/${GITHUB_REPOSITORY}/releases/latest" > latest.json; then
latest="$(jq -r .tag_name latest.json)"
elif jq --exit-status '.status == "404"' latest.json >/dev/null; then
latest="" # A repository may have only prereleases so far.
else
exit 1
fi
tag="${REQUESTED_TAG:-${RUN_TAG:-${latest}}}"
if [[ ! "${tag}" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "Invalid release tag: ${tag}" >&2
exit 1
fi
gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${tag}" > release.json
jq --exit-status '.draft == false' release.json
echo "tag=${tag}" >> "${GITHUB_OUTPUT}"
echo "latest=${latest}" >> "${GITHUB_OUTPUT}"
- uses: actions/checkout@v7
with:
ref: refs/tags/${{ steps.release.outputs.tag }}
path: release
persist-credentials: false
- name: Restore retained Pages tree
working-directory: source
run: |
set -euo pipefail
git fetch origin
if git show-ref --verify --quiet refs/remotes/origin/coverage-pages; then
git worktree add ../site -B coverage-pages origin/coverage-pages
else
git worktree add --detach ../site
git -C ../site checkout --orphan coverage-pages
git -C ../site rm -rf .
fi
- uses: actions/setup-python@v7
with:
python-version: "3.13"
- name: Test site builder
working-directory: source
run: python3 -m unittest discover -s tools -p release_site_test.py
- name: Convert release documentation and update latest redirect
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ steps.release.outputs.tag }}
LATEST_TAG: ${{ steps.release.outputs.latest }}
CONFIG_PATH: ${{ inputs.config_path }}
run: |
set -euo pipefail
config_args=()
if [[ -n "${CONFIG_PATH}" ]]; then
config_file="$(realpath --canonicalize-existing "source/${CONFIG_PATH}")"
if [[ "${config_file}" != "${GITHUB_WORKSPACE}/source/"* ]]; then
echo "Configuration must be a tracked file inside the main checkout" >&2
exit 1
fi
git -C source ls-files --error-unmatch -- "${CONFIG_PATH}" >/dev/null
config_args=(--config "${config_file}")
fi
python3 source/tools/release_site.py release site \
--repository "${GITHUB_REPOSITORY}" --tag "${RELEASE_TAG}" --latest "${LATEST_TAG}" \
"${config_args[@]}"
- name: Retain and stage complete Pages tree
env:
RELEASE_TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
git -C site config user.name 'github-actions[bot]'
git -C site config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git -C site add --all
if ! git -C site diff --cached --quiet; then
git -C site commit -m "site: publish ${RELEASE_TAG}"
git -C site push origin HEAD:coverage-pages
fi
mkdir -p public
rsync --archive --exclude='.git' site/ public/
- uses: actions/configure-pages@v6
- uses: actions/upload-pages-artifact@v5
with:
path: public
- name: Deploy complete site
id: deployment
uses: actions/deploy-pages@v5
3 changes: 3 additions & 0 deletions .github/workflows/release_prep.sh
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,6 @@ cat <<EOF
bazel_dep(name = "${BAZELMOD_NAME}", version = "${VERSION}")
\`\`\`
EOF

printf '\n'
bash tools/release_notes.sh "${TAG}"
5 changes: 4 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ repos:
types: ["yaml"]
files: ^\.github/workflows/
entry: actionlint
args: ["-shellcheck", ""]
# GitHub supports queue: max; actionlint (including v1.7.12) does not yet.
# Keep the exception limited to that documented concurrency key.
# https://docs.github.com/en/actions/concepts/workflows-and-actions/concurrency
args: ['-shellcheck', '', '-ignore', 'unexpected key "queue" for "concurrency" section']

- repo: local
hooks:
Expand Down
86 changes: 86 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# bashtest.sh - A Bazel shell test runner.

[Release website](https://mboworks.github.io/bashtest/)

This shell test library provides Bazel macro rules to simplify shell testing.

The library is tested with continuous integration: [![Test](https://github.com/mboworks/bashtest/actions/workflows/main.yml/badge.svg)](https://github.com/mboworks/bashtest/actions/workflows/main.yml).
Expand Down Expand Up @@ -95,3 +97,87 @@ Check [Releases](https://github.com/mboworks/bashtest/releases) for details. All
```
bazel_dep(name = "mboworks_bashtest", version = "0.6.1")
```

## Release website

Release notes use `.github/release-notes.md.template`, rendered by
`tools/release_notes.sh TAG`, to link to that tag's versioned website and related
release resources. The existing changelog and installation notes remain included.

The [website](https://mboworks.github.io/bashtest/) forwards to the latest published
stable release at `site/tag/<tag>/`, preserving the exact Git tag name.
Each release keeps its converted HTML, images, and configured files. Retrying
publication leaves an existing snapshot unchanged; a different commit cannot
replace it. Older versions remain directly accessible.

[`release-site.json`](release-site.json) defines the layout. Source names are
relative to the repository root; destinations are relative to that release's
site directory. For example:

```json
{
"pages": {
"README.md": "index.html",
"docs/guide.md": "guide/index.html"
},
"files": {
"schema/example.json": "schema/v1.json"
},
"links": [
{
"label": "Release",
"href": "https://github.com/{owner}/{repo}/releases/tag/{tag}"
}
]
}
```

Use existing source files in the actual configuration. `pages` converts Markdown;
optional `files` copies other files unchanged. `README.md` must map to `index.html`.
The generated `documents.html`, `release.json`, `release-site.json`, and `assets/`
paths are reserved. Destination paths cannot have hidden components (names starting
with a dot), because the Pages artifact uploader excludes them. Hidden source
paths remain valid; for example, `.github/workflows/README.md` maps to
`workflows/index.html`.
Navigation links support `{owner}`, `{repo}`, `{tag}`, `{version}`, and `{commit}`.
`{version}` omits a leading `v` for compatibility with coverage report paths.
By default, the configuration and content come from the release tag. Every linked
local Markdown page (including directory README links) must have a `pages` mapping.
Publication fails for an omitted mapping, a missing generated file, or a broken
anchor within the snapshot. Links to configured pages follow their destination
mappings; other local source links use the exact release commit. Embedded images are copied, including remote badges. Markdown
conversion uses the [GitHub Markdown API](https://docs.github.com/en/rest/markdown/markdown)
at publication time; browsing the result requires no Markdown renderer or CDN.

After the Release workflow succeeds, `Publish release site` retains the snapshot
on `coverage-pages` and deploys the complete Pages tree. Coverage and site
publication share a concurrency group to preserve both trees. GitHub's latest
stable release selects the root redirect; backfilling an older release does not
make it latest. The workflow can also be dispatched with a published tag to retry
publication. Enable GitHub Pages with
**GitHub Actions** as its source, and set the repository's About website to
`https://mboworks.github.io/bashtest/`.

### Backfill a historical release

No new release or tag change is needed. Manually dispatch `Publish release site`
with `tag` set to the historical release and `config_path` set to a tracked JSON
file on `main`. Leave `config_path` empty to use a configuration already in the tag.
For example, after selecting a compatible configuration and an existing tag:

```sh
gh workflow run pages.yml --repo mboworks/bashtest --ref main \
-f tag="$RELEASE_TAG" -f config_path=release-site.json
```

The override controls only publication layout; all Markdown and copied files come
from the selected tag. Each new snapshot retains the exact configuration as
`release-site.json`, with its SHA-256, origin, and source commit in `release.json`.
A configuration can serve several historical tags when its sources exist in each.
For another layout, commit another configuration and select its path. Missing
sources or links fail publication instead of using newer content. Retrying a
published tag preserves its original HTML and configuration.

Local regression tests: `python3 -m unittest discover -s tools -p release_site_test.py`.
CI also converts the configured documentation and checks the generated links in
a disposable runner directory. It never commits, retains, or deploys that preview.
11 changes: 11 additions & 0 deletions release-site.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"pages": {
"README.md": "index.html",
"CHANGELOG.md": "CHANGELOG.html",
"CONTRIBUTING.md": "CONTRIBUTING.html",
"bashtest/README.md": "bashtest/README.html",
"CODE_OF_CONDUCT.md": "CODE_OF_CONDUCT.html",
"RULES.md": "RULES.html"
},
"links": []
}
28 changes: 28 additions & 0 deletions tools/release_notes.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#!/usr/bin/env bash

# SPDX-FileCopyrightText: Copyright (c) M. Boerger, the MBO Works authors
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# Render release-note links without building, publishing, or changing repository state.
set -euo pipefail

TAG="${1:?Usage: release_notes.sh TAG}"
if [[ ! "${TAG}" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "Invalid release tag: ${TAG}" >&2
exit 1
fi
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
sed -e "s|@TAG@|${TAG}|g" -e "s|@VERSION@|${TAG#v}|g" \
"${ROOT}/.github/release-notes.md.template"
52 changes: 52 additions & 0 deletions tools/release_notes_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# SPDX-FileCopyrightText: Copyright (c) M. Boerger, the MBO Works authors
# SPDX-License-Identifier: Apache-2.0
"""Exercise the release-note renderer with the actual repository template."""

from pathlib import Path
import subprocess
import tempfile
import unittest

ROOT = Path(__file__).resolve().parents[1]
REPO = "bashtest"


class ReleaseNotesTest(unittest.TestCase):
def render(self, tag):
with tempfile.TemporaryDirectory() as cwd:
return subprocess.run(
["bash", str(ROOT / "tools/release_notes.sh"), tag],
cwd=cwd, text=True, capture_output=True, check=False)

def test_release_resources(self):
for tag in ("1.2.3", "v1.2.3", "v1.2.3-rc.1"):
with self.subTest(tag=tag):
result = self.render(tag)
self.assertEqual(result.returncode, 0, result.stderr)
notes = result.stdout
base = f"https://mboworks.github.io/{REPO}"
self.assertIn(f"{base}/site/tag/{tag}/", notes)
self.assertNotIn("@TAG@", notes)
self.assertNotIn("@VERSION@", notes)
version = tag.removeprefix("v")
if REPO in ("mbo", "xff", "carve"):
self.assertIn(f"{base}/coverage/tag/{version}/", notes)
else:
self.assertNotIn("/coverage/", notes)
if REPO == "xff":
self.assertIn(f"{base}/releases/{version}/)", notes)
self.assertIn(f"{base}/releases/{version}/XFF.md", notes)
if REPO == "coderef":
self.assertIn(f"/blob/{tag}/CHANGELOG.md", notes)
self.assertIn(f"{base}/site/tag/{tag}/schema/v1.json", notes)

def test_reject_invalid_tags_without_partial_notes(self):
for tag in ("", "main", "../1.2.3", "v1.2.3|bad", "1.2.3\nmain"):
with self.subTest(tag=tag):
result = self.render(tag)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(result.stdout, "")


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