Skip to content

Merge pull request #572 from ChBrain/governance/groups-zip-family #60

Merge pull request #572 from ChBrain/governance/groups-zip-family

Merge pull request #572 from ChBrain/governance/groups-zip-family #60

Workflow file for this run

name: auto-release on main
# Cuts a GitHub Release on every push to main whose effect is to add
# or update a complete culture, then cascades into the delivery
# pipeline: build-zips, build-pdfs, deliver-available-json.
#
# The release is published with GITHUB_TOKEN, so GitHub's anti-recursion
# rule suppresses the `release` event -- a downstream `release: [created]`
# trigger never fires (the v0.12.0 gap: the KAIHACKS manifest PR was
# never opened). So this workflow calls the three downstream workflows
# directly as reusable workflows once the release is cut. They keep their
# own `release: [created]` trigger for human-cut releases.
#
# Bump rules (assistant-controlled per RELEASE.md):
# - NEW culture joins main -> bump minor, reset patch to 0
# - culture updated on main -> bump patch
# - anything else (governance, scripts, docs, ...) -> no-op
#
# "Joined" / "updated" is anchored to scripts/culture_completeness.py
# -- the same is_complete_culture rule build-zips uses to decide what
# ships. A folder that crosses the bar between BEFORE and AFTER is
# new; one already across whose content changed is an update.
on:
push:
branches: [main]
# Serialize. Two pushes landing within seconds must compute their
# bumps in sequence so the second sees the first's tag, instead of
# racing for the same number.
concurrency:
group: auto-release-main
cancel-in-progress: false
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
outputs:
# tag: the version cut this run (empty when nothing was released).
# cut: 'true' only when a release was actually published -- the
# guard the downstream caller jobs gate on.
tag: ${{ steps.version.outputs.tag }}
cut: ${{ steps.bump.outputs.kind != 'none' }}
steps:
- uses: actions/checkout@v4
with:
# Full history + tags: we diff BEFORE..AFTER, read the
# latest semver tag, and run the completeness script at two
# different refs via worktrees.
fetch-depth: 0
fetch-tags: true
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Decide bump kind
id: bump
env:
BEFORE: ${{ github.event.before }}
AFTER: ${{ github.sha }}
run: |
set -euo pipefail
# Initial push or force-push past reachable history: no diff
# to compute, skip silently.
if [ -z "$BEFORE" ] || [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
echo "kind=none" >> "$GITHUB_OUTPUT"
echo "reason=initial push (no previous SHA)"
exit 0
fi
if ! git cat-file -e "$BEFORE" 2>/dev/null; then
echo "kind=none" >> "$GITHUB_OUTPUT"
echo "reason=previous SHA $BEFORE not reachable"
exit 0
fi
# Snapshot the complete-cultures set at BEFORE and AFTER via
# transient worktrees, so the rule that was current at each
# ref is the one applied -- rule tightenings in this push
# don't retroactively reclassify legacy folders.
WT_BEFORE="$RUNNER_TEMP/wt-before"
WT_AFTER="$RUNNER_TEMP/wt-after"
rm -rf "$WT_BEFORE" "$WT_AFTER"
git worktree add --detach "$WT_BEFORE" "$BEFORE" >/dev/null
git worktree add --detach "$WT_AFTER" "$AFTER" >/dev/null
trap 'git worktree remove --force "$WT_BEFORE" 2>/dev/null || true; git worktree remove --force "$WT_AFTER" 2>/dev/null || true' EXIT
# Use complete_cultures() so sub-national cultures (ADR-0001)
# join the bump diff -- pre-#536 this ran complete_countries()
# (depth-3 only), so a new sub-national appeared as the parent
# country being UPDATED instead of being ADDED itself (the SH
# promotion patch-bumped to v0.14.2 instead of minor-bumping
# to v0.15.0). The output emits the FULL relative path from
# regions/ so depth-aware UPDATED detection below can tell a
# sub-national change from a sovereign-state change.
#
# Fallback to complete_countries() when the AFTER ref doesn't
# yet have complete_cultures (this PR's predecessor refs);
# produces depth-3 paths so the comparison still works on
# both sides.
PY='
import sys
sys.path.insert(0, "scripts")
try:
from culture_completeness import complete_cultures, _REGIONS
rows = complete_cultures()
except Exception:
try:
from culture_completeness import complete_countries, _REGIONS
rows = complete_countries()
except Exception:
sys.exit(0)
for _region, path in rows:
print(path.relative_to(_REGIONS).as_posix())
'
PREV=$(cd "$WT_BEFORE" && python3 -c "$PY" | sort)
NEXT=$(cd "$WT_AFTER" && python3 -c "$PY" | sort)
# Set arithmetic. `comm` needs sorted input (we sorted above).
ADDED=$(comm -13 <(printf '%s\n' "$PREV") <(printf '%s\n' "$NEXT") | sed '/^$/d')
KEPT=$(comm -12 <(printf '%s\n' "$PREV") <(printf '%s\n' "$NEXT") | sed '/^$/d')
# Which KEPT cultures had a content change in this push?
# A culture is UPDATED only when a file changes AT ITS OWN
# depth -- NOT when a deeper sub-national folder under it
# changes. For sovereign europe/germany (depth 3 under
# regions/), files at regions/europe/germany/<file> count;
# files at regions/europe/germany/schleswig_holstein/<file>
# do NOT (the sub-national has its own entry and triggers its
# own UPDATED / ADDED row). Pre-#536 the grep was prefix-only
# and Germany was flagged as updated for any SH content
# change, double-counting the SH promotion.
CHANGED=$(git diff --name-only "$BEFORE" "$AFTER" -- 'regions/' || true)
export KEPT CHANGED
UPDATED=$(python3 -c '
import os
kept = [s for s in os.environ.get("KEPT", "").split("\n") if s.strip()]
changed = [s for s in os.environ.get("CHANGED", "").split("\n") if s.strip()]
# A file counts for slug iff its prefix is regions/<slug>/
# AND its slash count equals prefix slash count (file lives
# at the slug own depth, not inside a sub-folder).
for slug in kept:
prefix = f"regions/{slug}/"
own_depth = prefix.count("/")
for c in changed:
if c.startswith(prefix) and c.count("/") == own_depth:
print(slug)
break
')
UPDATED=$(printf '%s' "$UPDATED" | sed '/^$/d')
{
echo "added<<EOF"
echo "$ADDED"
echo "EOF"
echo "updated<<EOF"
echo "$UPDATED"
echo "EOF"
} >> "$GITHUB_OUTPUT"
if [ -n "$ADDED" ]; then
echo "kind=minor" >> "$GITHUB_OUTPUT"
echo "Decision: MINOR (new cultures joined): $(echo "$ADDED" | tr '\n' ' ')"
elif [ -n "$UPDATED" ]; then
echo "kind=patch" >> "$GITHUB_OUTPUT"
echo "Decision: PATCH (updated cultures): $(echo "$UPDATED" | tr '\n' ' ')"
else
echo "kind=none" >> "$GITHUB_OUTPUT"
echo "Decision: NONE (no culture-set change under regions/)"
fi
- name: Compute next version
id: version
if: steps.bump.outputs.kind != 'none'
run: |
set -euo pipefail
# vX.Y.Z only -- exclude prefixed tags like khai-tests-v* or
# khai-cultures-skills-* that may live in the same repo.
LATEST=$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -n1)
if [ -z "$LATEST" ]; then
LATEST="v0.0.0"
fi
MAJOR=$(echo "${LATEST#v}" | cut -d. -f1)
MINOR=$(echo "${LATEST#v}" | cut -d. -f2)
PATCH=$(echo "${LATEST#v}" | cut -d. -f3)
case "${{ steps.bump.outputs.kind }}" in
minor) MINOR=$((MINOR + 1)); PATCH=0 ;;
patch) PATCH=$((PATCH + 1)) ;;
esac
NEXT="v${MAJOR}.${MINOR}.${PATCH}"
echo "tag=$NEXT" >> "$GITHUB_OUTPUT"
echo "latest=$LATEST" >> "$GITHUB_OUTPUT"
echo "Bump $LATEST -> $NEXT"
- name: Compose release notes
id: notes
if: steps.bump.outputs.kind != 'none'
env:
KIND: ${{ steps.bump.outputs.kind }}
ADDED: ${{ steps.bump.outputs.added }}
UPDATED: ${{ steps.bump.outputs.updated }}
BEFORE: ${{ github.event.before }}
AFTER: ${{ github.sha }}
TAG: ${{ steps.version.outputs.tag }}
LATEST: ${{ steps.version.outputs.latest }}
run: |
set -euo pipefail
{
echo "## Cultures $TAG"
echo
if [ "$KIND" = "minor" ]; then
echo "New culture(s) joined main."
echo
echo "### Added"
printf '%s\n' "$ADDED" | sed 's/^/- /'
if [ -n "$UPDATED" ]; then
echo
echo "### Also updated"
printf '%s\n' "$UPDATED" | sed 's/^/- /'
fi
else
echo "Culture content updated."
echo
echo "### Updated"
printf '%s\n' "$UPDATED" | sed 's/^/- /'
fi
echo
echo "### Commits since $LATEST"
git log --pretty='- %s' "$LATEST..$AFTER" -- 'regions/' 2>/dev/null | head -n 50 || true
} > release_notes.md
echo "--- release notes ---"
cat release_notes.md
{
echo "body<<RELEASE_NOTES_EOF"
cat release_notes.md
echo "RELEASE_NOTES_EOF"
} >> "$GITHUB_OUTPUT"
- name: Publish GitHub Release
if: steps.bump.outputs.kind != 'none'
# softprops creates the tag if missing AND publishes a Release
# object. A real Release (not just a tag) is what the zip and PDF
# assets attach to and what `releases/latest/download/` resolves.
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.tag }}
name: ${{ steps.version.outputs.tag }}
body: ${{ steps.notes.outputs.body }}
target_commitish: ${{ github.sha }}
make_latest: 'true'
# The release above is published with GITHUB_TOKEN, so GitHub suppresses
# the `release` event -- a downstream `release: [created]` trigger never
# fires. Call the three delivery workflows directly as reusable
# workflows instead. Each guards on `cut`, so a push that produced no
# release (governance, docs, ...) does not run them.
deliver-available-json:
needs: release
if: needs.release.outputs.cut == 'true'
uses: ./.github/workflows/deliver-available-json.yml
with:
release: ${{ needs.release.outputs.tag }}
secrets: inherit
build-zips:
needs: release
if: needs.release.outputs.cut == 'true'
uses: ./.github/workflows/build-zips.yml
with:
release: ${{ needs.release.outputs.tag }}
build-pdfs:
needs: release
if: needs.release.outputs.cut == 'true'
uses: ./.github/workflows/build-pdfs.yml
with:
release: ${{ needs.release.outputs.tag }}
# Open a PR on ChBrain/KAIHACKS to bump the version badge on the
# Cultures map page whenever a Cultures release is cut. The badge
# links directly to the specific release tag so readers land on the
# exact release, not just the latest.
# Uses the existing secrets.KAIHACKS PAT (same one used by other workflows).
update-website-badge:
needs: release
if: needs.release.outputs.cut == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
repository: ChBrain/KAIHACKS
token: ${{ secrets.KAIHACKS }}
ref: main
- name: Bump version badge
env:
TAG: ${{ needs.release.outputs.tag }}
# FILE lives in env so the Python heredoc below can read it
# via os.environ. Pre-fix this was a plain shell assignment
# (FILE="..."), not exported, so the python3 subprocess saw
# no FILE and raised KeyError -- the bug that broke the
# v0.15.0 cascade.
FILE: kaihacks.ai/cultures/public_html/map/index.html
run: |
set -euo pipefail
python3 - <<'PY'
import os, re
tag = os.environ["TAG"]
path = os.environ["FILE"]
text = open(path).read()
text = re.sub(
r'href="https://github\.com/ChBrain/Cultures/releases/tag/v[\d\.]+"',
f'href="https://github.com/ChBrain/Cultures/releases/tag/{tag}"',
text,
)
text = re.sub(
r'>v[\d\.]+ &nearr;<',
f'>{tag} &nearr;<',
text,
)
open(path, "w").write(text)
PY
- name: Open PR
env:
TAG: ${{ needs.release.outputs.tag }}
GH_TOKEN: ${{ secrets.KAIHACKS }}
run: |
set -euo pipefail
BRANCH="web/cultures-version-${TAG}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
git add kaihacks.ai/cultures/public_html/map/index.html
git commit -m "web: bump Cultures version badge to ${TAG}"
git push origin "$BRANCH"
gh pr create \
--repo ChBrain/KAIHACKS \
--title "web: bump Cultures version badge to ${TAG}" \
--body "Automated: Cultures ${TAG} released. Updates the version badge on the map page to link to https://github.com/ChBrain/Cultures/releases/tag/${TAG}." \
--base main \
--head "$BRANCH"