diff --git a/.github/scripts/release_candidate.py b/.github/scripts/release_candidate.py new file mode 100644 index 0000000..cff6264 --- /dev/null +++ b/.github/scripts/release_candidate.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Classify an exact Boatstack source for a stable patch release.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path + + +STABLE_TAG = re.compile(r"^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") + + +class ReleaseBlocked(RuntimeError): + """The selected source cannot produce a stable release.""" + + +def git(repository: Path, *arguments: str) -> str: + result = subprocess.run( + ["git", *arguments], + cwd=repository, + text=True, + capture_output=True, + ) + if result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() + raise ReleaseBlocked(f"git {' '.join(arguments)} failed: {detail}") + return result.stdout.strip() + + +def stable_tags(repository: Path, source: str) -> list[tuple[tuple[int, int, int], str]]: + tags: list[tuple[tuple[int, int, int], str]] = [] + for tag in git(repository, "tag", "--merged", source, "--list", "v*").splitlines(): + match = STABLE_TAG.fullmatch(tag) + if match is not None: + tags.append((tuple(map(int, match.groups())), tag)) + return sorted(tags, reverse=True) + + +def classify(repository: Path, source: str) -> dict[str, str]: + resolved_source = git(repository, "rev-parse", "--verify", f"{source}^{{commit}}") + if resolved_source != source: + raise ReleaseBlocked(f"release source must be an exact commit SHA: {source}") + head = git(repository, "rev-parse", "HEAD") + if head != source: + raise ReleaseBlocked(f"checked-out source {head} does not match release source {source}") + + tags = stable_tags(repository, source) + if not tags: + raise ReleaseBlocked("stable patch releases require an existing vMAJOR.MINOR.PATCH tag") + version, latest_tag = tags[0] + + rewritten = git( + repository, + "diff", + "--name-only", + "--diff-filter=MD", + "--no-renames", + latest_tag, + source, + "--", + "release-notes/*.md", + ) + if rewritten: + raise ReleaseBlocked(f"Boatstack release notes are append-only: {rewritten.replace(chr(10), ', ')}") + + added = git( + repository, + "diff", + "--name-only", + "--diff-filter=A", + "--no-renames", + latest_tag, + source, + "--", + "release-notes/*.md", + ) + next_tag = f"v{version[0]}.{version[1]}.{version[2] + 1}" + if added and git(repository, "tag", "--list", next_tag): + raise ReleaseBlocked(f"next stable tag already exists: {next_tag}") + + return { + "release_required": "true" if added else "false", + "latest_tag": latest_tag, + "next_tag": next_tag, + "release_source": source, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--repo", type=Path, default=Path.cwd()) + parser.add_argument("--source", required=True) + parser.add_argument("--github-output", type=Path) + arguments = parser.parse_args() + + try: + result = classify(arguments.repo.resolve(), arguments.source) + except ReleaseBlocked as error: + print(f"BLOCKED: {error}", file=sys.stderr) + return 2 + + if arguments.github_output is not None: + with arguments.github_output.open("a", encoding="utf-8") as output: + for key, value in result.items(): + output.write(f"{key}={value}\n") + print(json.dumps(result, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/tests/test_release_candidate.py b/.github/tests/test_release_candidate.py new file mode 100644 index 0000000..8e1838e --- /dev/null +++ b/.github/tests/test_release_candidate.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import json +import subprocess +import tempfile +import unittest +from pathlib import Path + + +REPO = Path(__file__).resolve().parents[2] +SCRIPT = REPO / ".github" / "scripts" / "release_candidate.py" + + +class ReleaseCandidateTest(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.repository = Path(self.temporary.name) + self.git("init", "--initial-branch=main") + self.git("config", "user.email", "release-test@example.invalid") + self.git("config", "user.name", "Release Test") + self.write("release-notes/base.md", "### Base release\n") + self.commit("Create base release") + self.git("tag", "v1.2.3") + + def tearDown(self) -> None: + self.temporary.cleanup() + + def git(self, *arguments: str) -> str: + result = subprocess.run( + ["git", *arguments], + cwd=self.repository, + text=True, + capture_output=True, + check=True, + ) + return result.stdout.strip() + + def write(self, name: str, content: str) -> None: + path = self.repository / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + + def commit(self, message: str) -> str: + self.git("add", ".") + self.git("commit", "-m", message) + return self.git("rev-parse", "HEAD") + + def classify(self, source: str | None = None) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + "python3", + str(SCRIPT), + "--repo", + str(self.repository), + "--source", + source or self.git("rev-parse", "HEAD"), + ], + text=True, + capture_output=True, + ) + + def classify_with_output(self, output: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + "python3", + str(SCRIPT), + "--repo", + str(self.repository), + "--source", + self.git("rev-parse", "HEAD"), + "--github-output", + str(output), + ], + text=True, + capture_output=True, + ) + + def test_no_unreleased_note_is_a_no_op(self) -> None: + result = self.classify() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout)["release_required"], "false") + + def test_added_note_selects_next_patch_tag(self) -> None: + self.write("release-notes/change.md", "### Changed behavior\n") + source = self.commit("Add release-bearing change") + result = self.classify(source) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + json.loads(result.stdout), + { + "latest_tag": "v1.2.3", + "next_tag": "v1.2.4", + "release_required": "true", + "release_source": source, + }, + ) + + def test_github_output_matches_json_result(self) -> None: + self.write("release-notes/change.md", "### Changed behavior\n") + self.commit("Add release-bearing change") + output = self.repository / "github-output" + result = self.classify_with_output(output) + self.assertEqual(result.returncode, 0, result.stderr) + values = dict(line.split("=", 1) for line in output.read_text().splitlines()) + self.assertEqual(values, json.loads(result.stdout)) + + def test_modified_release_note_is_blocked(self) -> None: + self.write("release-notes/base.md", "### Rewritten release\n") + self.commit("Rewrite release note") + result = self.classify() + self.assertEqual(result.returncode, 2) + self.assertIn("release notes are append-only", result.stderr) + + def test_deleted_release_note_is_blocked(self) -> None: + (self.repository / "release-notes/base.md").unlink() + self.git("add", "-u") + self.git("commit", "-m", "Delete release note") + result = self.classify() + self.assertEqual(result.returncode, 2) + self.assertIn("release notes are append-only", result.stderr) + + def test_source_must_match_checked_out_head(self) -> None: + tagged_source = self.git("rev-parse", "HEAD") + self.write("release-notes/change.md", "### Changed behavior\n") + self.commit("Add release-bearing change") + result = self.classify(tagged_source) + self.assertEqual(result.returncode, 2) + self.assertIn("does not match release source", result.stderr) + + def test_prerelease_and_malformed_tags_do_not_replace_latest_stable(self) -> None: + self.git("tag", "v9.0.0-rc.1") + self.git("tag", "version-nine") + self.write("release-notes/change.md", "### Changed behavior\n") + self.commit("Add release-bearing change") + result = self.classify() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout)["latest_tag"], "v1.2.3") + + def test_existing_candidate_tag_on_unrelated_history_is_blocked(self) -> None: + unrelated = self.git("commit-tree", "HEAD^{tree}", "-m", "Unrelated release") + self.git("tag", "v1.2.4", unrelated) + self.write("release-notes/change.md", "### Changed behavior\n") + self.commit("Add release-bearing change") + result = self.classify() + self.assertEqual(result.returncode, 2) + self.assertIn("next stable tag already exists", result.stderr) + + def test_repository_without_stable_tag_is_blocked(self) -> None: + self.git("tag", "-d", "v1.2.3") + self.git("tag", "v1.2.3-rc.1") + result = self.classify() + self.assertEqual(result.returncode, 2) + self.assertIn("existing vMAJOR.MINOR.PATCH tag", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/tests/test_repository_contract.py b/.github/tests/test_repository_contract.py index a76ba7a..30026b3 100644 --- a/.github/tests/test_repository_contract.py +++ b/.github/tests/test_repository_contract.py @@ -271,7 +271,20 @@ def test_release_builds_six_checksum_bound_v2_runtimes(self) -> None: ci_name = re.search(r"(?m)^name:\s*(.+?)\s*$", ci) self.assertIsNotNone(ci_name) self.assertEqual(ci_name.group(1), "CI") - self.assertIn(f'workflows: ["{ci_name.group(1)}"]', automatic) + self.assertNotIn("workflow_run:", automatic) + self.assertIn('cron: "0 2 * * *"', automatic) + self.assertIn("workflow_dispatch:", automatic) + self.assertIn("actions: read", automatic) + self.assertIn('--workflow .github/workflows/ci.yml', automatic) + self.assertIn('--event push', automatic) + self.assertIn('--commit "$RELEASE_SOURCE"', automatic) + self.assertIn('git ls-remote origin refs/heads/main', automatic) + self.assertGreaterEqual(automatic.count('git ls-remote origin refs/heads/main'), 2) + self.assertIn("git fetch --force --tags origin", automatic) + self.assertIn('git ls-remote --exit-code --tags origin "refs/tags/$next_tag"', automatic) + self.assertIn("release_candidate.py", automatic) + self.assertIn("cancel-in-progress: false", automatic) + self.assertIn("no verified unreleased changes; no release was created", automatic) def test_manual_release_is_prerelease_only_and_exact_source_bound(self) -> None: # control-law: branch-prerelease-publishes-only-an-exact-new-rc-source diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 5d79420..a8ce11d 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -2,11 +2,12 @@ name: Publish verified Boatstack release on: - workflow_run: - workflows: ["CI"] - types: [completed] + schedule: + - cron: "0 2 * * *" + workflow_dispatch: permissions: + actions: read contents: read concurrency: @@ -15,13 +16,63 @@ concurrency: jobs: release: - if: >- - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' && - github.event.workflow_run.head_branch == 'main' runs-on: ubuntu-latest steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + - name: Verify exact current main and CI + id: source + env: + GH_TOKEN: ${{ github.token }} + RELEASE_SOURCE: ${{ github.sha }} + shell: bash + run: | + [[ "$GITHUB_REF" == "refs/heads/main" ]] || { + echo "BLOCKED: stable releases must run from main, not $GITHUB_REF." >&2 + exit 2 + } + checked_out="$(git rev-parse HEAD)" + [[ "$checked_out" == "$RELEASE_SOURCE" ]] || { + echo "BLOCKED: checked-out source $checked_out does not match $RELEASE_SOURCE." >&2 + exit 2 + } + remote_main="$(git ls-remote origin refs/heads/main | awk 'NR == 1 {print $1}')" + [[ -n "$remote_main" && "$remote_main" == "$RELEASE_SOURCE" ]] || { + echo "BLOCKED: main moved from $RELEASE_SOURCE to ${remote_main:-unknown}." >&2 + exit 2 + } + verified_sha="$(gh run list \ + --repo "$GITHUB_REPOSITORY" \ + --workflow .github/workflows/ci.yml \ + --branch main \ + --event push \ + --commit "$RELEASE_SOURCE" \ + --status success \ + --limit 1 \ + --json headSha \ + --jq '.[0].headSha // ""')" + [[ "$verified_sha" == "$RELEASE_SOURCE" ]] || { + echo "BLOCKED: exact source $RELEASE_SOURCE has no successful main push CI run." >&2 + exit 2 + } + echo "sha=$RELEASE_SOURCE" >> "$GITHUB_OUTPUT" + - name: Detect pending release-bearing changes + id: classify + env: + RELEASE_SOURCE: ${{ steps.source.outputs.sha }} + run: >- + python3 .github/scripts/release_candidate.py + --repo . + --source "$RELEASE_SOURCE" + --github-output "$GITHUB_OUTPUT" + - name: Report current release state + if: steps.classify.outputs.release_required != 'true' + run: echo "Boatstack has no verified unreleased changes; no release was created." - name: Create repository automation token + if: steps.classify.outputs.release_required == 'true' id: app-token uses: actions/create-github-app-token@v3 with: @@ -31,55 +82,49 @@ jobs: repositories: boatstack permission-contents: write - uses: actions/checkout@v7 + if: steps.classify.outputs.release_required == 'true' with: - ref: main + ref: ${{ steps.source.outputs.sha }} fetch-depth: 0 token: ${{ steps.app-token.outputs.token }} - - name: Detect release-bearing change - id: classify - shell: bash - run: | - latest_tag="$(git describe --tags --abbrev=0 --match 'v[0-9]*' 2>/dev/null || true)" - if [[ -z "$latest_tag" ]]; then - echo "BLOCKED: automatic patch releases require an existing stable tag." >&2 - exit 1 - fi - rewritten="$(git diff --name-only --diff-filter=MD --no-renames "$latest_tag" HEAD -- 'release-notes/*.md')" - if [[ -n "$rewritten" ]]; then - echo "BLOCKED: release notes are append-only:" >&2 - printf ' %s\n' "$rewritten" >&2 - exit 1 - fi - added="$(git diff --name-only --diff-filter=A --no-renames "$latest_tag" HEAD -- 'release-notes/*.md')" - if [[ -n "$added" ]]; then - echo "release_required=true" >> "$GITHUB_OUTPUT" - else - echo "release_required=false" >> "$GITHUB_OUTPUT" - fi - echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT" - name: Create next verified patch tag if: steps.classify.outputs.release_required == 'true' env: APP_SLUG: ${{ steps.app-token.outputs.app-slug }} - LATEST_TAG: ${{ steps.classify.outputs.latest_tag }} + EXPECTED_LATEST_TAG: ${{ steps.classify.outputs.latest_tag }} + EXPECTED_NEXT_TAG: ${{ steps.classify.outputs.next_tag }} + RELEASE_SOURCE: ${{ steps.source.outputs.sha }} shell: bash run: | - version="${LATEST_TAG#v}" - IFS=. read -r major minor patch <<< "$version" - if [[ ! "$major" =~ ^[0-9]+$ || ! "$minor" =~ ^[0-9]+$ || ! "$patch" =~ ^[0-9]+$ ]]; then - echo "BLOCKED: latest tag is not a stable semantic version: $LATEST_TAG" >&2 - exit 1 - fi - next_tag="v${major}.${minor}.$((patch + 1))" - if git rev-parse --verify --quiet "refs/tags/$next_tag"; then + checked_out="$(git rev-parse HEAD)" + [[ "$checked_out" == "$RELEASE_SOURCE" ]] || { + echo "BLOCKED: checked-out source changed before publication." >&2 + exit 2 + } + remote_main="$(git ls-remote origin refs/heads/main | awk 'NR == 1 {print $1}')" + [[ -n "$remote_main" && "$remote_main" == "$RELEASE_SOURCE" ]] || { + echo "BLOCKED: main moved before publication; retry against the new head." >&2 + exit 2 + } + git fetch --force --tags origin + candidate="$(python3 .github/scripts/release_candidate.py --repo . --source "$RELEASE_SOURCE")" + release_required="$(jq -r .release_required <<< "$candidate")" + latest_tag="$(jq -r .latest_tag <<< "$candidate")" + next_tag="$(jq -r .next_tag <<< "$candidate")" + [[ "$release_required" == true ]] || { + echo "BLOCKED: no unreleased change remains after refreshing tags." >&2 + exit 2 + } + [[ "$latest_tag" == "$EXPECTED_LATEST_TAG" && "$next_tag" == "$EXPECTED_NEXT_TAG" ]] || { + echo "BLOCKED: stable release tags changed during this run." >&2 + exit 2 + } + if git ls-remote --exit-code --tags origin "refs/tags/$next_tag" >/dev/null 2>&1; then echo "BLOCKED: tag already exists: $next_tag" >&2 - exit 1 + exit 2 fi git config user.name "${APP_SLUG}[bot]" git config user.email "${APP_SLUG}[bot]@users.noreply.github.com" - git tag -a "$next_tag" -m "Boatstack $next_tag" - git push origin "$next_tag" - echo "Published verified release tag $next_tag." - - name: Report documentation-only sync - if: steps.classify.outputs.release_required != 'true' - run: echo "Boatstack content is current; this merge does not require new binaries." + git tag -a "$next_tag" -m "Boatstack $next_tag" "$RELEASE_SOURCE" + git push origin "refs/tags/$next_tag" + echo "Published verified release tag $next_tag from $RELEASE_SOURCE." diff --git a/release-notes/2026-08-15-nightly-releases.md b/release-notes/2026-08-15-nightly-releases.md new file mode 100644 index 0000000..f48e51a --- /dev/null +++ b/release-notes/2026-08-15-nightly-releases.md @@ -0,0 +1,3 @@ +### Batch verified changes into nightly releases + +Boatstack now checks for verified unreleased changes nightly at 02:00 UTC. Maintainers can request the same stable release check manually, while branch-based release candidates remain available separately.