Merge pull request #572 from ChBrain/governance/groups-zip-family #60
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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\.]+ ↗<', | |
| f'>{tag} ↗<', | |
| 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" |