From 5630039f09fef958fd51b1b0fb495f3657330777 Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Mon, 15 Jun 2026 11:57:26 -0700 Subject: [PATCH 1/4] Automate SpiceDB Operator updates Add scripts/update-operator.sh and a daily GitHub Action that codify the manual update runbook from CLAUDE.md. The script resolves the current vs target operator version, downloads both release bundles, regenerates templates/configmap-update-graph.yaml, bumps Chart.yaml (minor version bump, appVersion, source URL), regenerates README.md via helm-docs, and verifies with helm lint/template plus a byte-identical cross-check of the rendered update-graph against the bundle. Drift guard: any bundle change outside the update-graph ConfigMap and the templated Deployment image tag (CRD/RBAC/ServiceAccount/Deployment spec) is classified as drift. The safe parts are still applied, but the PR is labeled needs-manual-review with the structural diff embedded, and a check fails so a human finishes the structural changes before merge. The workflow runs daily and on workflow_dispatch, authenticates with the default GITHUB_TOKEN, and opens/updates the PR via peter-evans/create-pull-request. Verified locally: reproducing the v1.24.0 -> v1.25.1 sync produces output byte-identical to the hand-done upgrade, the no-op case (already at target) makes no changes, and the v1.20.1 -> v1.21.0 case correctly flags drift (new policy/poddisruptionbudgets RBAC rule). --- .github/workflows/update-operator.yml | 131 ++++++++++++ CLAUDE.md | 25 ++- scripts/update-operator.sh | 295 ++++++++++++++++++++++++++ 3 files changed, 450 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/update-operator.yml create mode 100755 scripts/update-operator.sh diff --git a/.github/workflows/update-operator.yml b/.github/workflows/update-operator.yml new file mode 100644 index 0000000..1be667b --- /dev/null +++ b/.github/workflows/update-operator.yml @@ -0,0 +1,131 @@ +name: Update SpiceDB Operator + +# Checks daily for a new SpiceDB Operator release and opens a PR that syncs the +# chart (regenerated update-graph ConfigMap, bumped Chart.yaml, regenerated +# README). When the upstream bundle changes anything beyond the update-graph and +# the Deployment image tag (CRD / RBAC / ServiceAccount / Deployment spec), the +# PR is labeled "needs-manual-review" and the structural diff is attached so a +# human can finish the update. +# +# See scripts/update-operator.sh for the codified runbook. + +on: + schedule: + # Daily at 07:00 UTC. + - cron: "0 7 * * *" + workflow_dispatch: + inputs: + target_version: + description: "Operator release tag to sync to (e.g. v1.25.1). Leave blank for latest." + required: false + type: string + +permissions: + contents: write + pull-requests: write + +jobs: + update: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Helm + uses: azure/setup-helm@v4 + + - name: Install helm-docs + run: | + HELM_DOCS_VERSION=1.14.2 + curl -fsSL "https://github.com/norwoodj/helm-docs/releases/download/v${HELM_DOCS_VERSION}/helm-docs_${HELM_DOCS_VERSION}_Linux_x86_64.tar.gz" \ + | sudo tar -xz -C /usr/local/bin helm-docs + helm-docs --version + + - name: Run update script + id: update + env: + # Used by the script to authenticate the GitHub releases API call and + # avoid unauthenticated rate limits. + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + WORKDIR: ${{ runner.temp }}/operator-update + run: | + ./scripts/update-operator.sh "${{ inputs.target_version }}" + + - name: Build PR body + if: steps.update.outputs.updated == 'true' + id: body + env: + CURRENT_VERSION: ${{ steps.update.outputs.current_version }} + TARGET_VERSION: ${{ steps.update.outputs.target_version }} + CHART_VERSION: ${{ steps.update.outputs.chart_version }} + DRIFT: ${{ steps.update.outputs.drift }} + WORKDIR: ${{ runner.temp }}/operator-update + run: | + BODY_FILE="${RUNNER_TEMP}/pr-body.md" + { + echo "Automated sync of the chart to SpiceDB Operator [\`${TARGET_VERSION}\`](https://github.com/authzed/spicedb-operator/releases/tag/${TARGET_VERSION})." + echo + echo "Generated by \`.github/workflows/update-operator.yml\` (\`scripts/update-operator.sh\`)." + echo + echo "## Changes" + echo "- \`Chart.yaml\`: appVersion \`${CURRENT_VERSION}\` → \`${TARGET_VERSION}\`, chart version → \`${CHART_VERSION}\`." + echo "- \`templates/configmap-update-graph.yaml\`: regenerated from the \`${TARGET_VERSION}\` bundle." + echo "- \`README.md\`: regenerated via helm-docs." + echo + echo "## Verification" + echo "- \`helm lint\` and \`helm template\` pass in the workflow." + echo "- The rendered update-graph is verified byte-identical to the upstream bundle." + echo + + if [ "${DRIFT}" = "true" ]; then + echo "## :warning: Manual review required" + echo + echo "The upstream bundle changed resources **beyond** the update-graph ConfigMap and the Deployment image tag (CRD / RBAC / ServiceAccount / Deployment spec). The script applied the safe parts automatically, but a human must review and apply the structural changes below to the relevant templates before merging." + echo + echo "
Structural bundle diff (current → target)" + echo + echo '```diff' + # Cap the embedded diff so the PR body stays within GitHub limits. + head -c 50000 "${WORKDIR}/drift-report.txt" + echo + echo '```' + echo + echo "
" + else + echo "## No structural drift" + echo + echo "Only the update-graph ConfigMap and the templated image tag changed between \`${CURRENT_VERSION}\` and \`${TARGET_VERSION}\`. No CRD / RBAC / ServiceAccount / Deployment-spec changes were needed." + fi + } > "$BODY_FILE" + echo "path=$BODY_FILE" >> "$GITHUB_OUTPUT" + + if [ "${DRIFT}" = "true" ]; then + echo "labels=automated,dependencies,needs-manual-review" >> "$GITHUB_OUTPUT" + else + echo "labels=automated,dependencies" >> "$GITHUB_OUTPUT" + fi + + - name: Create Pull Request + if: steps.update.outputs.updated == 'true' + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ secrets.GITHUB_TOKEN }} + branch: automation/upgrade-spicedb-operator-to-${{ steps.update.outputs.target_version }} + base: main + commit-message: | + Upgrade SpiceDB Operator to ${{ steps.update.outputs.target_version }} + + Automated sync of the chart to the ${{ steps.update.outputs.target_version }} + release bundle. See scripts/update-operator.sh. + title: "Upgrade SpiceDB Operator to ${{ steps.update.outputs.target_version }}" + body-path: ${{ steps.body.outputs.path }} + labels: ${{ steps.body.outputs.labels }} + delete-branch: true + + - name: Fail on drift + # Surface drift as a failed check so it is visible in the PR/run list, + # even though the PR is still opened with the safe parts applied. + if: steps.update.outputs.updated == 'true' && steps.update.outputs.drift == 'true' + run: | + echo "::error::Structural drift detected — the opened PR needs manual review before merge." + exit 1 diff --git a/CLAUDE.md b/CLAUDE.md index f6bacd2..f0fb28b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,30 @@ helm-docs This chart is derived from the `bundle.yaml` files published in the [SpiceDB Operator releases](https://github.com/authzed/spicedb-operator/releases). To update the chart, you diff the old and new bundle.yaml files and apply the changes to the Helm templates. -### Step-by-step process +### Automated updates + +Most updates are handled automatically. The `.github/workflows/update-operator.yml` workflow runs daily (and on-demand via `workflow_dispatch`), checks for a new operator release, and — if the chart is behind — runs `scripts/update-operator.sh` to: + +- regenerate `templates/configmap-update-graph.yaml` from the new bundle, +- bump `Chart.yaml` (`version` minor bump, `appVersion`, source URL), +- regenerate `README.md` via helm-docs, +- verify with `helm lint` / `helm template` (and confirm the rendered update-graph is byte-identical to the bundle), + +then opens a PR via `peter-evans/create-pull-request`. + +The script encodes the manual runbook below for the common case (only the update-graph ConfigMap and the templated Deployment image tag change). **Drift guard:** if the bundle diff touches anything else (CRD / RBAC / ServiceAccount / Deployment spec), the script still applies the safe parts but marks the PR `needs-manual-review`, embeds the structural diff in the PR body, and fails a check. In that case, follow the manual process below to apply the structural changes to the relevant templates before merging. + +You can also run the script locally: + +```shell +# Sync to the latest release +scripts/update-operator.sh + +# Sync to a specific release +scripts/update-operator.sh v1.25.1 +``` + +### Step-by-step process (manual / drift cases) 1. **Determine the current version.** Read the `appVersion` field from `charts/spicedb-operator/Chart.yaml`. This is the SpiceDB Operator release the chart currently targets (e.g., `v1.23.0`). diff --git a/scripts/update-operator.sh b/scripts/update-operator.sh new file mode 100755 index 0000000..1beeaba --- /dev/null +++ b/scripts/update-operator.sh @@ -0,0 +1,295 @@ +#!/usr/bin/env bash +# +# update-operator.sh — sync the Helm chart to a SpiceDB Operator release. +# +# This codifies the manual runbook in CLAUDE.md ("Updating to a New SpiceDB +# Operator Version"). It is deterministic and idempotent: running it twice for +# the same target version produces no further changes. +# +# What it does: +# 1. Resolves the current chart appVersion and the target operator version. +# 2. Downloads both bundle.yaml files (current + target). +# 3. Regenerates templates/configmap-update-graph.yaml from the target bundle. +# 4. Detects "drift" — any change in the bundle diff OUTSIDE the update-graph +# ConfigMap and the Deployment image tag (CRD/RBAC/ServiceAccount/Deployment +# spec). Drift needs a human; the script writes a report and sets an output +# flag rather than guessing. +# 5. Bumps Chart.yaml (version minor bump, appVersion, source URL). +# 6. Regenerates README.md via helm-docs (if available). +# 7. Verifies with helm lint + helm template. +# +# Usage: +# scripts/update-operator.sh [TARGET_VERSION] +# +# TARGET_VERSION Operator release tag (e.g. v1.25.1). If omitted, the latest +# release is resolved from the GitHub API. +# +# Outputs (when $GITHUB_OUTPUT is set, for use in GitHub Actions): +# updated=true|false whether the chart changed +# current_version=vX.Y.Z the chart's appVersion before the run +# target_version=vX.Y.Z the operator version synced to +# chart_version=X.Y.Z the new chart version +# drift=true|false whether structural drift was detected +# +# Artifacts written to $WORKDIR (default: a tmp dir): +# bundle-current.yaml, bundle-target.yaml, drift-report.txt +# +set -euo pipefail + +# --- Configuration ----------------------------------------------------------- + +REPO="authzed/spicedb-operator" +CHART_DIR="charts/spicedb-operator" +CHART_YAML="${CHART_DIR}/Chart.yaml" +CONFIGMAP_TEMPLATE="${CHART_DIR}/templates/configmap-update-graph.yaml" +BUNDLE_URL_TEMPLATE="https://github.com/${REPO}/releases/download/%s/bundle.yaml" + +# Resolve repo root so the script works from any CWD. +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +WORKDIR="${WORKDIR:-$(mktemp -d)}" +mkdir -p "$WORKDIR" + +# --- Helpers ----------------------------------------------------------------- + +log() { printf '%s\n' "$*" >&2; } +die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } + +emit() { + # emit KEY VALUE -> append to GITHUB_OUTPUT if set, else print. + local key="$1" val="$2" + if [ -n "${GITHUB_OUTPUT:-}" ]; then + printf '%s=%s\n' "$key" "$val" >> "$GITHUB_OUTPUT" + fi + log "output: ${key}=${val}" +} + +require() { command -v "$1" >/dev/null 2>&1 || die "required command not found: $1"; } + +# --- Preconditions ----------------------------------------------------------- + +require curl +require helm +[ -f "$CHART_YAML" ] || die "not in chart repo root (missing $CHART_YAML)" +[ -f "$CONFIGMAP_TEMPLATE" ] || die "missing $CONFIGMAP_TEMPLATE" + +# --- Resolve versions -------------------------------------------------------- + +# Current appVersion from Chart.yaml, e.g. appVersion: "v1.24.0" -> v1.24.0 +CURRENT_VERSION="$( + grep -E '^appVersion:' "$CHART_YAML" \ + | sed -E 's/^appVersion:[[:space:]]*"?([^"[:space:]]+)"?.*/\1/' +)" +[ -n "$CURRENT_VERSION" ] || die "could not parse appVersion from $CHART_YAML" + +TARGET_VERSION="${1:-}" +if [ -z "$TARGET_VERSION" ]; then + log "resolving latest release of ${REPO}..." + # GitHub API: latest release tag. Honor GITHUB_TOKEN to avoid rate limits. + AUTH_HEADER=() + [ -n "${GITHUB_TOKEN:-}" ] && AUTH_HEADER=(-H "Authorization: Bearer ${GITHUB_TOKEN}") + TARGET_VERSION="$( + curl -fsSL "${AUTH_HEADER[@]}" \ + "https://api.github.com/repos/${REPO}/releases/latest" \ + | grep -E '"tag_name":' \ + | sed -E 's/.*"tag_name":[[:space:]]*"([^"]+)".*/\1/' + )" + [ -n "$TARGET_VERSION" ] || die "could not resolve latest release tag" +fi + +log "current appVersion: ${CURRENT_VERSION}" +log "target appVersion: ${TARGET_VERSION}" + +emit current_version "$CURRENT_VERSION" +emit target_version "$TARGET_VERSION" + +if [ "$CURRENT_VERSION" = "$TARGET_VERSION" ]; then + log "chart is already at ${TARGET_VERSION}; nothing to do." + emit updated false + emit drift false + emit chart_version "$(grep -E '^version:' "$CHART_YAML" | sed -E 's/^version:[[:space:]]*//')" + exit 0 +fi + +# --- Download bundles -------------------------------------------------------- + +CURRENT_BUNDLE="${WORKDIR}/bundle-current.yaml" +TARGET_BUNDLE="${WORKDIR}/bundle-target.yaml" + +bundle_url() { + # shellcheck disable=SC2059 # the format string is a trusted constant + printf "$BUNDLE_URL_TEMPLATE" "$1" +} + +log "downloading current bundle (${CURRENT_VERSION})..." +curl -fsSL "$(bundle_url "$CURRENT_VERSION")" -o "$CURRENT_BUNDLE" \ + || die "failed to download bundle for ${CURRENT_VERSION}" +log "downloading target bundle (${TARGET_VERSION})..." +curl -fsSL "$(bundle_url "$TARGET_VERSION")" -o "$TARGET_BUNDLE" \ + || die "failed to download bundle for ${TARGET_VERSION}" + +# --- Extract the update-graph ConfigMap data from a bundle ------------------- +# +# The bundle is a multi-doc YAML. Exactly one ConfigMap carries the key +# `update-graph.yaml: |`. We extract the literal block scalar that follows it: +# every line until the indentation drops back to the ConfigMap's field level. +# +# The data lines are indented 4 spaces in the bundle (2 for the data: map key's +# child + 2 for the block content). We capture them verbatim — the chart +# template requires exactly this 4-space indent under `update-graph.yaml: |`. + +extract_update_graph() { + local bundle="$1" + awk ' + # Find the line " update-graph.yaml: |" (2-space indent, ConfigMap data key) + /^ update-graph\.yaml: \|[[:space:]]*$/ { capture = 1; next } + capture { + # Block scalar content is indented deeper than the key (>= 4 spaces), + # or is a blank line. Stop at the first line that is not. + if ($0 ~ /^ / || $0 ~ /^[[:space:]]*$/) { + print + } else { + capture = 0 + } + } + ' "$bundle" +} + +TARGET_GRAPH="${WORKDIR}/target-graph.txt" +extract_update_graph "$TARGET_BUNDLE" > "$TARGET_GRAPH" +[ -s "$TARGET_GRAPH" ] || die "failed to extract update-graph from target bundle" + +# Sanity: the extracted block must start with " channels:" and contain the +# operator imageName line, otherwise our parse is wrong and we must not write. +head -1 "$TARGET_GRAPH" | grep -qE '^ channels:' \ + || die "extracted update-graph does not start with 'channels:' — parse failed" +grep -qE '^ imageName: ' "$TARGET_GRAPH" \ + || die "extracted update-graph missing 'imageName:' — parse failed" + +# --- Regenerate the ConfigMap template --------------------------------------- + +NEW_CONFIGMAP="${WORKDIR}/configmap-update-graph.yaml" +{ + cat <<'HEADER' +apiVersion: v1 +kind: ConfigMap +metadata: + name: update-graph + labels: + {{- include "spicedb-operator.labels" . | nindent 4 }} +data: + update-graph.yaml: | +HEADER + cat "$TARGET_GRAPH" +} > "$NEW_CONFIGMAP" + +cp "$NEW_CONFIGMAP" "$CONFIGMAP_TEMPLATE" +log "regenerated ${CONFIGMAP_TEMPLATE}" + +# --- Drift detection --------------------------------------------------------- +# +# We classify the bundle diff. Anything OUTSIDE the update-graph ConfigMap and +# the Deployment image tag is "drift" that a human must review (CRD/RBAC/SA/ +# Deployment spec changes). We compute this by normalizing both bundles: +# replacing the update-graph block with a placeholder and the operator image +# tag with a placeholder, then diffing what remains. + +normalize_bundle() { + local bundle="$1" + awk ' + /^ update-graph\.yaml: \|[[:space:]]*$/ { print " update-graph.yaml: |__GRAPH_ELIDED__"; capture = 1; next } + capture { + if ($0 ~ /^ / || $0 ~ /^[[:space:]]*$/) { next } + capture = 0 + } + # Elide the operator image tag (templated from appVersion in the chart). + { gsub(/spicedb-operator:v[0-9]+\.[0-9]+\.[0-9]+/, "spicedb-operator:__TAG__") } + { print } + ' "$bundle" +} + +DRIFT_REPORT="${WORKDIR}/drift-report.txt" +normalize_bundle "$CURRENT_BUNDLE" > "${WORKDIR}/norm-current.yaml" +normalize_bundle "$TARGET_BUNDLE" > "${WORKDIR}/norm-target.yaml" + +DRIFT=false +if ! diff -u "${WORKDIR}/norm-current.yaml" "${WORKDIR}/norm-target.yaml" > "$DRIFT_REPORT"; then + DRIFT=true + log "DRIFT DETECTED: bundle changed outside the update-graph ConfigMap." + log " -> see ${DRIFT_REPORT}" + log " -> CRD/RBAC/ServiceAccount/Deployment-spec may need manual changes." +else + log "no structural drift: only the update-graph and image tag changed." + : > "$DRIFT_REPORT" +fi +emit drift "$DRIFT" + +# --- Bump Chart.yaml --------------------------------------------------------- +# +# - version: minor bump (X.Y.Z -> X.(Y+1).0), per CLAUDE.md. +# - appVersion: target version. +# - source URL: point the authzed release-tag line at the target version. + +CHART_VERSION="$(grep -E '^version:' "$CHART_YAML" | sed -E 's/^version:[[:space:]]*//')" +IFS='.' read -r CV_MAJOR CV_MINOR _CV_PATCH <<< "$CHART_VERSION" +[ -n "${CV_MAJOR:-}" ] && [ -n "${CV_MINOR:-}" ] || die "could not parse chart version: $CHART_VERSION" +NEW_CHART_VERSION="${CV_MAJOR}.$((CV_MINOR + 1)).0" + +# Use a tmp file + mv for portable in-place edits (no sed -i portability issues). +tmp_chart="$(mktemp)" +sed -E \ + -e "s|^version:[[:space:]]*.*|version: ${NEW_CHART_VERSION}|" \ + -e "s|^appVersion:[[:space:]]*.*|appVersion: \"${TARGET_VERSION}\"|" \ + -e "s|(https://github.com/${REPO//\//\\/}/releases/tag/)v[0-9]+\.[0-9]+\.[0-9]+|\1${TARGET_VERSION}|" \ + "$CHART_YAML" > "$tmp_chart" +mv "$tmp_chart" "$CHART_YAML" +log "bumped chart version ${CHART_VERSION} -> ${NEW_CHART_VERSION}, appVersion -> ${TARGET_VERSION}" +emit chart_version "$NEW_CHART_VERSION" + +# --- Regenerate README via helm-docs (best effort) --------------------------- + +if command -v helm-docs >/dev/null 2>&1; then + log "running helm-docs..." + helm-docs >/dev/null 2>&1 || log "warning: helm-docs failed; README not regenerated" +elif command -v nix >/dev/null 2>&1; then + log "running helm-docs via nix..." + nix run nixpkgs#helm-docs >/dev/null 2>&1 || log "warning: nix helm-docs failed; README not regenerated" +else + log "warning: helm-docs not available; README not regenerated" +fi + +# --- Verify ------------------------------------------------------------------ + +log "helm lint..." +helm lint "$CHART_DIR" >&2 || die "helm lint failed" +log "helm template..." +helm template ci-verify "$CHART_DIR" >/dev/null || die "helm template failed" + +# Cross-check: the rendered update-graph must be byte-identical to the bundle. +RENDERED="${WORKDIR}/rendered.yaml" +helm template ci-verify "$CHART_DIR" > "$RENDERED" +# Extract the rendered update-graph block (same shape, but the data key is +# under a Source comment and may be release-name-prefixed metadata; the data +# block itself is identical 4-space-indented content). +awk ' + /^ update-graph\.yaml: \|[[:space:]]*$/ { capture = 1; next } + capture { + if ($0 ~ /^ / || $0 ~ /^[[:space:]]*$/) { print } else { capture = 0 } + } +' "$RENDERED" > "${WORKDIR}/rendered-graph.txt" + +# Strip trailing blank lines from both sides: Helm appends a trailing newline +# to the rendered block scalar, and our capture may pick up the blank line that +# precedes the next document. That is a rendering artifact, not a content diff. +strip_trailing_blanks() { sed -e :a -e '/^[[:space:]]*$/{$d;N;ba}' "$1"; } +strip_trailing_blanks "$TARGET_GRAPH" > "${WORKDIR}/target-graph.trim" +strip_trailing_blanks "${WORKDIR}/rendered-graph.txt" > "${WORKDIR}/rendered-graph.trim" + +if ! diff -q "${WORKDIR}/target-graph.trim" "${WORKDIR}/rendered-graph.trim" >/dev/null; then + die "rendered update-graph does not match the bundle — regeneration is wrong" +fi +log "verified: rendered update-graph is byte-identical to the bundle." + +emit updated true +log "done: chart updated to ${TARGET_VERSION} (chart ${NEW_CHART_VERSION})." From cf7b28f81e75df4351011171e74f6665104b062b Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Mon, 15 Jun 2026 12:00:06 -0700 Subject: [PATCH 2/4] Add early-exit version check to update workflow Add a lightweight 'Check for update' step right after checkout that parses the chart's current appVersion and resolves the target version (workflow input or latest operator release), then compares them. When they match, the job short-circuits: Helm/helm-docs installation, bundle downloads, and the full sync script are all skipped via 'if: steps.check.outputs.has_update'. The resolved target is passed explicitly into the script so the heavy step targets the exact version the check decided on (no second API call, no race if a release lands between steps). Downstream PR steps already gate on steps.update.outputs.updated, which is empty when the update step is skipped, so the no-op path ends green with no PR. --- .github/workflows/update-operator.yml | 42 ++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/.github/workflows/update-operator.yml b/.github/workflows/update-operator.yml index 1be667b..2c3ec59 100644 --- a/.github/workflows/update-operator.yml +++ b/.github/workflows/update-operator.yml @@ -31,10 +31,49 @@ jobs: - name: Checkout uses: actions/checkout@v4 + # Cheap early exit: compare the chart's current appVersion against the + # target (the workflow input, or the latest operator release). If they + # already match there is nothing to do, so we skip installing Helm / + # helm-docs, downloading bundles, and running the full sync. + - name: Check for update + id: check + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + current="$( + grep -E '^appVersion:' charts/spicedb-operator/Chart.yaml \ + | sed -E 's/^appVersion:[[:space:]]*"?([^"[:space:]]+)"?.*/\1/' + )" + [ -n "$current" ] || { echo "::error::could not parse appVersion"; exit 1; } + + target="${{ inputs.target_version }}" + if [ -z "$target" ]; then + target="$( + curl -fsSL -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + "https://api.github.com/repos/authzed/spicedb-operator/releases/latest" \ + | grep -E '"tag_name":' \ + | sed -E 's/.*"tag_name":[[:space:]]*"([^"]+)".*/\1/' + )" + [ -n "$target" ] || { echo "::error::could not resolve latest release tag"; exit 1; } + fi + + echo "current_version=$current" >> "$GITHUB_OUTPUT" + echo "target_version=$target" >> "$GITHUB_OUTPUT" + + if [ "$current" = "$target" ]; then + echo "has_update=false" >> "$GITHUB_OUTPUT" + echo "Chart is already at ${target}; nothing to do." + else + echo "has_update=true" >> "$GITHUB_OUTPUT" + echo "Update available: ${current} -> ${target}." + fi + - name: Install Helm + if: steps.check.outputs.has_update == 'true' uses: azure/setup-helm@v4 - name: Install helm-docs + if: steps.check.outputs.has_update == 'true' run: | HELM_DOCS_VERSION=1.14.2 curl -fsSL "https://github.com/norwoodj/helm-docs/releases/download/v${HELM_DOCS_VERSION}/helm-docs_${HELM_DOCS_VERSION}_Linux_x86_64.tar.gz" \ @@ -42,6 +81,7 @@ jobs: helm-docs --version - name: Run update script + if: steps.check.outputs.has_update == 'true' id: update env: # Used by the script to authenticate the GitHub releases API call and @@ -49,7 +89,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} WORKDIR: ${{ runner.temp }}/operator-update run: | - ./scripts/update-operator.sh "${{ inputs.target_version }}" + ./scripts/update-operator.sh "${{ steps.check.outputs.target_version }}" - name: Build PR body if: steps.update.outputs.updated == 'true' From be9c2b0587e01ca3b431523e1dc8dbcb59c87394 Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Mon, 15 Jun 2026 12:01:52 -0700 Subject: [PATCH 3/4] Use preinstalled yq and gh in update check GitHub-hosted runners ship yq and gh preinstalled. Replace the grep/sed appVersion parse with 'yq .appVersion' and the curl+grep/sed releases API call with 'gh release view --json tagName'. gh auto-authenticates from GITHUB_TOKEN in the environment. The standalone scripts/update-operator.sh keeps its curl-based resolution so it has no dependency on gh being installed/authed for local runs. --- .github/workflows/update-operator.yml | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/.github/workflows/update-operator.yml b/.github/workflows/update-operator.yml index 2c3ec59..f04d296 100644 --- a/.github/workflows/update-operator.yml +++ b/.github/workflows/update-operator.yml @@ -40,20 +40,15 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - current="$( - grep -E '^appVersion:' charts/spicedb-operator/Chart.yaml \ - | sed -E 's/^appVersion:[[:space:]]*"?([^"[:space:]]+)"?.*/\1/' - )" - [ -n "$current" ] || { echo "::error::could not parse appVersion"; exit 1; } + current="$(yq '.appVersion' charts/spicedb-operator/Chart.yaml)" + [ -n "$current" ] && [ "$current" != "null" ] \ + || { echo "::error::could not parse appVersion"; exit 1; } target="${{ inputs.target_version }}" if [ -z "$target" ]; then - target="$( - curl -fsSL -H "Authorization: Bearer ${GITHUB_TOKEN}" \ - "https://api.github.com/repos/authzed/spicedb-operator/releases/latest" \ - | grep -E '"tag_name":' \ - | sed -E 's/.*"tag_name":[[:space:]]*"([^"]+)".*/\1/' - )" + target="$(gh release view \ + --repo authzed/spicedb-operator \ + --json tagName --jq '.tagName')" [ -n "$target" ] || { echo "::error::could not resolve latest release tag"; exit 1; } fi From 3211734bacaabeff11d7a1b156315e8897393234 Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Mon, 15 Jun 2026 12:06:57 -0700 Subject: [PATCH 4/4] Use yq and gh in update script where they don't reformat The workflow runs on GitHub-hosted runners, so assume curl/helm/yq/gh are available and use the right tool for each job: - read appVersion/version with 'yq .field' instead of grep/sed - resolve the latest release with 'gh release view' instead of curl + grep/sed on the REST API (gh auto-authenticates from GITHUB_TOKEN) - drop the nix helm-docs fallback (CI installs the binary) Deliberately KEPT as line-targeted sed: - update-graph extraction (awk verbatim capture): yq reserializes the block scalar, reordering entries and changing whitespace, which breaks the byte-identical-to-bundle guarantee the cross-check enforces. - Chart.yaml field edits: 'yq -i' reflows the whole document (re-indents the sources list, strips blank lines between commented sections), producing a noisy diff. sed touches only the three intended lines. Added a comment on the Chart.yaml edit explaining why, so it isn't 'cleaned up' into yq -i later. Verified the refactored script still produces output byte-identical to the hand-done v1.25.1 upgrade (all three files); shellcheck clean. --- scripts/update-operator.sh | 48 +++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/scripts/update-operator.sh b/scripts/update-operator.sh index 1beeaba..eda4603 100755 --- a/scripts/update-operator.sh +++ b/scripts/update-operator.sh @@ -15,14 +15,18 @@ # spec). Drift needs a human; the script writes a report and sets an output # flag rather than guessing. # 5. Bumps Chart.yaml (version minor bump, appVersion, source URL). -# 6. Regenerates README.md via helm-docs (if available). +# 6. Regenerates README.md via helm-docs (if on PATH). # 7. Verifies with helm lint + helm template. # +# Intended to run on a GitHub-hosted runner, which provides curl, helm, yq, and +# gh. It is driven by .github/workflows/update-operator.yml but is also safe to +# run by hand on a checkout, provided those tools are installed and gh is authed. +# # Usage: # scripts/update-operator.sh [TARGET_VERSION] # # TARGET_VERSION Operator release tag (e.g. v1.25.1). If omitted, the latest -# release is resolved from the GitHub API. +# release is resolved via `gh release view`. # # Outputs (when $GITHUB_OUTPUT is set, for use in GitHub Actions): # updated=true|false whether the chart changed @@ -69,32 +73,27 @@ require() { command -v "$1" >/dev/null 2>&1 || die "required command not found: # --- Preconditions ----------------------------------------------------------- +# This script targets GitHub-hosted runners, where curl, helm, yq, and gh are +# preinstalled. gh authenticates from GITHUB_TOKEN in the environment. require curl require helm +require yq +require gh [ -f "$CHART_YAML" ] || die "not in chart repo root (missing $CHART_YAML)" [ -f "$CONFIGMAP_TEMPLATE" ] || die "missing $CONFIGMAP_TEMPLATE" # --- Resolve versions -------------------------------------------------------- -# Current appVersion from Chart.yaml, e.g. appVersion: "v1.24.0" -> v1.24.0 -CURRENT_VERSION="$( - grep -E '^appVersion:' "$CHART_YAML" \ - | sed -E 's/^appVersion:[[:space:]]*"?([^"[:space:]]+)"?.*/\1/' -)" -[ -n "$CURRENT_VERSION" ] || die "could not parse appVersion from $CHART_YAML" +# Current appVersion from Chart.yaml (read-only; yq does not rewrite the file). +CURRENT_VERSION="$(yq '.appVersion' "$CHART_YAML")" +[ -n "$CURRENT_VERSION" ] && [ "$CURRENT_VERSION" != "null" ] \ + || die "could not parse appVersion from $CHART_YAML" TARGET_VERSION="${1:-}" if [ -z "$TARGET_VERSION" ]; then log "resolving latest release of ${REPO}..." - # GitHub API: latest release tag. Honor GITHUB_TOKEN to avoid rate limits. - AUTH_HEADER=() - [ -n "${GITHUB_TOKEN:-}" ] && AUTH_HEADER=(-H "Authorization: Bearer ${GITHUB_TOKEN}") - TARGET_VERSION="$( - curl -fsSL "${AUTH_HEADER[@]}" \ - "https://api.github.com/repos/${REPO}/releases/latest" \ - | grep -E '"tag_name":' \ - | sed -E 's/.*"tag_name":[[:space:]]*"([^"]+)".*/\1/' - )" + # gh auto-authenticates from GITHUB_TOKEN in the environment. + TARGET_VERSION="$(gh release view --repo "$REPO" --json tagName --jq '.tagName')" [ -n "$TARGET_VERSION" ] || die "could not resolve latest release tag" fi @@ -108,7 +107,7 @@ if [ "$CURRENT_VERSION" = "$TARGET_VERSION" ]; then log "chart is already at ${TARGET_VERSION}; nothing to do." emit updated false emit drift false - emit chart_version "$(grep -E '^version:' "$CHART_YAML" | sed -E 's/^version:[[:space:]]*//')" + emit chart_version "$(yq '.version' "$CHART_YAML")" exit 0 fi @@ -231,12 +230,16 @@ emit drift "$DRIFT" # - appVersion: target version. # - source URL: point the authzed release-tag line at the target version. -CHART_VERSION="$(grep -E '^version:' "$CHART_YAML" | sed -E 's/^version:[[:space:]]*//')" +CHART_VERSION="$(yq '.version' "$CHART_YAML")" IFS='.' read -r CV_MAJOR CV_MINOR _CV_PATCH <<< "$CHART_VERSION" [ -n "${CV_MAJOR:-}" ] && [ -n "${CV_MINOR:-}" ] || die "could not parse chart version: $CHART_VERSION" NEW_CHART_VERSION="${CV_MAJOR}.$((CV_MINOR + 1)).0" -# Use a tmp file + mv for portable in-place edits (no sed -i portability issues). +# NOTE: We deliberately use line-targeted sed here rather than `yq -i`. yq +# reserializes the whole document, which reflows Chart.yaml's list indentation +# and strips the blank lines between its commented sections — a noisy diff. +# sed changes only the three lines we intend and leaves the rest byte-for-byte +# untouched. tmp file + mv avoids sed -i portability issues. tmp_chart="$(mktemp)" sed -E \ -e "s|^version:[[:space:]]*.*|version: ${NEW_CHART_VERSION}|" \ @@ -252,11 +255,8 @@ emit chart_version "$NEW_CHART_VERSION" if command -v helm-docs >/dev/null 2>&1; then log "running helm-docs..." helm-docs >/dev/null 2>&1 || log "warning: helm-docs failed; README not regenerated" -elif command -v nix >/dev/null 2>&1; then - log "running helm-docs via nix..." - nix run nixpkgs#helm-docs >/dev/null 2>&1 || log "warning: nix helm-docs failed; README not regenerated" else - log "warning: helm-docs not available; README not regenerated" + log "warning: helm-docs not on PATH; README not regenerated" fi # --- Verify ------------------------------------------------------------------