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
2 changes: 1 addition & 1 deletion .github/scripts/.version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v1.0.4
v1.0.6
23 changes: 19 additions & 4 deletions .github/workflows/auto-release.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# Release hygiene is detection-only here: repo rulesets block GITHUB_TOKEN from
# pushing to main or force-updating protected floating tags. Maintainers advance
# vX/vX.Y and bump .github/scripts/.version manually when the drift guard fails.
name: Auto Release

on:
Expand All @@ -21,21 +24,33 @@ jobs:
id: version
shell: bash
run: |
LATEST=$(git tag --sort=-v:refname --list 'v*' | head -1)
LATEST=$(git tag --sort=-v:refname --list 'v[0-9]*.[0-9]*.[0-9]*' | head -1)
if [ -z "$LATEST" ]; then
echo "tag=v1.0.0" >> "$GITHUB_OUTPUT"
else
MAJOR=$(echo "$LATEST" | sed 's/^v//' | cut -d. -f1)
MINOR=$(echo "$LATEST" | sed 's/^v//' | cut -d. -f2)
PATCH=$(echo "$LATEST" | sed 's/^v//' | cut -d. -f3)
if [[ ! "$LATEST" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
echo "::error::Latest release tag has an unsupported format: $LATEST"
exit 1
fi
MAJOR="${BASH_REMATCH[1]}"
MINOR="${BASH_REMATCH[2]}"
PATCH="${BASH_REMATCH[3]}"
echo "tag=v${MAJOR}.${MINOR}.$((PATCH + 1))" >> "$GITHUB_OUTPUT"
fi
echo "Resolved next version: $(cat "$GITHUB_OUTPUT" | grep tag)"

- name: Create release tag
shell: bash
run: |
tag="${{ steps.version.outputs.tag }}"
git tag "$tag" HEAD
git push origin "refs/tags/${tag}"

- name: Create release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "${{ steps.version.outputs.tag }}" \
--verify-tag \
--title "${{ steps.version.outputs.tag }}" \
--generate-notes
2 changes: 2 additions & 0 deletions .github/workflows/template-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ jobs:
ADR_SCHEMA_BASE_REF: ${{ github.event_name == 'pull_request' && format('origin/{0}', github.base_ref) || '' }}
- name: Validate pin parity
run: python tools/check_pin_parity.py
- name: Validate release drift
run: python tools/check_release_drift.py

repo-hygiene:
name: Org repo hygiene
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
!/reference/**
!/scripts/
!/scripts/**
!/tools/
!/tools/**
!/tests/
!/tests/**

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,8 @@ Supported sync modes:
4. This repo dogfoods the same workflow directly (nightly schedule).
5. `template-ci.yml` runs the checks from `.github/scripts/`, validating the released artifacts.

Floating `vX` / `vX.Y` advancement and `.github/scripts/.version` stamping are manual maintainer tasks: an admin re-points the floating tag and bumps `.version` through a normal PR. The drift guard in `template-ci.yml` fails CI when the latest release, floating tag, or marker drifts, prompting that manual fix. Full automation is blocked until the shelved GitHub App path in [github-token-limitation.md](docs/reference/github-token-limitation.md) exists, because `GITHUB_TOKEN` cannot push to main or force-update protected tags.

Each repo controls its own update cadence — the template publishes releases, consumers pull when ready. No cross-repo credentials, no push permissions, no coupling.

## Git Hygiene Standard
Expand Down
143 changes: 143 additions & 0 deletions tests/test_check_release_drift.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"""Tests for the release drift guard."""

from __future__ import annotations

import subprocess
from pathlib import Path

from tools import check_release_drift as release_drift


def git(repo: Path, *args: str) -> str:
result = subprocess.run( # noqa: S603 - controlled tests invoke local git
["git", *args], # noqa: S607
cwd=repo,
check=True,
capture_output=True,
text=True,
)
return result.stdout.strip()


def reusable_workflow(has_workflow_call: bool) -> str:
trigger = " workflow_call:\n" if has_workflow_call else " workflow_dispatch:\n"
return (
"name: Reusable\n"
"\n"
"on:\n"
f"{trigger}"
"\n"
"jobs:\n"
" noop:\n"
" runs-on: ubuntu-latest\n"
" steps:\n"
" - run: echo ok\n"
)


def write_release_files(repo: Path, tag: str, *, self_update_call: bool = True, python_qa_call: bool = True) -> None:
scripts_dir = repo / ".github" / "scripts"
workflows_dir = repo / ".github" / "workflows"
scripts_dir.mkdir(parents=True, exist_ok=True)
workflows_dir.mkdir(parents=True, exist_ok=True)

(scripts_dir / ".version").write_text(f"{tag}\n", encoding="utf-8")
(workflows_dir / "self-update.yml").write_text(reusable_workflow(self_update_call), encoding="utf-8")
(workflows_dir / "python-qa.yml").write_text(reusable_workflow(python_qa_call), encoding="utf-8")
(repo / "README.md").write_text("# release fixture\n", encoding="utf-8")


def commit_all(repo: Path, message: str) -> None:
git(repo, "add", ".")
git(repo, "commit", "-m", message)


def create_release_repo(
tmp_path: Path,
*,
self_update_call: bool = True,
python_qa_call: bool = True,
) -> Path:
repo = tmp_path / "repo"
repo.mkdir()
git(repo, "init", "-b", "main")
git(repo, "config", "user.name", "Release Test")
git(repo, "config", "user.email", "[email protected]")
git(repo, "config", "commit.gpgsign", "false")
git(repo, "config", "tag.gpgsign", "false")

write_release_files(repo, "v1.0.5")
commit_all(repo, "release v1.0.5")
git(repo, "tag", "v1.0.5")

write_release_files(
repo,
"v1.0.6",
self_update_call=self_update_call,
python_qa_call=python_qa_call,
)
commit_all(repo, "release v1.0.6")
git(repo, "tag", "v1.0.6")
git(repo, "tag", "v1", "v1.0.6")

return repo


def test_release_drift_guard_passes_when_version_and_floating_tag_are_current(tmp_path: Path) -> None:
repo = create_release_repo(tmp_path)
release_drift.ROOT = repo

assert release_drift.collect_errors() == []


def test_release_drift_guard_fails_when_version_marker_drifts(tmp_path: Path) -> None:
repo = create_release_repo(tmp_path)
release_drift.ROOT = repo
(repo / ".github" / "scripts" / ".version").write_text("v1.0.5\n", encoding="utf-8")

errors = release_drift.collect_errors()

assert any(".github/scripts/.version" in error for error in errors)


def test_release_drift_guard_fails_when_v1_points_at_an_old_release(tmp_path: Path) -> None:
repo = create_release_repo(tmp_path)
release_drift.ROOT = repo
git(repo, "tag", "--force", "v1", "v1.0.5")

errors = release_drift.collect_errors()

assert any("floating v1 resolves" in error for error in errors)


def test_release_drift_guard_fails_when_reusable_workflow_call_is_missing(tmp_path: Path) -> None:
repo = create_release_repo(tmp_path, self_update_call=False)
release_drift.ROOT = repo

errors = release_drift.collect_errors()

assert ".github/workflows/self-update.yml@v1 does not declare on: workflow_call" in errors


def test_declares_workflow_call_accepts_quoted_trigger_key() -> None:
workflow_text = 'name: Reusable\n\non:\n "workflow_call":\n\njobs:\n noop:\n runs-on: ubuntu-latest\n'

assert release_drift.declares_workflow_call(workflow_text)


def test_declares_workflow_call_ignores_nested_input_named_workflow_call() -> None:
workflow_text = (
"name: Manual\n"
"\n"
"on:\n"
" workflow_dispatch:\n"
" inputs:\n"
" workflow_call:\n"
" description: Not a reusable workflow trigger\n"
"\n"
"jobs:\n"
" noop:\n"
" runs-on: ubuntu-latest\n"
)

assert not release_drift.declares_workflow_call(workflow_text)
Loading
Loading