diff --git a/.github/workflows/upstream-check.yaml b/.github/workflows/upstream-check.yaml new file mode 100644 index 0000000..ab1852b --- /dev/null +++ b/.github/workflows/upstream-check.yaml @@ -0,0 +1,32 @@ +name: Upstream Version Check + +on: + schedule: + # Run daily at 08:00 UTC. + - cron: '0 8 * * *' + workflow_dispatch: + inputs: + dry_run: + description: 'Preview changes without creating an issue' + type: boolean + default: false + +permissions: + issues: write + +jobs: + check-upstream: + name: Check Upstream Releases + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install yq + uses: mikefarah/yq@v4 + + - name: Run upstream check + run: ci/scripts/upstream-check.sh + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DRY_RUN: ${{ inputs.dry_run || 'false' }} diff --git a/.upstream-monitor.yaml b/.upstream-monitor.yaml new file mode 100644 index 0000000..c1db149 --- /dev/null +++ b/.upstream-monitor.yaml @@ -0,0 +1,31 @@ +# Upstream Version Monitor Configuration +# +# Maps upstream product repositories to Helm chart artifacts. +# The ci/scripts/upstream-check.sh script reads this file to detect +# new upstream releases and open a GitHub issue when an update is available. +# +# Adding a new chart: +# 1. Add a new entry under `charts:` with the chart path. +# 2. List each upstream source with its GitHub repository. +# 3. Define targets — which files and YAML paths hold the current version. +# +# Fields: +# name — human-readable chart name (used in issue titles) +# path — path to the chart directory relative to repo root +# sources[].name — identifier for this upstream component +# sources[].github — GitHub owner/repo (used to query releases) +# sources[].strip_v_prefix — if true, strip leading "v" from the release +# tag before comparing (e.g. "v0.65.3" → "0.65.3") +# sources[].targets[].file — file holding current version, relative to chart path +# sources[].targets[].yaml_path — yq expression to the version field + +charts: + - name: netbird + path: charts/netbird + sources: + - name: server + github: netbirdio/netbird + strip_v_prefix: true + targets: + - file: Chart.yaml + yaml_path: .appVersion diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f94038..80f7218 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). +## Unreleased + +### Added + +- **Automated upstream version tracking**: New scheduled GitHub Actions workflow + (`.github/workflows/upstream-check.yaml`) that runs daily to detect new + releases from upstream repositories and opens a GitHub issue when an update + is available. Currently tracks NetBird server (`netbirdio/netbird`). +- `.upstream-monitor.yaml` configuration file mapping upstream GitHub + repositories to Helm chart version fields. Add new charts or sources by + extending this file. +- `ci/scripts/upstream-check.sh` script that reads the monitor config, queries + the GitHub API for latest releases, compares with current chart versions, + and creates GitHub issues for available updates. Supports `DRY_RUN=true` + for preview mode. +- Workflow supports manual trigger via `workflow_dispatch` with optional + dry-run input. + ## [0.1.1] — 2026-02-26 ### Added diff --git a/README.md b/README.md index 30bc418..8e2143e 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,54 @@ See each chart's README for detailed configuration. - [kubectl](https://kubernetes.io/docs/tasks/tools/) configured for your target cluster - Kubernetes 1.24+ (1.28+ for SQLite PAT seeding) +## Automated Upstream Version Tracking + +A scheduled GitHub Actions workflow checks upstream repositories daily for new +releases and opens a GitHub issue when a chart is behind upstream. + +### How It Works + +1. The workflow reads `.upstream-monitor.yaml` to discover which upstream repos + map to which chart version fields. +2. For each source, it queries the GitHub Releases API for the latest non-draft, + non-prerelease tag. +3. If the upstream version differs from what the chart currently references, a + GitHub issue is created with the current and latest versions, a link to the + upstream release, and a checklist of what needs to be done. + +### Configuration + +Edit `.upstream-monitor.yaml` to add new charts or upstream sources: + +```yaml +charts: + - name: netbird + path: charts/netbird + sources: + - name: server + github: netbirdio/netbird + strip_v_prefix: true + targets: + - file: Chart.yaml + yaml_path: .appVersion +``` + +### Manual Trigger + +Run the check on demand from the Actions tab → **Upstream Version Check** → +**Run workflow**. Enable the `dry_run` checkbox to preview changes without +creating an issue. + +### Local Usage + +```bash +# Preview what would change (no issue created) +DRY_RUN=true ./ci/scripts/upstream-check.sh + +# Run for real (requires gh auth login) +./ci/scripts/upstream-check.sh +``` + ## Contributing We welcome contributions! Please read our [Contributing Guide](CONTRIBUTING.md) before submitting pull requests. diff --git a/ci/scripts/upstream-check.sh b/ci/scripts/upstream-check.sh new file mode 100755 index 0000000..a9c17fe --- /dev/null +++ b/ci/scripts/upstream-check.sh @@ -0,0 +1,206 @@ +#!/usr/bin/env bash +# upstream-check.sh — Detect new upstream releases and open GitHub issues. +# +# Reads .upstream-monitor.yaml, queries the GitHub API for latest releases, +# and creates a GitHub issue when a chart is behind upstream. +# +# Required tools: yq (v4+), gh (GitHub CLI), jq +# +# Environment variables: +# GH_TOKEN — GitHub token (set automatically in GitHub Actions) +# DRY_RUN — "true" to skip issue creation (default: "false") +# +# Usage: +# ./ci/scripts/upstream-check.sh # normal run +# DRY_RUN=true ./ci/scripts/upstream-check.sh # preview only + +set -euo pipefail + +REPO_ROOT="$(git rev-parse --show-toplevel)" +CONFIG_FILE="${REPO_ROOT}/.upstream-monitor.yaml" +DRY_RUN="${DRY_RUN:-false}" + +# ── Helpers ─────────────────────────────────────────────────────────────── + +log() { echo "==> $*"; } +info() { echo " $*"; } +warn() { echo " WARNING: $*" >&2; } +die() { echo "ERROR: $*" >&2; exit 1; } + +check_deps() { + local missing=() + for cmd in yq gh jq; do + command -v "$cmd" &>/dev/null || missing+=("$cmd") + done + if [[ ${#missing[@]} -gt 0 ]]; then + die "missing required tools: ${missing[*]}" + fi +} + +# Fetch the latest non-prerelease, non-draft release tag from a GitHub repo. +get_latest_release_tag() { + local repo="$1" + gh api "repos/${repo}/releases/latest" --jq '.tag_name' 2>/dev/null || echo "" +} + +# Fetch the URL of the latest release page. +get_latest_release_url() { + local repo="$1" + gh api "repos/${repo}/releases/latest" --jq '.html_url' 2>/dev/null || echo "" +} + +# Read a YAML field from a file. +read_yaml() { + local file="$1" path="$2" + yq eval "$path" "$file" +} + +# Check whether an open issue already exists with the given title. +issue_exists_with_title() { + local title="$1" + local count + count=$(gh issue list --state open --search "$title" --json number,title \ + --jq "[.[] | select(.title == \"$title\")] | length" 2>/dev/null || echo "0") + [[ "$count" -gt 0 ]] +} + +# ── Main ────────────────────────────────────────────────────────────────── + +main() { + check_deps + + if [[ ! -f "$CONFIG_FILE" ]]; then + die "config file not found: $CONFIG_FILE" + fi + + local num_charts + num_charts=$(yq eval '.charts | length' "$CONFIG_FILE") + + for ((i = 0; i < num_charts; i++)); do + local chart_name chart_rel_path chart_path + chart_name=$(yq eval ".charts[$i].name" "$CONFIG_FILE") + chart_rel_path=$(yq eval ".charts[$i].path" "$CONFIG_FILE") + chart_path="${REPO_ROOT}/${chart_rel_path}" + + log "Chart: $chart_name ($chart_rel_path)" + + if [[ ! -d "$chart_path" ]]; then + warn "chart directory not found: $chart_path — skipping" + continue + fi + + local num_sources + num_sources=$(yq eval ".charts[$i].sources | length" "$CONFIG_FILE") + + # Collect updates: each entry is "source_name|github_repo|file|yaml_path|old|new" + local updates=() + + for ((j = 0; j < num_sources; j++)); do + local src_name github_repo strip_v + src_name=$(yq eval ".charts[$i].sources[$j].name" "$CONFIG_FILE") + github_repo=$(yq eval ".charts[$i].sources[$j].github" "$CONFIG_FILE") + strip_v=$(yq eval ".charts[$i].sources[$j].strip_v_prefix" "$CONFIG_FILE") + + info "Source: $src_name ($github_repo)" + + local latest_tag + latest_tag=$(get_latest_release_tag "$github_repo") + if [[ -z "$latest_tag" ]]; then + warn "could not fetch latest release for $github_repo — skipping" + continue + fi + + local latest_version="$latest_tag" + if [[ "$strip_v" == "true" ]]; then + latest_version="${latest_tag#v}" + fi + + local num_targets + num_targets=$(yq eval ".charts[$i].sources[$j].targets | length" "$CONFIG_FILE") + + for ((k = 0; k < num_targets; k++)); do + local target_file yaml_path current_version + target_file=$(yq eval ".charts[$i].sources[$j].targets[$k].file" "$CONFIG_FILE") + yaml_path=$(yq eval ".charts[$i].sources[$j].targets[$k].yaml_path" "$CONFIG_FILE") + current_version=$(read_yaml "${chart_path}/${target_file}" "$yaml_path") + + info " ${target_file} (${yaml_path}): current=${current_version} latest=${latest_version}" + + if [[ "$current_version" != "$latest_version" ]]; then + info " UPDATE AVAILABLE: ${current_version} -> ${latest_version}" + updates+=("${src_name}|${github_repo}|${target_file}|${yaml_path}|${current_version}|${latest_version}") + else + info " up to date" + fi + done + done + + # ── No updates needed ────────────────────────────────────────────── + if [[ ${#updates[@]} -eq 0 ]]; then + log "No updates needed for $chart_name" + echo "" + continue + fi + + # ── Build issue title and body ───────────────────────────────────── + local title_parts=() + local issue_body + issue_body="## Upstream Version Update Available"$'\n\n' + issue_body+="The following upstream component(s) for the **${chart_name}** chart have new releases:"$'\n\n' + issue_body+="| Component | Current Version | Latest Version | File | Field |"$'\n' + issue_body+="| --------- | --------------- | -------------- | ---- | ----- |"$'\n' + + for entry in "${updates[@]}"; do + IFS='|' read -r src repo file path old new <<< "$entry" + title_parts+=("${src} ${old} → ${new}") + local release_url + release_url=$(get_latest_release_url "$repo") + if [[ -n "$release_url" ]]; then + issue_body+="| ${src} | \`${old}\` | [\`${new}\`](${release_url}) | \`${file}\` | \`${path}\` |"$'\n' + else + issue_body+="| ${src} | \`${old}\` | \`${new}\` | \`${file}\` | \`${path}\` |"$'\n' + fi + done + + issue_body+=$'\n'"### What needs to be done"$'\n\n' + issue_body+="1. Update the version references listed above."$'\n' + issue_body+="2. Update any hardcoded version strings in test assertions (\`charts/${chart_name}/tests/\`)."$'\n' + issue_body+="3. Bump the chart version in \`Chart.yaml\`."$'\n' + issue_body+="4. Review the upstream release notes for breaking changes."$'\n' + issue_body+="5. Run \`make test\` to verify lint and unit tests pass."$'\n' + issue_body+=$'\n'"---"$'\n' + issue_body+="*This issue was created automatically by the upstream version checker.*"$'\n' + + local joined_parts + joined_parts=$(printf '%s, ' "${title_parts[@]}") + joined_parts="${joined_parts%, }" + local issue_title="chore(${chart_name}): upstream update available — ${joined_parts}" + + # ── Dry run ──────────────────────────────────────────────────────── + if [[ "$DRY_RUN" == "true" ]]; then + log "DRY RUN — would create issue:" + info "Title: $issue_title" + echo "$issue_body" + echo "" + continue + fi + + # ── Check for existing issue ─────────────────────────────────────── + if issue_exists_with_title "$issue_title"; then + log "Issue already exists with title: $issue_title — skipping" + echo "" + continue + fi + + # ── Create issue ─────────────────────────────────────────────────── + gh issue create \ + --title "$issue_title" \ + --body "$issue_body" \ + --label "autorelease" + + log "Issue created for $chart_name" + echo "" + done +} + +main "$@"