From 60ae6f1f759243e008b45fd9e01e192df9863e8d Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 4 Aug 2026 10:14:47 -0500 Subject: [PATCH] fix(ci): preflight release credentials and recover from an orphan tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit servuo-plugins hit both of these on its first real release run; this repo runs the same engine, so it has the same two defects latent. REGISTRY_USER / REGISTRY_TOKEN were empty there, yet the tag push SUCCEEDED: actions/checkout leaves an `http..extraheader` credential in the local git config, so `git remote set-url` to a URL with empty credentials still authenticated through that leftover header. The release API call had no such fallback and returned 401. Net result was the worst available outcome — the repo tagged, no release, and a failed job. Two fixes: A credential preflight, before anything is built or pushed, gated on the run actually intending to publish so a docs:/chore:-only merge (or this repo's pre-crate no-op) still passes on a repo with no secrets. It names the missing secrets and the scope they need instead of failing wherever they happen to be used first. Orphan-tag recovery. A tag with no release behind it means an earlier run died after tagging, and the old code treated any existing tag as "nothing to release" — so that state could never clear itself: every later run would see the tag and stand down, forever. The plan step now asks the API whether a release exists for the tag, and on 404 reuses the tag and publishes the release it is missing. This deliberately overrides the RELEASE=false the bump logic just decided, which is the whole point — with the tag in place there are no releasable commits after it. Anything other than 200/404 (network failure, bad token) is refused rather than guessed, since assuming "no release" would republish over a good one. The tag step now reuses an existing tag instead of failing on `git tag`, and the changelog for a recovery run summarizes what the tag contains (previous-tag..this-tag) rather than the empty range after it. sync-project-tree gets the same preflight: its first run on main failed with an opaque `git clone` error against `https://:@host/...` that said nothing about a missing secret. Verified by extracting every run block and exercising the paths: empty secrets fail the preflight with a legible message and populated ones pass; the no-Cargo.toml guard still short-circuits to release=false; a crate with no tag still takes the seed path; and against real repo state, a tag with a release stands down while an orphan tag recovers. Co-Authored-By: Claude --- .gitea/workflows/release.yml | 104 ++++++++++++++++++++++--- .gitea/workflows/sync-project-tree.yml | 18 +++++ 2 files changed, 110 insertions(+), 12 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index a7b3ef9..bd85c6a 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -79,6 +79,8 @@ jobs: # ── RELEASE ENGINE: decide the next version + changelog ────────────── - name: Plan the release (version + changelog) id: plan + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -euo pipefail mkdir -p dist @@ -124,21 +126,60 @@ jobs: VERSION="$(bump "${LAST_TAG#v}" "$BUMP")" fi + # An existing tag is NOT automatically "nothing to do". A tag with no + # release behind it means a previous run tagged and then died before + # publishing — which is exactly what happened on servuo-plugins' first + # release, where absent REGISTRY_* secrets took the release API call to + # 401 after the tag had already been pushed. Standing down on the tag + # alone makes that state permanent: every later run sees the tag, sets + # RELEASE=false, and the release never appears. Note this deliberately + # OVERRIDES the RELEASE=false decided just above — with the tag in + # place there are no releasable commits after it, so the normal path + # would stand down, which is exactly why it could never self-heal. + REUSE_TAG=false if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then - echo "Tag v${VERSION} already exists — nothing to release." - RELEASE=false + REL_HTTP="$(curl -s -o /dev/null -w '%{http_code}' \ + -H "Authorization: token $(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" \ + "https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/v${VERSION}" || echo 000)" + if [ "$REL_HTTP" = "200" ]; then + echo "Tag v${VERSION} already has a release — nothing to do." + RELEASE=false + elif [ "$REL_HTTP" = "404" ]; then + echo "::warning::Tag v${VERSION} exists but has no release — a previous run failed after tagging. Reusing the tag and publishing the release it is missing." + REUSE_TAG=true + RELEASE=true + else + # Anything else (000 from a network failure, 401/403 from a bad + # token) is not evidence of absence. Guessing "no release" would + # re-publish over a good one, so refuse instead. + echo "::error::Could not determine whether a release exists for v${VERSION} (HTTP ${REL_HTTP}). Refusing to guess." + exit 1 + fi fi + # Changelog range. A recovery run has nothing after the tag, so + # summarize what the tag itself contains rather than emitting an empty + # list: the range that produced it, i.e. previous-tag..this-tag. + if [ "$REUSE_TAG" = true ]; then + PREV_TAG="$(git describe --tags --match 'v*' --abbrev=0 "v${VERSION}^" 2>/dev/null || true)" + if [ -n "$PREV_TAG" ]; then CL_RANGE="${PREV_TAG}..v${VERSION}"; else CL_RANGE="v${VERSION}"; fi + SINCE="$PREV_TAG" + else + CL_RANGE="$RANGE" + SINCE="$LAST_TAG" + fi + CL_SUBJECTS="$(git log --no-merges --format='%s' $CL_RANGE || true)" + { echo "## ${BIN} v${VERSION}" echo - FEATS="$(echo "$SUBJECTS" | grep -E '^feat' || true)" - FIXES="$(echo "$SUBJECTS" | grep -E '^(fix|perf)' || true)" + FEATS="$(echo "$CL_SUBJECTS" | grep -E '^feat' || true)" + FIXES="$(echo "$CL_SUBJECTS" | grep -E '^(fix|perf)' || true)" [ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; } [ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; } echo "### All changes" - if [ -n "$LAST_TAG" ]; then echo "Since ${LAST_TAG}:"; fi - echo "$SUBJECTS" | sed 's/^/- /' + if [ -n "$SINCE" ]; then echo "Since ${SINCE}:"; fi + echo "$CL_SUBJECTS" | sed 's/^/- /' echo echo "### Verifying this download" echo @@ -155,11 +196,41 @@ jobs: echo "Windows will show a SmartScreen \"unrecognized app\" prompt; this is expected for unsigned binaries." } > dist/CHANGELOG.md - echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT" - echo "release=${RELEASE}" >> "$GITHUB_OUTPUT" - echo "bump=${BUMP}" >> "$GITHUB_OUTPUT" - echo "==> release=${RELEASE} version=${VERSION} bump=${BUMP} last_tag=${LAST_TAG:-}" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT" + echo "release=${RELEASE}" >> "$GITHUB_OUTPUT" + echo "bump=${BUMP}" >> "$GITHUB_OUTPUT" + echo "reuse_tag=${REUSE_TAG}" >> "$GITHUB_OUTPUT" + echo "==> release=${RELEASE} version=${VERSION} bump=${BUMP} reuse_tag=${REUSE_TAG} last_tag=${LAST_TAG:-}" + + # ── Credential preflight ───────────────────────────────────────────── + # Runs BEFORE anything is built or pushed, and only when this run intends + # to publish, so a docs:/chore:-only merge (or the pre-crate no-op) stays + # green on a repo with no secrets. + # + # Learned from servuo-plugins' first release: REGISTRY_USER and + # REGISTRY_TOKEN were empty, but the tag push SUCCEEDED anyway, because + # actions/checkout leaves an `http..extraheader` credential in the + # local git config — so `git remote set-url` to a URL with empty + # credentials still authenticated through that leftover header. The + # release API call had no such fallback and 401'd, leaving the repo tagged + # but unreleased. Checking up front makes that an immediate, legible + # failure instead of a half-published release. + - name: Verify release credentials are configured + if: ${{ steps.plan.outputs.release == 'true' }} + env: + REGISTRY_USER: ${{ secrets.REGISTRY_USER }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + MISSING="" + [ -n "$(printf '%s' "${REGISTRY_USER:-}" | tr -d '\r\n')" ] || MISSING="${MISSING} REGISTRY_USER" + [ -n "$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" ] || MISSING="${MISSING} REGISTRY_TOKEN" + if [ -n "$MISSING" ]; then + echo "::error::Missing Actions secret(s):${MISSING}. Set them under Settings → Actions → Secrets on ${REPO}. REGISTRY_TOKEN needs the write:repository scope to push the bump commit, the tag, and create the release." + exit 1 + fi + echo "Release credentials present." # ── RUST ADAPTER: toolchain + cross-compile deps ───────────────────── - name: Install Rust toolchain, Windows target, and MinGW linker @@ -256,7 +327,16 @@ jobs: else echo "Version unchanged (first release) — no bump commit needed." fi - git tag "${TAG}" + # The tag may already exist when finishing a run that died after + # tagging (see the plan step). `git tag` on an existing name fails + # under `set -e`; pushing an identical existing tag is a harmless + # no-op. A push that fails here means the remote tag points somewhere + # else, which SHOULD stop the run. + if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then + echo "Tag ${TAG} already exists — reusing it." + else + git tag "${TAG}" + fi git push origin "${TAG}" # ── RELEASE ENGINE: create the Gitea release + upload assets ───────── diff --git a/.gitea/workflows/sync-project-tree.yml b/.gitea/workflows/sync-project-tree.yml index d4ee48d..4c09097 100644 --- a/.gitea/workflows/sync-project-tree.yml +++ b/.gitea/workflows/sync-project-tree.yml @@ -58,6 +58,24 @@ jobs: echo "----- generated ${DOCS_PATH} -----" cat _sync/PROJECT_TREE.md + # Checked explicitly because the failure mode otherwise is a `git clone` + # against `https://:@host/...`, whose error says nothing about a missing + # secret. This workflow's first run on `main` failed exactly that way. + - name: Verify docs-repo credentials are configured + env: + REGISTRY_USER: ${{ secrets.REGISTRY_USER }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + MISSING="" + [ -n "$(printf '%s' "${REGISTRY_USER:-}" | tr -d '\r\n')" ] || MISSING="${MISSING} REGISTRY_USER" + [ -n "$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" ] || MISSING="${MISSING} REGISTRY_TOKEN" + if [ -n "$MISSING" ]; then + echo "::error::Missing Actions secret(s):${MISSING}. Set them under Settings → Actions → Secrets on ${SELF_REPO}. The token needs read/write on ${DOCS_REPO} to push the branch and open the PR." + exit 1 + fi + echo "Docs-repo credentials present." + - name: Open or update the docs PR if the tree changed env: REGISTRY_USER: ${{ secrets.REGISTRY_USER }}