From b67791e4d9b6ef85124bc51adf752cfd32ded351 Mon Sep 17 00:00:00 2001 From: "lia-by-librechat[bot]" <328778573+lia-by-librechat[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 23:33:43 -0400 Subject: [PATCH 1/4] ci: Fix Release Version Resolution for Untagged and Resumed Runs (#233) * ci: Fix Release Version Resolution for Untagged and Resumed Runs The release workflow resolved its version in one inline shell block under `set -euo pipefail`, where two paths could not succeed. Filtering tags through `grep` made a no-match fatal. On the ordinary untagged tip of `main`, `git tag --points-at HEAD | grep -E '^v[0-9]+...'` exits 1, and the step died before reaching its skip handling or `next-release-version.sh`, so a deployable commit could not obtain a release version (#228). Selecting stable tags now reads exit 1 as an empty answer while exit 2 and above still fail the release, which also lets the missing-previous-tag case report its own error. The rerun-resume path then rejected the tag it had itself chosen. With a stable tag already pointing at `HEAD` and no release published, the version comes from that tag, and the following existence check failed merely because the ref existed (#229). It now compares the tag's commit against the release commit, so only a tag on some other commit is a collision; `Create tag` already tolerates a tag that exists. The block moved into `.github/scripts/resolve-release-version.sh`, beside the `next-release-version.sh` it calls, so `tests/release-version-resolution.sh` can cover every path: automatic, resumed, skipped, dispatched, pushed-tag, and the runs that must be refused, each against a throwaway repository with a stubbed `gh`. * fix: harden release resolver execution --------- Co-authored-by: Lia Co-authored-by: Danny Avila (cherry picked from commit fd9a4fa65e0a5189957032c0046eb286311fda62) --- .github/scripts/resolve-release-version.sh | 195 +++++++++++++++ .github/workflows/ci.yml | 3 + .github/workflows/release.yml | 150 ++---------- tests/release-version-resolution.sh | 265 +++++++++++++++++++++ 4 files changed, 484 insertions(+), 129 deletions(-) create mode 100755 .github/scripts/resolve-release-version.sh create mode 100755 tests/release-version-resolution.sh diff --git a/.github/scripts/resolve-release-version.sh b/.github/scripts/resolve-release-version.sh new file mode 100755 index 00000000..ef5f8667 --- /dev/null +++ b/.github/scripts/resolve-release-version.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash + +# Resolves which version a release run publishes, or decides that it publishes +# nothing, and records the decision in $GITHUB_OUTPUT. Extracted from +# .github/workflows/release.yml so the four entry paths — an automatic release +# after CI, a rerun resuming a release whose tag was already cut, a dispatch, +# and a pushed tag — are covered by tests/release-version-resolution.sh. +# +# Inputs arrive as environment variables, mirroring the workflow's env block: +# +# EVENT_NAME github.event_name +# HEAD_SHA github.event.workflow_run.head_sha +# INPUT_VERSION github.event.inputs.version +# INPUT_DRAFT github.event.inputs.draft +# REF_NAME github.ref_name +# REF_TYPE github.ref_type +# GH_TOKEN a token `gh release view` can read releases with +# +# Outputs: skip, and for a real release version, app_version, chart_version, +# prerelease, latest, draft. Run from the repository root; the chart fields are +# read from helm/codeapi/Chart.yaml relative to it. + +set -euo pipefail + +EVENT_NAME="${EVENT_NAME:-}" +HEAD_SHA="${HEAD_SHA:-}" +INPUT_VERSION="${INPUT_VERSION:-}" +INPUT_DRAFT="${INPUT_DRAFT:-}" +REF_NAME="${REF_NAME:-}" +REF_TYPE="${REF_TYPE:-}" +GITHUB_OUTPUT="${GITHUB_OUTPUT:?GITHUB_OUTPUT must name the step output file}" + +STABLE_TAG_PATTERN='^v[0-9]+[.][0-9]+[.][0-9]+$' + +# `grep` exits 1 when nothing matches, and under `pipefail` that would abort the +# step. A commit with no stable tag is the ordinary state of `main`, so a +# no-match reads as an empty answer while a genuine grep failure — exit 2 and +# above — still fails the release. +select_stable_tags() { + local status=0 + grep -E "$STABLE_TAG_PATTERN" || status=$? + [ "$status" -le 1 ] +} + +# Highest stable tag among those `git tag` selects, empty when there are none. +newest_stable_tag() { + git tag "$@" | select_stable_tags | sort -V | tail -n 1 +} + +# The commit a tag resolves to. The caller first verifies that the ref exists, +# so a failure here means the tag ultimately names a non-commit object. +tag_commit() { + git rev-parse -q --verify "refs/tags/$1^{commit}" +} + +SKIP=false +VERSION="" +HEAD_COMMIT="" + +if [ "$EVENT_NAME" = "workflow_run" ]; then + HEAD_COMMIT="$(git rev-parse HEAD)" + if [ "$HEAD_COMMIT" != "$HEAD_SHA" ]; then + echo "::error::Checked out SHA does not match the successful CI run" + exit 1 + fi + + REMOTE_MAIN_SHA="$(git ls-remote origin refs/heads/main | awk '{print $1}')" + if [ -z "$REMOTE_MAIN_SHA" ]; then + echo "::error::Could not resolve the current main branch tip" + exit 1 + fi + if [ "$REMOTE_MAIN_SHA" != "$HEAD_SHA" ]; then + echo "main advanced after this CI run; the newer successful run will release the combined changes" + SKIP=true + fi + + # A rerun after tag creation but before release publication resumes the + # missing release rather than incrementing the version again. + EXACT_TAG="$(newest_stable_tag --points-at HEAD)" + if [ "$SKIP" = "false" ] && [ -n "$EXACT_TAG" ]; then + if gh release view "$EXACT_TAG" >/dev/null 2>&1; then + echo "$EXACT_TAG already publishes this commit; nothing to do" + SKIP=true + else + VERSION="$EXACT_TAG" + fi + elif [ "$SKIP" = "false" ]; then + PREVIOUS_TAG="$(newest_stable_tag --merged HEAD)" + if [ -z "$PREVIOUS_TAG" ]; then + echo "::error::Automatic releases require an existing stable vMAJOR.MINOR.PATCH tag" + exit 1 + fi + VERSION="$(.github/scripts/next-release-version.sh "$PREVIOUS_TAG" "$PREVIOUS_TAG..HEAD")" + if [ -z "$VERSION" ]; then + echo "Only documentation, workflow, or test files changed since $PREVIOUS_TAG; no release needed" + SKIP=true + fi + fi +elif [ "$EVENT_NAME" = "workflow_dispatch" ]; then + # Releases describe what shipped to main. Dispatching from a topic branch + # would tag a commit that is not on the release line. + if [ "$REF_TYPE" != "branch" ] || [ "$REF_NAME" != "main" ]; then + echo "::error::Releases must be cut from main; this run is on '$REF_NAME'" + exit 1 + fi + VERSION="$INPUT_VERSION" +else + VERSION="$REF_NAME" +fi + +if [ "$SKIP" = "true" ]; then + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 +fi + +# A bare "2.0.0" typed into the dispatch box is accepted; everything downstream +# works with the v-prefixed form the tag actually uses. +case "$VERSION" in + v*) ;; + *) VERSION="v$VERSION" ;; +esac + +if [[ ! "$VERSION" =~ ^v[0-9]+[.][0-9]+[.][0-9]+(-rc[0-9]+)?$ ]]; then + echo "::error::Release tags must be v.. or v..-rcN, for example v1.0.0 or v1.1.0-rc1 (got '$VERSION')" + exit 1 +fi + +if [ "$EVENT_NAME" = "workflow_run" ]; then + # A tag already pointing at this commit is the resumed release above, and the + # publish steps tolerate it. Only a tag on some other commit is a collision. + if git show-ref --verify --quiet "refs/tags/$VERSION"; then + if ! EXISTING_TAG_COMMIT="$(tag_commit "$VERSION")"; then + echo "::error::Calculated tag $VERSION already exists but does not point to a commit" + exit 1 + fi + if [ "$EXISTING_TAG_COMMIT" != "$HEAD_COMMIT" ]; then + echo "::error::Calculated tag $VERSION already exists on a different commit" + exit 1 + fi + fi +fi + +read_chart_field() { + grep -m1 "^$1:" helm/codeapi/Chart.yaml \ + | sed -E "s/^$1:[[:space:]]*//; s/[[:space:]]*#.*//; s/^[\"']//; s/[\"']\$//" +} +APP_VERSION="$(read_chart_field appVersion)" +CHART_VERSION="$(read_chart_field version)" + +if [ "$EVENT_NAME" = "workflow_dispatch" ] \ + && git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then + echo "::error::Tag $VERSION already exists. Pick a new version, or delete the tag if it was cut in error." + exit 1 +fi + +case "$VERSION" in + *-rc*) PRERELEASE=true ;; + *) PRERELEASE=false ;; +esac + +# `latest` moves only when this is the highest stable version, so re-cutting an +# older patch cannot drag it backwards. The tag under dispatch does not exist +# yet, hence adding it to the comparison. +LATEST=false +if [ "$PRERELEASE" = "false" ]; then + HIGHEST_STABLE="$( + { + git tag --list 'v[0-9]*' + printf '%s\n' "$VERSION" + } \ + | select_stable_tags \ + | sort -V \ + | tail -n 1 + )" + if [ "$HIGHEST_STABLE" = "$VERSION" ]; then + LATEST=true + fi +fi + +DRAFT=false +if [ "$INPUT_DRAFT" = "true" ]; then + DRAFT=true +fi + +{ + echo "skip=false" + echo "version=$VERSION" + echo "app_version=$APP_VERSION" + echo "chart_version=$CHART_VERSION" + echo "prerelease=$PRERELEASE" + echo "latest=$LATEST" + echo "draft=$DRAFT" +} >> "$GITHUB_OUTPUT" + +echo "Releasing $VERSION (chart $CHART_VERSION, appVersion $APP_VERSION, prerelease=$PRERELEASE, latest=$LATEST, draft=$DRAFT)" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 926b9e6e..55c98d74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,9 @@ jobs: - name: Release versioning run: tests/release-versioning.sh + - name: Release version resolution + run: tests/release-version-resolution.sh + - name: Validate sandbox Dockerfiles run: | docker buildx build --check -f api/Dockerfile . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c47b1a0d..684f1654 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,6 +24,7 @@ name: Release on: workflow_run: workflows: ['CI'] + branches: [main] types: [completed] workflow_dispatch: inputs: @@ -60,6 +61,20 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: + # `workflow_run` can be rerun for a commit older than this resolver. Save + # the helper from the revision that supplied this workflow before the + # release checkout replaces the working tree with that historical SHA. + - name: Checkout release workflow + if: github.event_name == 'workflow_run' + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + fetch-depth: 1 + ref: ${{ github.workflow_sha }} + + - name: Preserve release resolver + if: github.event_name == 'workflow_run' + run: install -m 755 .github/scripts/resolve-release-version.sh "$RUNNER_TEMP/resolve-release-version.sh" + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: # Full history and tags: resolving whether this release is the newest @@ -77,135 +92,12 @@ jobs: REF_NAME: ${{ github.ref_name }} REF_TYPE: ${{ github.ref_type }} GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - SKIP=false - if [ "$EVENT_NAME" = "workflow_run" ]; then - if [ "$(git rev-parse HEAD)" != "$HEAD_SHA" ]; then - echo "::error::Checked out SHA does not match the successful CI run" - exit 1 - fi - - git fetch --no-tags origin main:refs/remotes/origin/main - if [ "$(git rev-parse refs/remotes/origin/main)" != "$HEAD_SHA" ]; then - echo "main advanced after this CI run; the newer successful run will release the combined changes" - SKIP=true - fi - - # A rerun after tag creation but before release publication resumes - # the missing release rather than incrementing the version again. - EXACT_TAG="$({ git tag --points-at HEAD || true; } | grep -E '^v[0-9]+[.][0-9]+[.][0-9]+$' | sort -V | tail -n 1)" - if [ "$SKIP" = "false" ] && [ -n "$EXACT_TAG" ]; then - if gh release view "$EXACT_TAG" >/dev/null 2>&1; then - echo "$EXACT_TAG already publishes this commit; nothing to do" - SKIP=true - else - VERSION="$EXACT_TAG" - fi - elif [ "$SKIP" = "false" ]; then - PREVIOUS_TAG="$(git tag --merged HEAD \ - | grep -E '^v[0-9]+[.][0-9]+[.][0-9]+$' \ - | sort -V \ - | tail -n 1)" - if [ -z "$PREVIOUS_TAG" ]; then - echo "::error::Automatic releases require an existing stable vMAJOR.MINOR.PATCH tag" - exit 1 - fi - VERSION="$(.github/scripts/next-release-version.sh "$PREVIOUS_TAG" "$PREVIOUS_TAG..HEAD")" - if [ -z "$VERSION" ]; then - echo "Only documentation, workflow, or test files changed since $PREVIOUS_TAG; no release needed" - SKIP=true - fi - fi - elif [ "$EVENT_NAME" = "workflow_dispatch" ]; then - # Releases describe what shipped to main. Dispatching from a topic - # branch would tag a commit that is not on the release line. - if [ "$REF_TYPE" != "branch" ] || [ "$REF_NAME" != "main" ]; then - echo "::error::Releases must be cut from main; this run is on '$REF_NAME'" - exit 1 - fi - VERSION="$INPUT_VERSION" - else - VERSION="$REF_NAME" - fi - - if [ "$SKIP" = "true" ]; then - echo "skip=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # A bare "2.0.0" typed into the dispatch box is accepted; everything - # downstream works with the v-prefixed form the tag actually uses. - case "$VERSION" in - v*) ;; - *) VERSION="v$VERSION" ;; - esac - - if [[ ! "$VERSION" =~ ^v[0-9]+[.][0-9]+[.][0-9]+(-rc[0-9]+)?$ ]]; then - echo "::error::Release tags must be v.. or v..-rcN, for example v1.0.0 or v1.1.0-rc1 (got '$VERSION')" - exit 1 - fi - - if [ "$EVENT_NAME" = "workflow_run" ] \ - && git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then - echo "::error::Calculated tag $VERSION already exists on a different commit" - exit 1 - fi - - read_chart_field() { - grep -m1 "^$1:" helm/codeapi/Chart.yaml \ - | sed -E "s/^$1:[[:space:]]*//; s/[[:space:]]*#.*//; s/^[\"']//; s/[\"']\$//" - } - APP_VERSION="$(read_chart_field appVersion)" - CHART_VERSION="$(read_chart_field version)" - - if [ "$EVENT_NAME" = "workflow_dispatch" ] \ - && git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then - echo "::error::Tag $VERSION already exists. Pick a new version, or delete the tag if it was cut in error." - exit 1 - fi - - case "$VERSION" in - *-rc*) PRERELEASE=true ;; - *) PRERELEASE=false ;; - esac - - # `latest` moves only when this is the highest stable version, so - # re-cutting an older patch cannot drag it backwards. The tag under - # dispatch does not exist yet, hence adding it to the comparison. - LATEST=false - if [ "$PRERELEASE" = "false" ]; then - HIGHEST_STABLE="$( - { - git tag --list 'v[0-9]*' - printf '%s\n' "$VERSION" - } \ - | grep -E '^v[0-9]+[.][0-9]+[.][0-9]+$' \ - | sort -V \ - | tail -n 1 - )" - if [ "$HIGHEST_STABLE" = "$VERSION" ]; then - LATEST=true - fi - fi - - DRAFT=false - if [ "$INPUT_DRAFT" = "true" ]; then - DRAFT=true - fi - - { - echo "skip=false" - echo "version=$VERSION" - echo "app_version=$APP_VERSION" - echo "chart_version=$CHART_VERSION" - echo "prerelease=$PRERELEASE" - echo "latest=$LATEST" - echo "draft=$DRAFT" - } >> "$GITHUB_OUTPUT" - - echo "Releasing $VERSION (chart $CHART_VERSION, appVersion $APP_VERSION, prerelease=$PRERELEASE, latest=$LATEST, draft=$DRAFT)" + RESOLVER_PATH: ${{ github.event_name == 'workflow_run' && format('{0}/resolve-release-version.sh', runner.temp) || '.github/scripts/resolve-release-version.sh' }} + # The resolution itself lives in a script so that every path through it + # — automatic release, resumed release, dispatch, pushed tag, and the + # runs that must skip or fail — is covered by + # tests/release-version-resolution.sh in CI. + run: "$RESOLVER_PATH" # helm is preinstalled on ubuntu-latest, the same way the chart tests in # ci.yml depend on it. diff --git a/tests/release-version-resolution.sh b/tests/release-version-resolution.sh new file mode 100755 index 00000000..c70d50d3 --- /dev/null +++ b/tests/release-version-resolution.sh @@ -0,0 +1,265 @@ +#!/usr/bin/env bash + +# Covers .github/scripts/resolve-release-version.sh: the version a release run +# publishes, and the runs that have to skip or fail instead. Every case builds a +# throwaway repository with an `origin` the resolver can query and a stubbed +# `gh`, so nothing here reaches the network or the real repository. + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# The resolver is deliberately kept outside each throwaway checkout. That +# mirrors release.yml preserving the workflow revision in RUNNER_TEMP before a +# workflow_run checks out the possibly historical release commit. +RESOLVER="$ROOT/.github/scripts/resolve-release-version.sh" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +REPO="$WORK/repo" +ORIGIN="$WORK/origin.git" +OUTPUT="$WORK/github_output" +LOG="$WORK/log" +CASE='' +STATUS=0 +FAILURES=0 + +# `gh release view` is the only gh call the resolver makes. PUBLISHED lists the +# releases that already exist; anything else must not be invoked at all. +mkdir -p "$WORK/bin" +cat > "$WORK/bin/gh" <<'STUB' +#!/usr/bin/env bash +if [ "$1" = 'release' ] && [ "$2" = 'view' ]; then + for published in ${PUBLISHED:-}; do + if [ "$published" = "$3" ]; then + exit 0 + fi + done + exit 1 +fi +echo "unexpected gh invocation: $*" >&2 +exit 2 +STUB +chmod +x "$WORK/bin/gh" +PATH="$WORK/bin:$PATH" + +git_repo() { + git -C "$REPO" "$@" +} + +# A fresh repository whose layout matches what the resolver reads from the +# checkout: the version bump script it shells out to, and the chart it takes the +# app and chart versions from, trailing comments and quotes included. +new_case() { + CASE="$1" + rm -rf "$REPO" "$ORIGIN" + git init -q --bare "$ORIGIN" + git init -q -b main "$REPO" + git_repo config user.name test + git_repo config user.email test@example.com + git_repo remote add origin "$ORIGIN" + mkdir -p "$REPO/.github/scripts" "$REPO/helm/codeapi" + cp "$ROOT/.github/scripts/next-release-version.sh" "$REPO/.github/scripts/" + cat > "$REPO/helm/codeapi/Chart.yaml" <<'CHART' +apiVersion: v2 +name: codeapi +version: 0.3.1 # Chart version +appVersion: "2.0.0" # App version +CHART + commit api/runtime.ts initial 'chore: initial import' + publish_main +} + +commit() { + local path="$1" content="$2" message="$3" + mkdir -p "$REPO/$(dirname "$path")" + printf '%s\n' "$content" > "$REPO/$path" + git_repo add "$path" + git_repo commit -q -m "$message" +} + +publish_main() { + git_repo push -q origin main +} + +head_sha() { + git_repo rev-parse HEAD +} + +# Runs the resolver in the throwaway repository. Arguments are KEY=VALUE pairs +# standing in for the workflow's env block. +resolve() { + : > "$OUTPUT" + set +e + (cd "$REPO" && env GITHUB_OUTPUT="$OUTPUT" "$@" bash "$RESOLVER") > "$LOG" 2>&1 + STATUS=$? + set -e +} + +fail() { + echo "FAIL [$CASE] $1" >&2 + sed 's/^/ | /' "$LOG" >&2 + FAILURES=$((FAILURES + 1)) +} + +expect_status() { + if [ "$STATUS" != "$1" ]; then + fail "exit status: expected $1, got $STATUS" + fi +} + +expect_output() { + local actual + actual="$(sed -n "s/^$1=//p" "$OUTPUT" | tail -n 1)" + if [ "$actual" != "$2" ]; then + fail "output $1: expected '$2', got '$actual'" + fi +} + +expect_log() { + if ! grep -qF "$1" "$LOG"; then + fail "expected log to mention: $1" + fi +} + +# An untagged tip of main is the ordinary automatic-release path: the version +# comes from Conventional Commit intent since the last stable tag. Filtering +# tags with `grep` used to abort the step here, because no match under +# `pipefail` looks like a command failure. +new_case 'automatic release from an untagged commit' +git_repo tag v1.2.3 +commit api/runtime.ts repaired 'fix: repair execution' +publish_main +resolve EVENT_NAME=workflow_run HEAD_SHA="$(head_sha)" +expect_status 0 +expect_output skip false +expect_output version v1.2.4 +expect_output app_version 2.0.0 +expect_output chart_version 0.3.1 +expect_output prerelease false +expect_output latest true +expect_output draft false + +new_case 'documentation-only range releases nothing' +git_repo tag v1.2.3 +commit docs/guide.md docs 'docs: clarify deployment' +publish_main +resolve EVENT_NAME=workflow_run HEAD_SHA="$(head_sha)" +expect_status 0 +expect_output skip true +expect_output version '' +expect_log 'no release needed' + +new_case 'a repository without a stable tag reports why' +commit api/runtime.ts repaired 'fix: repair execution' +publish_main +resolve EVENT_NAME=workflow_run HEAD_SHA="$(head_sha)" +expect_status 1 +expect_log 'Automatic releases require an existing stable' + +# The rerun-to-publish recovery path: a previous run created the tag and then +# failed before the release existed. +new_case 'a rerun resumes the tag that already points at HEAD' +git_repo tag v1.2.3 +commit api/runtime.ts repaired 'fix: repair execution' +git_repo tag v1.2.4 +publish_main +resolve EVENT_NAME=workflow_run HEAD_SHA="$(head_sha)" PUBLISHED='' +expect_status 0 +expect_output skip false +expect_output version v1.2.4 +expect_output latest true + +new_case 'a published tag at HEAD releases nothing twice' +git_repo tag v1.2.3 +commit api/runtime.ts repaired 'fix: repair execution' +git_repo tag v1.2.4 +publish_main +resolve EVENT_NAME=workflow_run HEAD_SHA="$(head_sha)" PUBLISHED='v1.2.4' +expect_status 0 +expect_output skip true +expect_log 'already publishes this commit' + +new_case 'a calculated tag held by another commit is a collision' +git_repo tag v1.2.3 +git_repo checkout -q -b elsewhere +commit api/runtime.ts diverged 'fix: unrelated work' +git_repo tag v1.2.4 +git_repo checkout -q main +commit api/runtime.ts repaired 'fix: repair execution' +publish_main +resolve EVENT_NAME=workflow_run HEAD_SHA="$(head_sha)" +expect_status 1 +expect_log 'already exists on a different commit' + +new_case 'a calculated tag held by a non-commit object is a collision' +git_repo tag v1.2.3 +blob="$(printf 'not a commit\n' | git_repo hash-object -w --stdin)" +git_repo update-ref refs/tags/v1.2.4 "$blob" +commit api/runtime.ts repaired 'fix: repair execution' +publish_main +resolve EVENT_NAME=workflow_run HEAD_SHA="$(head_sha)" +expect_status 1 +expect_log 'already exists but does not point to a commit' + +new_case 'a stale CI run defers to the newer tip' +git_repo tag v1.2.3 +commit api/runtime.ts repaired 'fix: repair execution' +publish_main +commit api/runtime.ts advanced 'fix: land more work' +resolve EVENT_NAME=workflow_run HEAD_SHA="$(head_sha)" +expect_status 0 +expect_output skip true +expect_log 'main advanced after this CI run' + +new_case 'a dispatched version may omit the v prefix' +git_repo tag v1.2.3 +resolve EVENT_NAME=workflow_dispatch REF_TYPE=branch REF_NAME=main \ + INPUT_VERSION=2.0.0 INPUT_DRAFT=true +expect_status 0 +expect_output version v2.0.0 +expect_output prerelease false +expect_output latest true +expect_output draft true + +new_case 'a release candidate is a prerelease and never latest' +git_repo tag v1.2.3 +resolve EVENT_NAME=workflow_dispatch REF_TYPE=branch REF_NAME=main \ + INPUT_VERSION=v1.3.0-rc1 +expect_status 0 +expect_output version v1.3.0-rc1 +expect_output prerelease true +expect_output latest false + +new_case 'dispatching from a topic branch is refused' +resolve EVENT_NAME=workflow_dispatch REF_TYPE=branch REF_NAME=feature/x \ + INPUT_VERSION=v1.3.0 +expect_status 1 +expect_log 'Releases must be cut from main' + +new_case 'dispatching an existing version is refused' +git_repo tag v1.2.3 +resolve EVENT_NAME=workflow_dispatch REF_TYPE=branch REF_NAME=main \ + INPUT_VERSION=v1.2.3 +expect_status 1 +expect_log 'already exists' + +new_case 'a malformed version is refused' +resolve EVENT_NAME=workflow_dispatch REF_TYPE=branch REF_NAME=main \ + INPUT_VERSION=1.2 +expect_status 1 +expect_log 'Release tags must be' + +new_case 'a pushed older patch tag does not become latest' +git_repo tag v9.9.9 +resolve EVENT_NAME=push REF_TYPE=tag REF_NAME=v1.0.1 +expect_status 0 +expect_output version v1.0.1 +expect_output prerelease false +expect_output latest false + +if [ "$FAILURES" -ne 0 ]; then + echo "$FAILURES release version resolution assertion(s) failed" >&2 + exit 1 +fi + +echo 'release version resolution tests passed' From ef66feac3727a02feeec7e0b6dea166fd9e2b592 Mon Sep 17 00:00:00 2001 From: trial Date: Fri, 18 Sep 2026 12:27:38 +0200 Subject: [PATCH 2/4] fix(ci): keep the fork release job permanently disabled The fork tracks upstream releases and adds its own commits on top, with deployments pinned by commit SHA, so it never cuts a tag or publishes a release of its own. release.yml is vendored from upstream for merge-sync, but its job now carries if: ${{ false }}, so neither a dispatch nor a stray tag push can create a tag or publish. GitHub requires the on key, so upstream's triggers stay byte-identical; the fork delta is the job condition plus a header note, and restoring upstream behavior is deleting that one condition. docs/fork/patches.md records the policy, its evidence, and the replay and drop condition. --- .github/workflows/release.yml | 16 ++++++--- docs/fork/patches.md | 61 +++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 684f1654..90de20a3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,12 @@ # # Three entry points feed one job: # +# UZH fork: the job is disabled unconditionally. The fork tracks upstream +# releases and adds its own commits on top, with deployments pinned by +# commit SHA, so it never cuts a tag or publishes a release of its own. +# This file stays otherwise byte-identical to upstream so a release merge +# applies cleanly. See docs/fork/patches.md. +# # * successful CI on main — automatically releases deployable changes. The # next repository version follows Conventional Commit intent; changes that # only touch docs, workflows, or tests do not cut a release. @@ -53,11 +59,11 @@ concurrency: jobs: release: name: Tag and publish - if: >- - github.event_name != 'workflow_run' || - (github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' && - github.event.workflow_run.head_branch == 'main') + # UZH fork: disabled unconditionally. Upstream tags and publishes the release + # here; the fork must not create tags or releases, so no trigger can run + # this job. Deleting this condition restores upstream behavior (see + # docs/fork/patches.md). + if: ${{ false }} runs-on: ubuntu-latest timeout-minutes: 30 steps: diff --git a/docs/fork/patches.md b/docs/fork/patches.md index 94ca3a10..1785d5a5 100644 --- a/docs/fork/patches.md +++ b/docs/fork/patches.md @@ -39,6 +39,24 @@ baseline, the v1.1.0 status is recorded in the same section. - Limitation: the fork SHA identifies the pre-integration fork branch; the reconciliation PR records the resulting exact head and GitHub merge SHA. +### Follow-up basis: upstream v1.2.0 release-workflow port (2026-09-18) + +Recorded when the fork ported upstream's release version-resolution fix. Only +the release-automation files were re-checked; the v1.1.0 dispositions above are +unchanged. + +- Fork ref and SHA: `uzh/main` at + `55840f32f3e0204f0f459832d17be33ecfc2c5cc` (PR #24 v1.1.0 merge) +- Upstream ref and SHA: LibreChat-AI/code-interpreter tag `v1.2.0` at + `fd9a4fa65e0a5189957032c0046eb286311fda62` (also `upstream/main`) +- Ported as `b67791e` (cherry-pick): `.github/scripts/resolve-release-version.sh`, + `.github/workflows/release.yml`, and `tests/release-version-resolution.sh` are + byte-identical to `v1.2.0`, and the `ci.yml` release-version-resolution step is + identical. `ci.yml` keeps the fork's own `chmod 0555` line and does not carry + the `#222` and `#227` test steps from trees the fork does not have. +- Delta: the port also picks up upstream `c688b30` (#225), which the fork's + byte-identical-to-v1.1.0 `release.yml` had been missing. + States: Active, Review on sync, Draft, History only, Retired. ## Patch index @@ -55,6 +73,7 @@ States: Active, Review on sync, Draft, History only, Retired. | Bind JWT trust to verified issuers | Active | `f68acf0` | JWT verification keys and issuer configuration | | Keep operational logs values-free | Active | `c87a14d`, `bf83dbe`, `689be7d`, `42a9743` | Winston and Pino logging sinks and public failures | | Preserve requests through telemetry failures | Active | PR #23 (`f5bf3b4`) | OpenTelemetry SDK and the shared telemetry core | +| Never publish fork tags or releases | Active | PR #25 (port `b67791e`, disable `e9f6fa0`) | GitHub Actions events and the ported upstream resolver | ## Publish exact-SHA UZH images @@ -490,6 +509,44 @@ Replay and drop condition: OpenTelemetry construction or lifecycle reaches the request path, with a test matrix covering each fault class. +## Never publish fork tags or releases + +Required behavior: + +- Track upstream releases and add fork commits on top; deployments pin a commit + SHA, so the fork never cuts a tag or publishes a GitHub release of its own. +- Vendor upstream's release automation — `.github/scripts/resolve-release-version.sh`, + `.github/workflows/release.yml`, `tests/release-version-resolution.sh`, and the + `ci.yml` step — so merge-sync stays trivial and the ported resolver stays tested. +- Keep the vendored `release.yml` inert: its job carries `if: ${{ false }}`, so no + trigger can create a tag or release. + +Owned paths: + +- `.github/workflows/release.yml` (the job condition and the header note only; + every other line, including upstream’s triggers, tracks `v1.2.0` + byte-for-byte) + +Source and current-upstream evidence: + +- Upstream `fd9a4fa` (tag `v1.2.0`, PR #233) fixed #228 (untagged abort) and #229 + (rerun-resume rejected its own tag) by extracting the resolver into + `.github/scripts/resolve-release-version.sh`; ported verbatim as `b67791e`. +- GitHub's workflow schema requires the `on` key + (`json.schemastore.org/github-workflow.json`), so a trigger-less `release.yml` + is not valid; the disable is a job condition rather than an empty `on` block. +- The fork has no tags or releases on `origin` (`git ls-remote --tags` empty). + Nothing consumes fork releases: df-cloud pins chart revision + `c1509a88a3189aaf666fe9409ec0c9c539f30c1d` and images by commit SHA from + `ghcr.io/uzh-bf/code-interpreter/*`. + +Replay and drop condition: + +- Replay by restoring upstream's job `if:` condition and the `workflow_run` + trigger block. +- Drop only on a deliberate policy change: if the fork starts cutting its own + tags and releases, remove this row and the disable. + ## Retired debris - Merge commit `356123a` is history-only transport for the package-init fix; @@ -517,3 +574,7 @@ Replay and drop condition: by PR #23. The auto-merged paths (Helm templates, `values.yaml`, `ci.yml`, `egress-ledger.ts`, logger sinks) were verified against v1.1.0 rather than replayed; no fork-authored final-tree path is left unowned after the merge. +- v1.2.0 release-workflow coverage: the port adds upstream files that are not + fork patches, and the disable adds one fork-owned path, + `.github/workflows/release.yml`, recorded above. No other fork path changed in + this port. From 56e7ea2838814b23c5e24863ed3e09d23c4b9988 Mon Sep 17 00:00:00 2001 From: trial Date: Fri, 18 Sep 2026 12:27:38 +0200 Subject: [PATCH 3/4] docs(project): record the release-workflow follow-up and PR #25 Records the post-merge Release failure on 55840f3, upstream's #228/#229 fix in PR #233 (fd9a4fa, v1.2.0), the never-publish policy for the tagless fork, and the one remaining gate (merging PR #25). --- ...-09-17-upstream-v1.1.0-integration-plan.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/docs/project/2026-09-17-upstream-v1.1.0-integration-plan.md b/docs/project/2026-09-17-upstream-v1.1.0-integration-plan.md index 543cd194..7ec1a5d5 100644 --- a/docs/project/2026-09-17-upstream-v1.1.0-integration-plan.md +++ b/docs/project/2026-09-17-upstream-v1.1.0-integration-plan.md @@ -167,3 +167,39 @@ Separately gated (asked at the end, not now): merge into main; closing dependabo merge into main; close dependabot PRs #4/#14/#16/#22; PR #21 re-derivation; image publication; GitOps/deployment promotion; deleting this branch or its worktree. + +## Post-merge follow-up: release workflow (2026-09-18) + +PR #24 merged as `55840f3`. CI on main (run `35262795878`) and the image build +(run `35262795973`, seven images tagged `55840f3`/`main`) are green, but the +`Release` workflow failed on `55840f3` (run `35266235871`): the step *Resolve and +validate version* exited 1 before any later step ran. + +- Root cause: the fork's `release.yml` was byte-identical to upstream v1.1.0, + whose inline resolver ran `git tag --points-at HEAD | grep …` under + `set -euo pipefail`; a no-match `grep` exit 1 aborted the step on an untagged + main. Upstream filed #228 and #229, and fixed both in PR #233 (`fd9a4fa`, + released as `v1.2.0`) by extracting `.github/scripts/resolve-release-version.sh` + with the `tests/release-version-resolution.sh` suite. +- The fix alone leaves the fork red: it has never cut a stable tag on `origin` + (zero tags, zero releases), so the resolver refuses an untagged main; nothing + consumes fork releases (df-cloud pins chart revision `c1509a88` and UZH GHCR + images). +- Disposition: draft PR #25 on `rs/fix-release-version-resolution` ports the + fix as cherry-pick `b67791e` (release files byte-identical to `v1.2.0`, plus + the `ci.yml` step and upstream's #225 tip check) and records the fork policy + in `docs/fork/patches.md`. +- Verification at code head `7c5024d`: `tests/release-version-resolution.sh` + (14 cases) and `tests/release-versioning.sh` pass locally; `release.yml` + parses as YAML with its `workflow_dispatch` and `push: tags` triggers intact + and a job condition of `if: ${{ false }}`; the ported resolver files verify + byte-identical to `v1.2.0`; PR #25’s checks run on the pushed head. +- Policy (2026-09-18): the fork tracks upstream releases and adds its own + commits on top, with deployments pinned by commit SHA; it never cuts its own + tags or publishes its own releases. `release.yml` stays vendored for + merge-sync but its job carries `if: ${{ false }}`, so no trigger can publish. + GitHub requires the `on` key, so the triggers remain in the file. +- Gated, not executed: merging PR #25. +- Upstream `v1.2.0` (`fd9a4fa`) is now the latest stable release, carrying + #222, #227 and #233; it is a candidate for the next integration round, not + started here. From 046bf7fbb6afabd504ce6a3197e48ad57f6bc0f1 Mon Sep 17 00:00:00 2001 From: trial Date: Fri, 18 Sep 2026 12:36:21 +0200 Subject: [PATCH 4/4] docs(fork): state the never-publish policy where releases are documented docs/RELEASING.md documents cutting a tag by hand, which the disabled Release job cannot prevent, and CONTRIBUTING.md points readers at it. Both now carry a short fork note: the fork tracks upstream releases, pins deployments by commit SHA, and does not cut its own tags or releases. Upstream text is otherwise unchanged. The patch ledger records the note under the release-policy entry's owned paths and evidence, and the integration plan notes the alignment. --- CONTRIBUTING.md | 4 ++++ docs/RELEASING.md | 5 +++++ docs/fork/patches.md | 10 ++++++++-- .../2026-09-17-upstream-v1.1.0-integration-plan.md | 2 ++ 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e7b95797..75029f77 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,6 +32,10 @@ release. Successful `main` CI automatically releases deployable changes while documentation, workflow, and test-only changes are skipped. See [docs/RELEASING.md](docs/RELEASING.md) for the full process and manual path. +> **UZH fork.** The fork does not cut its own tags or releases; it tracks +> upstream releases and pins deployments by commit SHA. See +> [docs/RELEASING.md](docs/RELEASING.md). + ## Development See the [README](README.md) for the architecture overview and diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 3b2ff3ef..ad8dcfc4 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -3,6 +3,11 @@ Deployments should track a tag, not `main`. This document covers how those tags are cut. +> **UZH fork.** The fork does not cut its own tags or releases. It tracks +> upstream releases and adds its own commits on top, and its deployments pin a +> commit SHA. The process below describes upstream’s release flow; in the fork +> the Release job is disabled, so a tag pushed by hand would not publish either. + ## Versioning A release is named `vMAJOR.MINOR.PATCH`, optionally with a `-rcN` suffix for a diff --git a/docs/fork/patches.md b/docs/fork/patches.md index 1785d5a5..486876a0 100644 --- a/docs/fork/patches.md +++ b/docs/fork/patches.md @@ -526,6 +526,8 @@ Owned paths: - `.github/workflows/release.yml` (the job condition and the header note only; every other line, including upstream’s triggers, tracks `v1.2.0` byte-for-byte) +- `docs/RELEASING.md` and `CONTRIBUTING.md` (a fork note at the top of the + release process; upstream text is otherwise unchanged) Source and current-upstream evidence: @@ -535,10 +537,14 @@ Source and current-upstream evidence: - GitHub's workflow schema requires the `on` key (`json.schemastore.org/github-workflow.json`), so a trigger-less `release.yml` is not valid; the disable is a job condition rather than an empty `on` block. -- The fork has no tags or releases on `origin` (`git ls-remote --tags` empty). - Nothing consumes fork releases: df-cloud pins chart revision +- The fork has no tags or releases on `origin` (`git ls-remote --tags` empty), + and nothing consumes fork releases: df-cloud pins chart revision `c1509a88a3189aaf666fe9409ec0c9c539f30c1d` and images by commit SHA from `ghcr.io/uzh-bf/code-interpreter/*`. +- The vendored `docs/RELEASING.md` documents cutting a tag by hand + (`git tag -a … && git push origin …`), which the disabled job cannot stop, so + both it and `CONTRIBUTING.md` carry a fork note saying the fork does not cut + tags or releases. Replay and drop condition: diff --git a/docs/project/2026-09-17-upstream-v1.1.0-integration-plan.md b/docs/project/2026-09-17-upstream-v1.1.0-integration-plan.md index 7ec1a5d5..64535918 100644 --- a/docs/project/2026-09-17-upstream-v1.1.0-integration-plan.md +++ b/docs/project/2026-09-17-upstream-v1.1.0-integration-plan.md @@ -199,6 +199,8 @@ validate version* exited 1 before any later step ran. tags or publishes its own releases. `release.yml` stays vendored for merge-sync but its job carries `if: ${{ false }}`, so no trigger can publish. GitHub requires the `on` key, so the triggers remain in the file. + `docs/RELEASING.md` and `CONTRIBUTING.md` carry a matching fork note, since the + hand-cut tag path they document is outside the workflow’s control. - Gated, not executed: merging PR #25. - Upstream `v1.2.0` (`fd9a4fa`) is now the latest stable release, carrying #222, #227 and #233; it is a candidate for the next integration round, not