From 01dffcc9cc818c25b867725925508d9ffa767bb4 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Wed, 23 Sep 2026 15:31:05 +0300 Subject: [PATCH 1/2] fix(hack): pin the trunk to the latest published release, not tag update_versions.sh picked the default cozystack_tag from the upstream git tag list. A tag exists as soon as it is pushed, while its GitHub release can still be a draft with no assets, so the trunk could be pinned to a version whose releases/download URLs all return 404. Resolve the default from the GitHub releases API instead, keeping only published, non-draft, non-prerelease releases and taking the highest version rather than the newest one, since a patch of an older minor can be created after a newer minor. An explicit --cozystack-tag still wins. An optional GITHUB_TOKEN goes to curl on stdin rather than in argv, so it never shows up in a process listing. Without a tag the script needs jq and says so when it is missing, instead of reporting that upstream has no published release. CLAUDE.md and CONTRIBUTING.md now list jq as a required tool. hack/test_version_pins.sh is an offline self-check that feeds the resolver release-list fixtures and runs the script against a stub curl to check where the token goes and the missing-jq error. Assisted-by: LLM Signed-off-by: Aleksei Sviridkin --- CLAUDE.md | 2 +- CONTRIBUTING.md | 4 +- data/versions/next.yaml | 2 +- hack/test_version_pins.sh | 91 +++++++++++++++++++++++++++++++++++++++ hack/update_versions.sh | 52 +++++++++++++++++----- 5 files changed, 138 insertions(+), 13 deletions(-) create mode 100755 hack/test_version_pins.sh diff --git a/CLAUDE.md b/CLAUDE.md index 60e2bdcd..389c0546 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,7 +46,7 @@ make update-apps APPS="tenant redis" make show-target RELEASE_TAG=v1.3.0 # prints the resolved DOC_VERSION/BRANCH ``` -Required tools: Hugo extended v0.164.0, Go 1.23+, Node 22+, yq v4+ (for version lifecycle targets). +Required tools: Hugo extended v0.164.0, Go 1.23+, Node 22+, yq v4+ (for version lifecycle targets), jq (for `hack/download_openapi.sh` in the production build and `make update-all` without `RELEASE_TAG`). ## Architecture diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f61ca0d0..cdb10371 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -226,7 +226,9 @@ make show-target RELEASE_TAG=v1.3.0 # prints resolved DOC_VERSION / BRANCH ``` Required tools: Hugo extended 0.164.0, Go 1.23+, Node 22+, `yq` v4+ (for the -version lifecycle targets and Makefile routing). +version lifecycle targets and Makefile routing), `jq` (for +`hack/download_openapi.sh` in the production build and `make update-all` +without `RELEASE_TAG`). ## Where the architecture is implemented diff --git a/data/versions/next.yaml b/data/versions/next.yaml index 66ed53c2..5436f40d 100644 --- a/data/versions/next.yaml +++ b/data/versions/next.yaml @@ -4,7 +4,7 @@ # next/ trunk track upstream cozystack/cozystack@main: # # talos / talos_minor ← packages/core/talos/images/talos/profiles/installer.yaml @ main -# cozystack_version / _tag ← latest final release tag (the upcoming release's +# cozystack_version / _tag ← latest published release (the upcoming release's # own tag/assets don't exist until it is cut; # hack/release_next.sh overrides these from # RELEASE_TAG when next/ is promoted). diff --git a/hack/test_version_pins.sh b/hack/test_version_pins.sh new file mode 100755 index 00000000..f7781041 --- /dev/null +++ b/hack/test_version_pins.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Offline self-check for the data/versions/*.yaml pin pipeline +# (hack/update_versions.sh). +# Run from the repo root: hack/test_version_pins.sh +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +fail=0 +check() { # check + if [[ "$2" == "$3" ]]; then + echo "ok $1" + else + echo "FAIL $1: expected '$2', got '$3'"; fail=1 + fi +} + +# shellcheck source=hack/update_versions.sh +source hack/update_versions.sh + +# GitHub lists releases newest-created first. +check "resolver skips a draft and a prerelease" "v1.6.3" "$(latest_published_tag <<'EOF' +[ + {"tag_name": "v1.6.4", "draft": true, "prerelease": false}, + {"tag_name": "v1.7.0-rc.1", "draft": false, "prerelease": true}, + {"tag_name": "v1.6.3", "draft": false, "prerelease": false}, + {"tag_name": "v1.6.2", "draft": false, "prerelease": false} +] +EOF +)" + +check "resolver picks the highest version, not the newest release" "v1.6.3" "$(latest_published_tag <<'EOF' +[ + {"tag_name": "v1.5.9", "draft": false, "prerelease": false}, + {"tag_name": "v1.6.3", "draft": false, "prerelease": false}, + {"tag_name": "v1.5.8", "draft": false, "prerelease": false} +] +EOF +)" + +check "resolver prints nothing when no release is published" "" "$(latest_published_tag <<<'[{"tag_name": "v1.6.4", "draft": true, "prerelease": false}]')" + +sandbox="$(mktemp -d)" +trap 'rm -rf "$sandbox"' EXIT + +# update_versions.sh must send GITHUB_TOKEN on curl's stdin, never in argv +# where any process listing shows it. A PATH stub stands in for curl. +mkdir -p "$sandbox/bin" +cat > "$sandbox/bin/curl" <<'EOF' +#!/usr/bin/env bash +echo "argv: $*" >> "$CURL_LOG" +case "$*" in + *api.github.com*) + if [[ "$*" == *"--header @-"* ]]; then sed 's/^/stdin: /' >> "$CURL_LOG"; fi + echo '[{"tag_name": "v1.6.3", "draft": false, "prerelease": false}]' ;; + *) echo 'version: v1.13.6' ;; +esac +EOF +chmod +x "$sandbox/bin/curl" +run_update() { + CURL_LOG="$sandbox/curl.log" PATH="$sandbox/bin:$PATH" \ + hack/update_versions.sh --dest "$sandbox/pin.yaml" >/dev/null +} +GITHUB_TOKEN=s3cr3t run_update +check "token stays out of curl argv" "0" "$(grep --count '^argv: .*s3cr3t' "$sandbox/curl.log" || true)" +check "token is sent as a header on stdin" "1" "$(grep --count '^stdin: Authorization: token s3cr3t$' "$sandbox/curl.log" || true)" +check "pin file takes the resolved release" '"v1.6.3"' "$(awk '/^cozystack_tag:/{print $2}' "$sandbox/pin.yaml")" +rm "$sandbox/curl.log" +(unset GITHUB_TOKEN GH_TOKEN; run_update) +check "no token, plain request" "argv: -fsSL https://api.github.com/repos/cozystack/cozystack/releases?per_page=100" "$(grep 'api.github.com' "$sandbox/curl.log")" + +# Without jq the default tag cannot be resolved: the error must say so rather +# than claim upstream has no published release. An explicit tag needs no jq. +mkdir -p "$sandbox/nojq" +for tool in bash awk sed grep sort tail mkdir dirname cat; do + ln -s "$(type -P "$tool")" "$sandbox/nojq/$tool" +done +ln -s "$sandbox/bin/curl" "$sandbox/nojq/curl" +run_nojq() { + CURL_LOG="$sandbox/curl.log" PATH="$sandbox/nojq" \ + hack/update_versions.sh --dest "$sandbox/pin.yaml" "$@" 2>&1 +} +echo keep > "$sandbox/pin.yaml" +rc=0; out="$(run_nojq)" || rc=$? +check "no jq, no tag: exits non-zero" "1" "$rc" +check "no jq, no tag: error names jq" "1" "$(grep --count 'jq is required' <<<"$out" || true)" +check "no jq, no tag: pin file untouched" "keep" "$(cat "$sandbox/pin.yaml")" +run_nojq --cozystack-tag v1.6.2 >/dev/null +check "no jq, explicit tag: pins it" '"v1.6.2"' "$(awk '/^cozystack_tag:/{print $2}' "$sandbox/pin.yaml")" + +exit "$fail" diff --git a/hack/update_versions.sh b/hack/update_versions.sh index 2527b670..4a289f90 100755 --- a/hack/update_versions.sh +++ b/hack/update_versions.sh @@ -12,16 +12,17 @@ Sources of truth: * talos / talos_minor ← packages/core/talos/images/talos/profiles/installer.yaml at --branch (always the version main/the tag ships). * cozystack_version/_tag ← --cozystack-tag if given (e.g. an upcoming release - tag); otherwise the latest final upstream release - tag. Used as the `next` trunk's resolvable default - until the real release is cut. + tag); otherwise the highest published (non-draft, + non-prerelease) GitHub release, so every + releases/download// URL resolves. Used as the + `next` trunk's default until the real release is cut. Options: --dest PATH data/versions/.yaml file to (re)generate (required) --branch REF Git ref in cozystack/cozystack to read the Talos installer from (default: main) --cozystack-tag TAG Pin cozystack_tag to this vX.Y.Z tag instead of the latest - release (optional) + published release (optional) -h, --help Show this help and exit Examples: @@ -30,6 +31,18 @@ Examples: EOF } +# Reads a GitHub releases-list JSON array on stdin and prints the highest +# published final vX.Y.Z tag. A tag exists before its release is published, so +# the tag list alone can name a version whose assets are still missing. Sorted +# by version, not creation time: a patch of an older minor can be the newest. +latest_published_tag() { + jq -r '.[] | select(.draft == false and .prerelease == false) | .tag_name' \ + | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true +} + +# Sourced by hack/test_version_pins.sh for latest_published_tag only. +[[ "${BASH_SOURCE[0]}" != "$0" ]] && return 0 + SOURCE_REPO="cozystack/cozystack" DEST="" BRANCH="main" @@ -73,11 +86,30 @@ talos_minor="${talos%.*}" # v1.13.0 -> v1.13 # -------------------- 2. Cozystack release tag -------------------- if [[ -z "$COZYSTACK_TAG" ]]; then - # Latest final release: top-level refs/tags/vX.Y.Z only (excludes the - # api/apps/v1alpha1/* submodule tags and any -rc/-beta pre-releases). - COZYSTACK_TAG="$(git ls-remote --tags --refs "https://github.com/${SOURCE_REPO}.git" 'v*.*.*' \ - | awk -F/ '/refs\/tags\/v[0-9]+\.[0-9]+\.[0-9]+$/{print $NF}' \ - | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true)" + if ! command -v jq >/dev/null; then + echo "Error: jq is required to resolve the default cozystack tag; install jq or pass --cozystack-tag." >&2 + exit 1 + fi + # Optional token for the higher API rate limit. + token="${GITHUB_TOKEN:-${GH_TOKEN:-}}" + RELEASES_URL="https://api.github.com/repos/${SOURCE_REPO}/releases?per_page=100" + fetch_releases() { + if [[ -n "$token" ]]; then + # Header on stdin (curl >= 7.55.0): in argv the token shows in ps. + curl -fsSL --header @- "$RELEASES_URL" <<<"Authorization: token ${token}" + else + curl -fsSL "$RELEASES_URL" + fi + } + if ! releases="$(fetch_releases)"; then + echo "Error: GitHub releases API request failed: $RELEASES_URL (set GITHUB_TOKEN if rate-limited, or pass --cozystack-tag)." >&2 + exit 1 + fi + COZYSTACK_TAG="$(latest_published_tag <<<"$releases")" + if [[ -z "$COZYSTACK_TAG" ]]; then + echo "Error: no published vX.Y.Z release found at $RELEASES_URL." >&2 + exit 1 + fi fi if [[ ! "$COZYSTACK_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "Error: could not determine a cozystack release tag (got '${COZYSTACK_TAG:-}')." >&2 @@ -94,7 +126,7 @@ cat > "$DEST" < Date: Wed, 23 Sep 2026 15:31:46 +0300 Subject: [PATCH 2/2] fix(hack): give released version pin files a released header release_next.sh copied next.yaml into the new vX.Y.yaml verbatim, so a released pin file kept the trunk header saying 'make update-all' regenerates it to track upstream main. That is false for a released file, which update-all never touches. Rewrite the leading comment block and the trunk wording in the section comments when snapshotting, and correct v1.6.yaml, the one released file that carried the trunk header. The header does not repeat the pinned version: patch releases bump the values by hand, and a copy in the comment would go stale. Only comments change; no pinned value moves. Assisted-by: LLM Signed-off-by: Aleksei Sviridkin --- data/versions/v1.6.yaml | 15 +++++---------- hack/release_next.sh | 21 +++++++++++++++++---- hack/test_version_pins.sh | 17 ++++++++++++++++- 3 files changed, 38 insertions(+), 15 deletions(-) diff --git a/data/versions/v1.6.yaml b/data/versions/v1.6.yaml index 829350c0..ca8db7b8 100644 --- a/data/versions/v1.6.yaml +++ b/data/versions/v1.6.yaml @@ -1,18 +1,13 @@ -# AUTOGENERATED by hack/update_versions.sh — do not edit by hand. +# Pinned upstream-tool versions for the Cozystack v1.6 docs. # -# Regenerated by 'make update-all' so the {{< version-pin >}} values in the -# next/ trunk track upstream cozystack/cozystack@main: -# -# talos / talos_minor ← packages/core/talos/images/talos/profiles/installer.yaml @ main -# cozystack_version / _tag ← latest final release tag (the upcoming release's -# own tag/assets don't exist until it is cut; -# hack/release_next.sh overrides these from -# RELEASE_TAG when next/ is promoted). +# Snapshotted from data/versions/next.yaml by hack/release_next.sh when v1.6 +# was released. 'make update-all' does not rewrite it; patch releases +# update it by hand (hack/release-checklist.md). # Cozystack release the docs are pinned to. cozystack_version: "1.6.0" cozystack_tag: "v1.6.0" -# Talos version shipped by the Cozystack installer for this trunk. +# Talos version shipped by the Cozystack installer for this minor. talos: "v1.13.6" talos_minor: "v1.13" # the docs minor used by talos.dev URLs diff --git a/hack/release_next.sh b/hack/release_next.sh index 1344edf0..c0892786 100755 --- a/hack/release_next.sh +++ b/hack/release_next.sh @@ -121,8 +121,10 @@ fi # 5. Snapshot data/versions/next.yaml → data/versions/$DOC_VERSION.yaml so the # {{< version-pin >}} shortcode in the released docs keeps resolving to the -# values that were true at the cut. next.yaml is unchanged; update it -# separately for the next development cycle. +# values that were true at the cut. next.yaml is unchanged. The upstream +# promote workflow (cozystack/cozystack promote-rc.yaml) regenerates it from +# the release staging branch before calling release-next, so the snapshot +# carries that release's Talos pins. VERSIONS_DIR="data/versions" NEXT_DATA="${VERSIONS_DIR}/next.yaml" TARGET_DATA="${VERSIONS_DIR}/${DOC_VERSION}.yaml" @@ -130,11 +132,22 @@ if [[ -f "$NEXT_DATA" ]]; then if [[ -e "$TARGET_DATA" ]]; then echo "! $TARGET_DATA already exists; leaving it as-is." >&2 else - cp "$NEXT_DATA" "$TARGET_DATA" + # Replace the trunk header (the leading comment block) and the trunk wording + # in section comments: both describe next.yaml, not a frozen snapshot. + { + echo "# Pinned upstream-tool versions for the Cozystack ${DOC_VERSION} docs." + echo "#" + echo "# Snapshotted from ${NEXT_DATA} by hack/release_next.sh when ${DOC_VERSION}" + echo "# was released. 'make update-all' does not rewrite it; patch releases" + echo "# update it by hand (hack/release-checklist.md)." + echo "" + awk 'h && /^#/ {next} h && /^$/ {h=0; next} {h=0; print}' h=1 "$NEXT_DATA" \ + | sed 's|for this trunk\.|for this minor.|' + } > "$TARGET_DATA" # Pin the release-coupled Cozystack version from RELEASE_TAG so a stale # next.yaml can't silently freeze the wrong version into the snapshot — # this is exactly how v1.5.yaml once inherited next.yaml's v1.3.0 values. - # Talos pins are left as snapshotted; they're refreshed manually per cycle. + # Talos pins are left as snapshotted. VERSION_BARE="${RELEASE_TAG#v}" sed -i.bak \ -e "s|^\(cozystack_version:[[:space:]]*\).*|\1\"${VERSION_BARE}\"|" \ diff --git a/hack/test_version_pins.sh b/hack/test_version_pins.sh index f7781041..994cb5f9 100755 --- a/hack/test_version_pins.sh +++ b/hack/test_version_pins.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Offline self-check for the data/versions/*.yaml pin pipeline -# (hack/update_versions.sh). +# (hack/update_versions.sh, hack/release_next.sh). # Run from the repo root: hack/test_version_pins.sh set -euo pipefail @@ -88,4 +88,19 @@ check "no jq, no tag: pin file untouched" "keep" "$(cat "$sandbox/pin.yaml")" run_nojq --cozystack-tag v1.6.2 >/dev/null check "no jq, explicit tag: pins it" '"v1.6.2"' "$(awk '/^cozystack_tag:/{print $2}' "$sandbox/pin.yaml")" +# release_next.sh must give the snapshot a released-version header while +# copying every value line except the release-coupled cozystack pins. +mkdir -p "$sandbox/hack" "$sandbox/data/versions" "$sandbox/content/en/docs/next" +cp hugo.yaml "$sandbox/" +cp hack/release_next.sh hack/register_version.sh "$sandbox/hack/" +cp data/versions/next.yaml "$sandbox/data/versions/" +printf -- '---\ntitle: "Next"\n---\n' > "$sandbox/content/en/docs/next/_index.md" +(cd "$sandbox" && ./hack/release_next.sh --release-tag v9.9.0 >/dev/null) +snapshot="$sandbox/data/versions/v9.9.yaml" +check "snapshot header no longer mentions the trunk" "0" "$(grep --count --ignore-case 'trunk' "$snapshot" || true)" +check "snapshot header names the released version" "1" "$(grep --count '^# .*Cozystack v9.9 docs' "$snapshot" || true)" +check "snapshot pins cozystack_tag to the release" '"v9.9.0"' "$(awk '/^cozystack_tag:/{print $2}' "$snapshot")" +values() { grep --invert-match --extended-regexp '^(#|$|cozystack_version:|cozystack_tag:)' "$1"; } +check "snapshot keeps every other value line" "$(values data/versions/next.yaml)" "$(values "$snapshot")" + exit "$fail"