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
158 changes: 158 additions & 0 deletions .github/actions/detect-changes/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
name: Detect CV changes
description: Detect bump type, fetch registry version, compute next version, generate changelog

inputs:
base_sha:
description: Base SHA to diff against
required: true
github_token:
description: GitHub token for registry API calls
required: true
fetch_universe:
description: Whether to fetch universe version from registry
required: false
default: "false"

outputs:
bump_type:
description: "none, patch, minor, or major"
value: ${{ steps.bump.outputs.bump_type }}
warning:
description: Warning message (e.g. for spec changes)
value: ${{ steps.bump.outputs.warning }}
current_version:
description: Current version from registry
value: ${{ steps.registry.outputs.current_version }}
next_version:
description: Computed next version
value: ${{ steps.next.outputs.next_version }}
universe_version:
description: Universe version from registry (only if fetch_universe=true)
value: ${{ steps.registry.outputs.universe_version }}
changelog:
description: Markdown changelog
value: ${{ steps.changelog.outputs.changelog }}

runs:
using: composite
steps:
# ---------------------------------------------------------------
# 1. Detect bump type from diff
# ---------------------------------------------------------------
- name: Detect bump type
id: bump
shell: bash
env:
BASE_SHA: ${{ inputs.base_sha }}
run: |
set -e

echo "Comparing ${BASE_SHA}..HEAD"

CHANGED=$(git diff --name-only --diff-filter=ACDMR "${BASE_SHA}..HEAD" -- \
':(exclude)_src' ':(exclude)_tests' ':(exclude)_archive' ':(exclude)scripts' \
':(exclude)*.md' ':(exclude)*.toml' ':(exclude)*.lock' ':(exclude)*.py' \
':(exclude).github' ':(exclude)LICENSE*' ':(exclude).gitignore' \
':(exclude).pre-commit-config.yaml' ':(exclude)esgvoc_manifest.yaml' || true)

if [ -z "${CHANGED}" ]; then
echo "No CV files changed"
echo "bump_type=none" >> "$GITHUB_OUTPUT"
echo "warning=" >> "$GITHUB_OUTPUT"
exit 0
fi

echo "Changed files:"
echo "${CHANGED}"

BUMP="patch"
WARN=""

# New collections → major
NEW_CONTEXTS=$(git diff --name-only --diff-filter=A "${BASE_SHA}..HEAD" -- '*/000_context.jsonld' || true)
if [ -n "${NEW_CONTEXTS}" ]; then
BUMP="major"
fi

# Spec changes → minor minimum
if [ "${BUMP}" != "major" ]; then
SPEC_CHANGES=$(git diff --name-only --diff-filter=ACDMR "${BASE_SHA}..HEAD" -- '*_specs.yaml' || true)
if [ -n "${SPEC_CHANGES}" ]; then
BUMP="minor"
WARN="Spec files changed — at minimum a minor bump, could warrant major if it breaks downstream tooling."
fi
fi

# Modified collection models → minor
if [ "${BUMP}" = "patch" ]; then
MOD_CONTEXTS=$(git diff --name-only --diff-filter=M "${BASE_SHA}..HEAD" -- '*/000_context.jsonld' || true)
if [ -n "${MOD_CONTEXTS}" ]; then
BUMP="minor"
fi
fi

echo "bump_type=${BUMP}" >> "$GITHUB_OUTPUT"
echo "warning=${WARN}" >> "$GITHUB_OUTPUT"
echo "Detected bump type: ${BUMP}"

# ---------------------------------------------------------------
# 2. Fetch current version from registry
# ---------------------------------------------------------------
- name: Fetch registry version
if: steps.bump.outputs.bump_type != 'none'
id: registry
shell: bash
env:
GH_TOKEN: ${{ inputs.github_token }}
FETCH_UNIVERSE: ${{ inputs.fetch_universe }}
run: |
set -e

CURRENT=$(gh api repos/WCRP-CMIP/esgvoc_registry/contents/obs4ref.json \
--jq '.content' | base64 -d | python3 .github/scripts/ci_version.py fetch-current)

echo "current_version=${CURRENT}" >> "$GITHUB_OUTPUT"
echo "Current registry version: ${CURRENT}"

if [ "${FETCH_UNIVERSE}" = "true" ]; then
UNIVERSE=$(gh api repos/WCRP-CMIP/esgvoc_registry/contents/universe.json \
--jq '.content' | base64 -d | python3 .github/scripts/ci_version.py fetch-universe)
echo "universe_version=${UNIVERSE}" >> "$GITHUB_OUTPUT"
echo "Universe version: ${UNIVERSE}"
fi

# ---------------------------------------------------------------
# 3. Compute next version
# ---------------------------------------------------------------
- name: Compute next version
if: steps.bump.outputs.bump_type != 'none'
id: next
shell: bash
env:
CURRENT: ${{ steps.registry.outputs.current_version }}
BUMP: ${{ steps.bump.outputs.bump_type }}
run: |
NEXT=$(python3 .github/scripts/ci_version.py compute-next --current "${CURRENT}" --bump "${BUMP}")
echo "next_version=${NEXT}" >> "$GITHUB_OUTPUT"
echo "Next version: ${NEXT}"

# ---------------------------------------------------------------
# 4. Generate changelog
# ---------------------------------------------------------------
- name: Generate changelog
if: steps.bump.outputs.bump_type != 'none'
id: changelog
shell: bash
env:
BASE_SHA: ${{ inputs.base_sha }}
run: |
CHANGELOG=$(python3 .github/scripts/ci_changelog.py "${BASE_SHA}")

{
echo "changelog<<CHANGELOG_EOF"
echo "${CHANGELOG}"
echo "CHANGELOG_EOF"
} >> "$GITHUB_OUTPUT"

echo "Changelog:"
echo "${CHANGELOG}"
130 changes: 130 additions & 0 deletions .github/scripts/ci_changelog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"""
CI helper: generate a markdown changelog from git diff.

Usage:
python .github/scripts/ci_changelog.py <base_sha>
"""

import argparse
import collections
import subprocess
import sys

# Paths excluded from CV change detection (infrastructure, docs, etc.)
EXCLUDE_PATTERNS = [
":(exclude)_src",
":(exclude)_tests",
":(exclude)_archive",
":(exclude)scripts",
":(exclude)*.md",
":(exclude)*.toml",
":(exclude)*.lock",
":(exclude)*.py",
":(exclude).github",
":(exclude)LICENSE*",
":(exclude).gitignore",
":(exclude).pre-commit-config.yaml",
":(exclude)esgvoc_manifest.yaml",
]


def get_diff(base_sha):
"""Run git diff and return the name-status output."""
cmd = [
"git", "diff", "--name-status", "--diff-filter=ACDMR",
f"{base_sha}..HEAD", "--",
] + EXCLUDE_PATTERNS

result = subprocess.run(cmd, capture_output=True, text=True, check=True)
return result.stdout.strip()


def parse_diff(diff_output):
"""Parse git diff name-status output into categorized changes."""
changes = collections.defaultdict(lambda: {"A": [], "M": [], "D": []})
model_changes = []
spec_changes = []

for line in diff_output.split("\n"):
if not line.strip():
continue

parts = line.split("\t")

if parts[0].startswith(("R", "C")):
# Renames/copies: "R100\told/path\tnew/path" — treat as add of the new path
if len(parts) < 3:
continue
status = "A"
path = parts[2]
elif len(parts) >= 2:
status = parts[0]
path = parts[1]
else:
continue

segments = path.split("/")
filename = segments[-1]

# Spec files at root level
if len(segments) == 1 and filename.endswith("_specs.yaml"):
action = "new" if status == "A" else "updated"
spec_changes.append((filename, action))
continue

if len(segments) < 2:
continue

collection = segments[0]

if filename == "000_context.jsonld":
action = "new" if status == "A" else "updated"
model_changes.append((collection, action))
else:
term_id = filename.replace(".json", "")
changes[collection][status[0]].append(term_id)

return spec_changes, model_changes, changes


def format_changelog(spec_changes, model_changes, term_changes):
"""Format changes into markdown."""
lines = []

for filename, action in spec_changes:
lines.append(f"- {filename}: {action}")

for collection, action in model_changes:
lines.append(f"- {collection}: {action} collection model")

for collection in sorted(term_changes):
parts = []
counts = term_changes[collection]
if counts["A"]:
parts.append(f'{len(counts["A"])} added')
if counts["M"]:
parts.append(f'{len(counts["M"])} modified')
if counts["D"]:
parts.append(f'{len(counts["D"])} removed')
if parts:
lines.append(f"- {collection}: {', '.join(parts)}")

return "\n".join(lines) if lines else "No CV changes."


def main():
parser = argparse.ArgumentParser(description="Generate changelog from git diff")
parser.add_argument("base_sha", help="Base SHA to diff against")
args = parser.parse_args()

diff_output = get_diff(args.base_sha)
if not diff_output:
print("No CV changes.")
sys.exit(0)

spec_changes, model_changes, term_changes = parse_diff(diff_output)
print(format_changelog(spec_changes, model_changes, term_changes))


if __name__ == "__main__":
main()
63 changes: 63 additions & 0 deletions .github/scripts/ci_update_manifest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""
CI helper: update esgvoc_manifest.yaml with new version info.

Usage:
python .github/scripts/ci_update_manifest.py \
--version 1.2.0 \
--universe-version 1.0.3 \
--changelog-file changelog.md
"""

import argparse
import sys

import yaml


def _literal_str_representer(dumper, data):
"""Force multi-line strings to use YAML literal block scalar (|)."""
if "\n" in data:
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")
return dumper.represent_scalar("tag:yaml.org,2002:str", data)


yaml.add_representer(str, _literal_str_representer)


def main():
parser = argparse.ArgumentParser(description="Update esgvoc manifest")
parser.add_argument("--version", required=True, help="New CV version")
parser.add_argument("--universe-version", required=True, help="Universe version")
parser.add_argument(
"--changelog-file",
default="-",
help="Path to changelog file, or - for stdin (default: -)",
)
parser.add_argument(
"--manifest",
default="esgvoc_manifest.yaml",
help="Path to manifest file (default: esgvoc_manifest.yaml)",
)
args = parser.parse_args()

if args.changelog_file == "-":
changelog = sys.stdin.read().strip()
else:
with open(args.changelog_file) as f:
changelog = f.read().strip()

with open(args.manifest) as f:
manifest = yaml.safe_load(f)

manifest["cv_version"] = args.version
manifest["universe_version"] = args.universe_version
manifest["release_notes"] = changelog + "\n"

with open(args.manifest, "w") as f:
yaml.dump(manifest, f, default_flow_style=False, sort_keys=False, allow_unicode=True)

print(f"Updated manifest to version {args.version}")


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