Skip to content

fix(ci): stop the nightly release job failing on an immutable tag - #661

Merged
logbie merged 1 commit into
mainfrom
warden/fix-nightly-immutable-release-tag
Jul 30, 2026
Merged

fix(ci): stop the nightly release job failing on an immutable tag#661
logbie merged 1 commit into
mainfrom
warden/fix-nightly-immutable-release-tag

Conversation

@logbie

@logbie logbie commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

What was broken

Nightly Build run 30528566163 (manual dispatch, 2026-07-30 08:55 UTC, main 579eb806) failed. All three build jobs were green — Check for changes, Build WFL for Linux (static musl), and Build WFL for Windows (25m35s, through MSI packaging and the installer smoke test). The Create or Update Nightly Release job died at Tag commit for nightly:

Deleting existing remote tag: nightly-2026-07-30
remote: error: GH013: Repository rule violations found for refs/tags/nightly-2026-07-30.
remote: - Cannot delete this tag
##[warning]Failed to delete remote tag nightly-2026-07-30, but continuing ...
Creating new tag: nightly-2026-07-30
 ! [rejected]        nightly-2026-07-30 -> nightly-2026-07-30 (already exists)
##[error]Process completed with exit code 1

Publish or update nightly release was skipped, so a fully successful build published nothing.

Root cause

This repository has GitHub immutable releases enabled — GET /releases/tags/nightly-2026-07-30 reports "immutable": true. Once a release is published, its tag is permanently protected against deletion and movement. The release job was built on a delete-then-recreate model that this makes impossible:

  1. Tag commit for nightly deletes the remote tag, then recreates and pushes it. The delete is refused (GH013); the refusal is downgraded to a warning and execution continues; the push then fails hard with "already exists".
  2. Publish or update nightly release does the same thing one level up — gh release delete "$TAG" --yes 2>/dev/null || true followed by gh release create "$TAG". An immutable release cannot be deleted either, so the delete is silently discarded and the create would fail because the release already exists. Despite the step's name it never updated anything; it only ever recreated. This is a latent second failure that today's run never reached because step 1 died first.

So any nightly run that happens after a release already exists for the same calendar date fails. In practice that means every manual re-dispatch on a day the scheduled nightly already published. It has not bitten before now only by luck: the 2026-07-29 re-dispatch worked because that day's scheduled nightly failed before tagging (the musl portability gate, fixed in #657), so no tag existed. Today's scheduled run succeeded and published at 06:18, and the 08:55 dispatch hit the wall.

The fix

.github/workflows/nightly.yml only, two steps in the release job. No Rust code, no other job touched.

Tag commit for nightly — never deletes; is idempotent instead:

  • tag absent → create and push (unchanged behaviour, the normal daily path);
  • tag already points at this commit → no-op, log and continue;
  • tag points elsewhere → try git push --force, and if that is refused, warn and continue rather than fail.

Annotated tags are resolved through the ^{} line of git ls-remote so the comparison is against the tagged commit, with a fallback for a lightweight tag.

Publish or update nightly release — checks before it replaces:

  • looks up the release for the tag; if it exists and is immutable, warn and exit 0 rather than attempt an impossible delete-and-recreate;
  • if it exists and is still mutable, delete and recreate exactly as before;
  • if it does not exist, create exactly as before.

Asset collection and validation moved above that check so the "empty file" and "no artifacts" errors still fire. Release notes, flags, and asset handling are byte-identical.

The deliberate judgement call: on a same-day re-run against an already-published immutable release the job now succeeds with a warning instead of failing. The build genuinely is good, and Publish artifacts to DigitalOcean Spaces — which runs earlier and is unaffected — has already refreshed the canonical CDN copies that the release notes point at. Only the case "release exists and is immutable" is tolerated; anything else still fails loudly.

Verification

This is a pure CI-mechanics change with no Rust behaviour to test, so per AGENTS.md the existing checks are the test. What was verified locally:

  • YAML parses, and a structural diff against main confirms step counts are unchanged in all four jobs (5 / 21 / 10 / 9), the check-for-changes, build, and build-linux jobs are byte-identical, and triggers are unchanged. Only the two named steps differ.
  • bash -n clean on both step scripts.
  • Both scripts exercised against stubbed git/gh covering every branch:
    • tag step, 8 cases (tag absent / at HEAD / elsewhere / lightweight, each with force-push succeeding and refused) — all exit 0 with the correct action, including the exact case that broke this run (tag elsewhere + push refused);
    • release step, 4 cases (release absent → creates; immutable → warns and skips without calling gh release create; mutable → deletes and recreates; no artifacts → still ::error:: and exit 1).
  • The git ls-remote parsing was checked against the real repository, not just stubs: refs/tags/nightly-2026-07-30 resolves to tag object ab3166a6 and commit 1bb02f2a, which is the commit the 05:57 scheduled nightly built.

Not verified: an actual Actions run. That is what this PR's CI is for. The most useful post-merge check is to dispatch Nightly Build from main on a day the scheduled run has already published — the previously failing path — and confirm the release job reports the skip warning and goes green.


Automated triage PR from the WFL repo warden — please review and merge; the warden does not merge its own PRs.


Open in Devin Review

Summary by CodeRabbit

  • Bug Fixes
    • Improved nightly release handling to safely rerun without failing when tags or releases already exist.
    • Added compatibility with immutable or protected nightly tags and releases.
    • Preserved successful workflow completion when an existing immutable release cannot be replaced.

GitHub immutable releases are enabled on this repository, so the tag of a
published release can never be deleted or moved. The release job's
delete-then-recreate flow therefore could not succeed once a release existed
for the same date: the tag delete was refused with GH013 "Cannot delete this
tag", the warning was swallowed, and the subsequent push died on "already
exists", failing the job with every build green.

Make tagging idempotent (reuse a tag that already points at this commit, try to
move one that does not, tolerate a refusal) and only replace a release that is
still mutable, skipping with a warning when it is immutable.

Fixes the failure in run 30528566163.
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The nightly workflow now handles existing nightly tags idempotently and avoids deleting immutable GitHub Releases. Mutable releases are deleted before replacement, while blocked tag moves and immutable releases allow the workflow to complete successfully.

Changes

Nightly release lifecycle

Layer / File(s) Summary
Idempotent nightly tag handling
.github/workflows/nightly.yml
The workflow compares the remote nightly tag with the current commit, creates or retargets it when possible, and tolerates blocked moves.
Immutable-aware release publishing
.github/workflows/nightly.yml
The workflow checks the release immutability state, skips immutable releases, and deletes mutable releases before replacement.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant GitHubActions
  participant GitRemote
  participant GitHubRelease
  participant DigitalOceanSpaces

  GitHubActions->>GitRemote: Query nightly tag and current commit SHA
  GitRemote-->>GitHubActions: Return tag target
  GitHubActions->>GitRemote: Create or force-update nightly tag
  GitHubActions->>GitHubRelease: Query release by nightly tag
  GitHubRelease-->>GitHubActions: Return release id and immutable flag
  GitHubActions->>GitHubRelease: Delete mutable release before recreation
  GitHubActions->>DigitalOceanSpaces: Use Spaces for immutable-release artifacts
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly captures the main change: making the nightly release job tolerate immutable tags and releases instead of failing.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch warden/fix-nightly-immutable-release-tag

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

Open in Devin Review

Comment on lines +766 to +775
RELEASE_ID=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" -q '.id' 2>/dev/null || true)
if [ -n "$RELEASE_ID" ]; then
IMMUTABLE=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" -q '.immutable' 2>/dev/null || true)
if [ "$IMMUTABLE" = "true" ]; then
echo "::warning::Release $TAG is already published and immutable, so it cannot be replaced. Skipping the GitHub release step; this run's artifacts are on DigitalOcean Spaces, which the release notes already name as the canonical download location."
exit 0
fi
echo "Replacing the existing (mutable) release $TAG."
gh release delete "$TAG" --yes 2>/dev/null || true
fi

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Immutability detection depends on the .immutable field being present and equal to "true"

The skip decision hinges entirely on gh api ... -q '.immutable' returning the literal string true. If the field is absent or the second gh api call fails transiently, IMMUTABLE is empty/null and the script falls through to gh release delete (silenced by || true) followed by gh release create, which is exactly the original failing sequence (::error:: already exists). A single API call reused for both .id and .immutable (e.g. gh api ... -q '.id + " " + (.immutable|tostring)') would remove the second round-trip and the possibility of the two reads disagreeing. Note also that on a 404 gh may print null for -q '.id', making RELEASE_ID non-empty; that path is harmless because IMMUTABLE is then also null and the delete-then-create behaves as before.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1da712bdb1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +724 to +728
if git push --force origin "refs/tags/$TAG"; then
echo "Moved $TAG to $SHA."
else
echo "::warning::Could not move $TAG; it is protected by an immutable release. Leaving the published tag at $TAGGED."
fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fail when the tag push is rejected for other reasons

In the checked Tag commit for nightly step, this branch treats every nonzero result from git push --force as immutable-release protection, even though git push -h only defines --force as “force updates” and the command can also fail because of authentication, connectivity, hooks, or another tag rule. If such a failure occurs and the subsequent GitHub API calls succeed, the next step can create or replace a mutable release using the old remote tag while uploading artifacts and notes for ${{ github.sha }}, producing contradictory release provenance while the job reports success. Verify that the existing release is immutable before suppressing the push error; otherwise propagate the failure.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
.github/workflows/nightly.yml (1)

730-735: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

TAG is recomputed independently of the tagging step's DATE.

This step derives TAG via a fresh date +'%Y-%m-%d' call (line 735) rather than reusing the value computed in "Tag commit for nightly" (lines 688-690). If the job happens to straddle a UTC midnight between the two steps, the release step would check/replace a different tag than the one just created/moved, breaking the very idempotency contract this PR establishes.

♻️ Suggested fix: compute the date/tag once and share it
       - name: Tag commit for nightly
+        id: tag
         run: |
           DATE=$(date +'%Y-%m-%d')
           TAG="nightly-${DATE}"
+          echo "tag=$TAG" >> "$GITHUB_OUTPUT"
           ...
       - name: Publish or update nightly release
         env:
           VERSION:   ${{ needs.check-for-changes.outputs.version }}
           SHORT_SHA: ${{ needs.check-for-changes.outputs.short_sha }}
+          TAG:       ${{ steps.tag.outputs.tag }}
         run: |
-          TAG="nightly-$(date +'%Y-%m-%d')"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/nightly.yml around lines 730 - 735, Update the nightly
workflow so the “Publish or update nightly release” step reuses the date or tag
produced by the “Tag commit for nightly” step instead of invoking date again.
Preserve the existing nightly tag format and ensure both tagging and release
publishing reference the same shared value across a UTC midnight.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/nightly.yml:
- Around line 766-775: The release lookup logic around RELEASE_ID must
distinguish a missing release from authentication, rate-limit, transient, or
other API failures. Remove the unconditional suppression from the gh api calls,
preserve the lookup response for checking .id and .immutable, and fail the
workflow for any non-404 lookup error; retain the existing immutable skip and
mutable-release deletion behavior, while allowing only a confirmed “not found”
result to proceed to release creation.
- Around line 701-708: Update the REFS lookup in the nightly tag flow to
preserve and check the exit status of git ls-remote separately from its output.
Only create and push the tag when the query succeeds with no matching refs; when
the command fails due to communication, authentication, or another error, stop
the workflow and report the failure instead of treating it as a missing tag.

---

Nitpick comments:
In @.github/workflows/nightly.yml:
- Around line 730-735: Update the nightly workflow so the “Publish or update
nightly release” step reuses the date or tag produced by the “Tag commit for
nightly” step instead of invoking date again. Preserve the existing nightly tag
format and ensure both tagging and release publishing reference the same shared
value across a UTC midnight.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 99677f97-d89e-410b-ac30-4770838e3401

📥 Commits

Reviewing files that changed from the base of the PR and between 579eb80 and 1da712b.

📒 Files selected for processing (1)
  • .github/workflows/nightly.yml

Comment on lines +701 to +708
REFS=$(git ls-remote --tags origin "refs/tags/$TAG" "refs/tags/$TAG^{}" || true)

if [ -z "$REFS" ]; then
echo "No existing remote tag $TAG; creating it at $SHA."
git tag -f -a "$TAG" -m "Nightly build $DATE"
git push origin "refs/tags/$TAG"
exit 0
fi

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== workflow context =="
if [ -f .github/workflows/nightly.yml ]; then
  nl -ba .github/workflows/nightly.yml | sed -n '670,735p'
else
  echo "myfile not found"
fi

echo
echo "== git command semantics probe =="
python3 - <<'PY'
import subprocess, textwrap, tempfile, pathlib, os

def run(env=None, check=None, user_env=None):
    cmd = ['git'] + list(env if env else [])
    merged = os.environ.copy()
    if user_env:
        merged.update(user_env)
    merged.pop('GIT_SSH_COMMAND', None)
    merged.pop('GIT_ASKPASS', None)
    res = subprocess.run(cmd, capture_output=True, text=True, check=False, env=merged)
    return res.returncode, '\n'.join(res.stdout.splitlines()[:3]), res.stderr.splitlines()[:3]

print("local repo ls-remote exit:", "success" if run(['ls-remote', '--tags'], user_env={'GIT_SSH_COMMAND':'true'})[0]==0 else "fail")

print("repoless exact pattern exits non-zero?", end=" ")
rc = subprocess.run(['git','ls-remote','--tags','origin','refs/tags/nightly-2026-07-20'], text=True).returncode
print("non-zero" if rc else "zero")

print("repoless two patterns exits non-zero?", end=" ")
rc = subprocess.run(['git','ls-remote','--tags','origin','refs/tags/nightly-2026-07-20','refs/tags/nightly-2026-07-20^{}'], text=True).returncode
print("non-zero" if rc else "zero")

PY

Repository: WebFirstLanguage/wfl

Length of output: 221


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate workflow =="
git ls-files .github/workflows/nightly.yml || true

if [ -f .github/workflows/nightly.yml ]; then
  echo "== workflow lines 660-740 =="
  awk 'NR>=660 && NR<=740 {printf "%6d\t%s\n", NR, $0}' .github/workflows/nightly.yml
fi

echo
echo "== git command semantics probe =="
python3 - <<'PY'
import subprocess, os

print("git_lsremote_no_repo_exact_exit_nonzero:", subprocess.run(["git","ls-remote","--tags","origin","refs/tags/DNE-EXACT"], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE).returncode != 0)
print("git_lsremote_no_repo_two_patterns_exit_nonzero:", subprocess.run(["git","ls-remote","--tags","origin","refs/tags/DNE-EXACT","refs/tags/DNE-EXACT^{}"], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE).returncode != 0)
PY

Repository: WebFirstLanguage/wfl

Length of output: 4790


🌐 Web query:

Git documentation git ls-remote exit code remote errors pattern no match

💡 Result:

By default, the git ls-remote command returns an exit code of 0 if it successfully communicates with the remote repository, regardless of whether any references (refs) match your provided patterns [1][2]. To distinguish between successful communication and the absence of matching references, you must use the --exit-code flag [1][3]. When --exit-code is provided, the command will exit with status 2 if no matching references are found [1][4][2]. Regarding pattern matching, git ls-remote interprets patterns as glob patterns [2][5]. These patterns are matched against the tail of a reference name, meaning they either match the full name (e.g., refs/heads/foo) or match a component starting after a slash separator (e.g., bar matches refs/heads/bar but not refs/heads/foobar) [2][6]. If you provide multiple patterns, the command will display any reference that matches at least one of the specified patterns [2][6].

Citations:


Distinguish git ls-remote communication failures from a missing nightly tag.

At line 701, REFS=$(...) || true only turns real command failures into “no tag found,” which can lead to pushing a new tag instead of treating a query/auth/network failure as the real result.

Suggested fix
-          REFS=$(git ls-remote --tags origin "refs/tags/$TAG" "refs/tags/$TAG^{}" || true)
+          if ! REFS=$(git ls-remote --exit-code --tags origin "refs/tags/$TAG"); then
+            echo "::error::No remote tag $TAG found; aborting rather than creating one."
+            exit 1
+          fi
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/nightly.yml around lines 701 - 708, Update the REFS lookup
in the nightly tag flow to preserve and check the exit status of git ls-remote
separately from its output. Only create and push the tag when the query succeeds
with no matching refs; when the command fails due to communication,
authentication, or another error, stop the workflow and report the failure
instead of treating it as a missing tag.

Comment on lines +766 to +775
RELEASE_ID=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" -q '.id' 2>/dev/null || true)
if [ -n "$RELEASE_ID" ]; then
IMMUTABLE=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" -q '.immutable' 2>/dev/null || true)
if [ "$IMMUTABLE" = "true" ]; then
echo "::warning::Release $TAG is already published and immutable, so it cannot be replaced. Skipping the GitHub release step; this run's artifacts are on DigitalOcean Spaces, which the release notes already name as the canonical download location."
exit 0
fi
echo "Replacing the existing (mutable) release $TAG."
gh release delete "$TAG" --yes 2>/dev/null || true
fi

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## files"
git ls-files | rg '(^|/)(nightly\.yml|.*workflow.*\.ya?ml)$' || true

echo
echo "## nightly workflow relevant section"
if [ -f .github/workflows/nightly.yml ]; then
  wc -l .github/workflows/nightly.yml
  sed -n '740,795p' .github/workflows/nightly.yml | cat -n
fi

echo
echo "## search gh release create/delete/api around workflows"
rg -n 'gh (api|release delete|release create)|REMOVE_GITHUB_RELEASE|DELETE.*RELEASE|TAG=' .github/workflows/nightly.yml || true

Repository: WebFirstLanguage/wfl

Length of output: 4326


Don’t swallow release-lookup failures before creating the release.

2>/dev/null || true on the gh api calls masks 401/403/ratelimit/transient errors the same way as “release does not exist,” leaving RELEASE_ID empty. If an existing release is actually present but lookup fails, the immutable/mutable checks are skipped and the later gh release create can fail with “release already exists.” Fail fast for lookup/delete failures that are not “not found,” and preserve the response only so the immutable field can be checked.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/nightly.yml around lines 766 - 775, The release lookup
logic around RELEASE_ID must distinguish a missing release from authentication,
rate-limit, transient, or other API failures. Remove the unconditional
suppression from the gh api calls, preserve the lookup response for checking .id
and .immutable, and fail the workflow for any non-404 lookup error; retain the
existing immutable skip and mutable-release deletion behavior, while allowing
only a confirmed “not found” result to proceed to release creation.

@logbie
logbie merged commit 63c79ce into main Jul 30, 2026
23 checks passed
@logbie
logbie deleted the warden/fix-nightly-immutable-release-tag branch July 30, 2026 09:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant