From fd59a7491259935540fe3786a86fbc9bb720f2e1 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 5 Aug 2026 05:22:59 -0500 Subject: [PATCH 1/3] ci(bundle): recognize a linux-aarch64 link asset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1 of PLAN.md §5.2's four, and it has to be first. Two rules in this job are strict in opposite directions: an unrecognized link asset name fails the run, and a missing REQUIRED platform key fails it too. So the name must be taught before the release that carries it, and the key can only be required after one exists -- requiring it first would fail every bundle for as long as the gap lasts. This is therefore the mapping only. linux-aarch64 is not in REQUIRED yet; step 3 promotes it once a link release actually ships the binary, after which a dropped target reddens CI instead of vanishing silently from every bundle. The compose step needed no change: it builds the asset map from the platform TSV, so a third key costs it nothing. Edited on `main` and deliberately not on `edge`. The compose job runs from `main`, and leaving `edge`'s copy untouched means the eventual cutover merge has nothing to conflict over. Co-Authored-By: Claude --- .gitea/workflows/bundle.yml | 14 ++++++++++++-- bundles/README.md | 4 +++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/bundle.yml b/.gitea/workflows/bundle.yml index ddfa4c4..f8cf20e 100644 --- a/.gitea/workflows/bundle.yml +++ b/.gitea/workflows/bundle.yml @@ -173,13 +173,23 @@ jobs: # Map link's binaries onto platform keys. The pattern is asserted, not # assumed: an unrecognized asset name is a hard failure so that adding - # a target to link's release.yml (aarch64, macOS) surfaces here as a - # red run, rather than being silently dropped from every bundle. + # a target to link's release.yml (macOS, a Windows arm64) surfaces here + # as a red run, rather than being silently dropped from every bundle. + # + # linux-aarch64 is recognized here BEFORE link publishes one + # (PLAN.md §5.2, step 1 of 4). That order is forced by the two rules + # below being strict in opposite directions: an unknown name fails the + # run, and a missing REQUIRED key fails it too. So the name has to be + # taught before the release that carries it, and the key can only be + # required after — requiring it first would fail every bundle for as + # long as the gap lasts. Step 3 promotes it into REQUIRED once a link + # release actually ships the binary. : > work/link-platforms.tsv while IFS="$(printf '\t')" read -r NAME URL; do [ -n "$NAME" ] || continue case "$NAME" in *-linux-x86_64) PLAT=linux-x86_64 ;; + *-linux-aarch64) PLAT=linux-aarch64 ;; *-windows-x86_64.exe) PLAT=windows-x86_64 ;; *) fail "unrecognized link asset '${NAME}' — bundle.yml does not know what platform to file it under. Teach it this name or the bundle would silently omit the asset." ;; esac diff --git a/bundles/README.md b/bundles/README.md index 315c949..bdb62a6 100644 --- a/bundles/README.md +++ b/bundles/README.md @@ -56,9 +56,11 @@ protocol) or to either component's release version. All three move independently "tag": "v1.1.0", "version": "1.1.0", "protocol": 3, - "assets": { // per-platform: the installer runs on both + "assets": { // per-platform: the installer runs on each "linux-x86_64": { "name": "…", "url": "…", "sha256": "…" }, "windows-x86_64": { "name": "…", "url": "…", "sha256": "…" } + // "linux-aarch64" joins these from link's first release built for it; + // bundle.yml already knows the name (PLAN.md §5.2) } }, From 7db58031c77456eac94d4374737972ca90d4809f Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 5 Aug 2026 17:14:57 -0500 Subject: [PATCH 2/3] fix(bundle): publish to a bundles branch, and unbreak the stale check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things, all found by the first compose run that ever had a bundle to write. 1. `main` is protected, so the push was declined by the pre-receive hook -- twice, since the retry rebases and pushes to the same place. Every bundle since v1.1.1 has been composed correctly and thrown away. Bundles now go to a `bundles` branch of their own, at its root, which needs no protection exception and keeps everything the original choice was for: a reviewable diff, a git history of the compat matrix, plain anonymous raw URLs, no credentials on the shard host. The header's claim that this push "needs no new branch-protection exception" was simply false. 2. A `${{ }}` written literally in a shell comment silently disabled the entire stale-component check. The runner scans a step's script for template expressions before running it, fails to parse the empty one, and skips the step WITHOUT failing the job -- so the dispatch that is supposed to fire a component's release workflow has never run once. Reworded, with a warning not to write that token in a comment again. (link/release.yml and this repo's release.yml carry the same bug in their bump-and-tag step; handled separately.) 3. linux-aarch64 is now a REQUIRED platform key, which was step 3 of PLAN.md §5.2 and was waiting on link publishing one. v1.1.1 does, so from here a dropped target reddens this job instead of vanishing from every bundle. The published bundles are materialized into a worktree at `published/`, so the ".2 suffix" scan and the idempotence check read what is actually published rather than a stale copy on main. The branch is created from an empty-tree root commit on first use, so it carries no history that has nothing to do with the compat matrix; it has been seeded already with bundle 2026.08.04, because every bundle is kept forever and the move must not lose the one that exists. bundles/*.json is deleted from main -- it is now a stale copy of data that lives elsewhere, and a wrong "current" is worse than none. The README stays and documents the branch. Verified by running the whole job in a container against a bare repo standing in for the remote: first run creates the branch and publishes both files with all three asset keys, second and third runs report "identical to the published current.json -- nothing to publish" and push nothing, and the stale check now runs and reports both components as having nothing releasable. Co-Authored-By: Claude --- .gitea/workflows/bundle.yml | 128 +++++++++++++++++++++------------ bundles/README.md | 22 ++++-- bundles/bundle-2026.08.04.json | 40 ----------- bundles/current.json | 40 ----------- 4 files changed, 100 insertions(+), 130 deletions(-) delete mode 100644 bundles/bundle-2026.08.04.json delete mode 100644 bundles/current.json diff --git a/.gitea/workflows/bundle.yml b/.gitea/workflows/bundle.yml index f8cf20e..6e0dc4f 100644 --- a/.gitea/workflows/bundle.yml +++ b/.gitea/workflows/bundle.yml @@ -13,15 +13,23 @@ # byte-identical. # # ── Where it is published, and why not as a release ────────────────────────── -# Bundles are COMMITTED to this repo under bundles/: +# Bundles are COMMITTED to this repo, on their own `bundles` branch, at its root: # -# bundles/current.json the bundle the installer uses by default -# bundles/bundle-.json every bundle ever published, kept for --bundle +# current.json the bundle the installer uses by default +# bundle-.json every bundle ever published, kept for --bundle # # so the installer's two fetches are plain anonymous raw URLs on a public repo: # -# https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/main/bundles/current.json -# https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/main/bundles/bundle-2026.08.04.json +# https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/current.json +# https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/bundle-2026.08.04.json +# +# A BRANCH, not `main`, because `main` is protected and this job is unattended: +# the pre-receive hook declines a push from CI, which is not a thing a nightly +# cron can resolve. Publishing to a branch of its own keeps everything the +# original choice was for — a reviewable diff, a git history of the compat +# matrix, plain raw URLs, no auth on the shard host — and needs no protection +# exception. The alternative, whitelisting a scheduled job for pushes to the +# default branch, buys nothing this does not. # # The obvious alternative — one Gitea release per bundle — was rejected because # it collides with this repo's own product. release.yml publishes the installer @@ -30,8 +38,8 @@ # intermittently resolve to a release containing no installer binary. Committing # also gets a reviewable diff and a git history of the compat matrix for free. # -# The push to `main` needs no new branch-protection exception: release.yml's -# version-bump commit already requires REGISTRY_USER to be able to push here. +# `main` is never pushed to by this workflow. (release.yml does not push to it +# either — it tags and lets the release API do the rest.) # # ── Triggers (PLAN.md §7.2) ────────────────────────────────────────────────── # workflow_dispatch — POSTed by link's and servuo-plugins' release workflows @@ -86,14 +94,44 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - # Full history: the push step rebases onto main if release.yml's version - # bump landed while this job was composing, and a depth-1 clone has no - # base to rebase onto. - - name: Check out the bundles directory + # Full history: the publish step rebases onto the bundles branch if another + # run landed while this one was composing, and a depth-1 clone has no base + # to rebase onto. + - name: Check out the repository uses: actions/checkout@v4 with: fetch-depth: 0 + # The published bundles live on their own branch (see the header), so they + # are materialized into a worktree rather than being part of the checkout. + # Everything downstream reads and writes `published/`, which means the + # ".2 suffix" scan and the idempotence check both see what is actually + # published rather than a stale copy on main. + - name: Materialize the bundles branch + run: | + set -euo pipefail + git config user.name "installer-ci" + git config user.email "ci@whitlocktech.com" + # `prune` matters on a re-run in an existing checkout: removing the + # directory leaves the worktree registered, and `worktree add` then + # refuses the path. CI checks out fresh every time, so this only shows + # up when driving the job by hand — which is how it is tested. + rm -rf published + git worktree prune + if git ls-remote --exit-code --heads origin bundles >/dev/null 2>&1; then + git fetch origin bundles + git worktree add -B bundles published origin/bundles + echo "==> bundles branch: $(ls published/*.json 2>/dev/null | wc -l) published bundle(s)" + else + # First run. A root commit with an empty tree gives the worktree a + # branch to sit on without inheriting main's history, which has + # nothing to do with the compat matrix. + EMPTY_TREE="$(git hash-object -t tree /dev/null)" + ROOT="$(git commit-tree "$EMPTY_TREE" -m 'chore(bundle): start the bundles branch')" + git worktree add -B bundles published "$ROOT" + echo "==> bundles branch does not exist yet; it will be created by the first publish" + fi + - name: Install jq and curl run: | set -euo pipefail @@ -176,14 +214,15 @@ jobs: # a target to link's release.yml (macOS, a Windows arm64) surfaces here # as a red run, rather than being silently dropped from every bundle. # - # linux-aarch64 is recognized here BEFORE link publishes one - # (PLAN.md §5.2, step 1 of 4). That order is forced by the two rules + # linux-aarch64 was recognized here one merge BEFORE link published one + # (PLAN.md §5.2, steps 1 and 3). That order was forced by the two rules # below being strict in opposite directions: an unknown name fails the - # run, and a missing REQUIRED key fails it too. So the name has to be - # taught before the release that carries it, and the key can only be - # required after — requiring it first would fail every bundle for as - # long as the gap lasts. Step 3 promotes it into REQUIRED once a link - # release actually ships the binary. + # run, and a missing REQUIRED key fails it too. So the name had to be + # taught before the release that carried it, and the key could only be + # required after — requiring it first would have failed every bundle + # for as long as the gap lasted. link v1.1.1 ships the binary, so the + # key is now required: a dropped target reddens this job instead of + # vanishing from every bundle. : > work/link-platforms.tsv while IFS="$(printf '\t')" read -r NAME URL; do [ -n "$NAME" ] || continue @@ -196,9 +235,9 @@ jobs: printf '%s\t%s\t%s\t%s\n' "$PLAT" "$NAME" "$URL" \ "$(sha256sum "work/link/${NAME}" | cut -d' ' -f1)" >> work/link-platforms.tsv done < work/link/asset-list.tsv - for REQUIRED in linux-x86_64 windows-x86_64; do + for REQUIRED in linux-x86_64 linux-aarch64 windows-x86_64; do grep -q "^${REQUIRED}$(printf '\t')" work/link-platforms.tsv \ - || fail "link release is missing a ${REQUIRED} binary; the installer ships for both" + || fail "link release is missing a ${REQUIRED} binary; the installer ships for all three" done # The overlay release carries exactly one artifact: the tarball. @@ -340,8 +379,8 @@ jobs: # commit a dated duplicate of the same matrix forever. Compare only # what the installer would actually act on. CHANGED=true - if [ -f bundles/current.json ]; then - if jq -S 'del(.bundle, .generated)' bundles/current.json > work/old-content.json \ + if [ -f published/current.json ]; then + if jq -S 'del(.bundle, .generated)' published/current.json > work/old-content.json \ && jq -S '.' work/content.json > work/new-content.json \ && cmp -s work/old-content.json work/new-content.json; then CHANGED=false @@ -350,7 +389,7 @@ jobs: echo "changed=${CHANGED}" >> "$GITHUB_OUTPUT" if [ "$CHANGED" = false ]; then - echo "==> identical to bundles/current.json — nothing to publish." + echo "==> identical to the published current.json — nothing to publish." exit 0 fi @@ -360,15 +399,14 @@ jobs: # always names exactly one matrix and `--bundle` stays reproducible. BASE="$(date -u +%Y.%m.%d)" TAG="$BASE"; N=1 - while [ -f "bundles/bundle-${TAG}.json" ]; do + while [ -f "published/bundle-${TAG}.json" ]; do N=$((N+1)); TAG="${BASE}.${N}" done - mkdir -p bundles jq --arg bundle "$TAG" --arg generated "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ '{ schema: .schema, bundle: $bundle, generated: $generated } + del(.schema)' \ - work/content.json > "bundles/bundle-${TAG}.json" - cp "bundles/bundle-${TAG}.json" bundles/current.json + work/content.json > "published/bundle-${TAG}.json" + cp "published/bundle-${TAG}.json" published/current.json echo "bundle_tag=${TAG}" >> "$GITHUB_OUTPUT" echo "==> composed bundle ${TAG}" @@ -397,9 +435,11 @@ jobs: set -euo pipefail CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" # Warnings go to a FILE, not a step output. The job summary below - # reads it with `cat`; interpolating a multi-line `${{ }}` value into + # reads it with `cat`; interpolating a multi-line template value into # a shell string there would let any character in a commit-derived - # message change what that script does. + # message change what that script does. (Do not write that token + # literally in a comment: the runner parses it, fails, and silently + # skips the whole step.) : > work/stale-warnings.md for pair in "${LINK_REPO}:${{ steps.resolve.outputs.link_tag }}" \ @@ -480,27 +520,25 @@ jobs: # cannot be parsed"). CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')" CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')" - git config user.name "installer-ci" - git config user.email "ci@whitlocktech.com" git remote set-url origin "https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git" - git add bundles + cd published + git add -A git commit -m "chore(bundle): publish ${TAG} (link ${{ steps.resolve.outputs.link_tag }}, overlay ${{ steps.resolve.outputs.overlay_tag }}, protocol ${{ steps.protocol.outputs.protocol }}) [skip ci]" - # The checkout is a detached snapshot of main; push the commit at HEAD - # to the branch the installer reads its raw URLs from. release.yml - # pushes its version-bump commit to the same branch, so losing the - # race is normal rather than exceptional — rebase and retry once - # instead of failing and leaving the bundle unpublished until the - # next cron. Only bundles/ is touched here, so a rebase over a bump - # commit cannot conflict. - if ! git push origin "HEAD:main"; then - echo "::warning::push rejected (main moved during compose) — rebasing and retrying once" - git fetch origin main - git rebase origin/main - git push origin "HEAD:main" + # Two runs can compose at once — a component release dispatches this + # while the nightly cron is mid-flight — so losing the race is normal + # rather than exceptional. Rebase and retry once instead of failing and + # leaving the bundle unpublished until tomorrow. Every file here is a + # bundle nobody else edits, and a bundle tag names exactly one matrix, + # so a rebase cannot conflict. + if ! git push origin bundles; then + echo "::warning::push rejected (the bundles branch moved during compose) — rebasing and retrying once" + git fetch origin bundles + git rebase origin/bundles + git push origin bundles fi - echo "==> published bundles/bundle-${TAG}.json and bundles/current.json" + echo "==> published bundle-${TAG}.json and current.json on the bundles branch" - name: Job summary if: always() diff --git a/bundles/README.md b/bundles/README.md index bdb62a6..4985d99 100644 --- a/bundles/README.md +++ b/bundles/README.md @@ -13,7 +13,10 @@ nightly, so a missed dispatch self-heals. A run that finds nothing changed write See `docs/installer/PLAN.md` §7 for the design. -## Layout +## Where they live: the `bundles` branch + +**The JSON documents are not in this directory.** They are published to a branch of their own, +[`bundles`](https://gitea.whitlocktech.com/RunicGateway/installer/src/branch/bundles), at its root: | File | What it is | |---|---| @@ -24,14 +27,24 @@ Tags are UTC dates — `2026.08.04`. A second bundle on the same day (a sidecar morning, an overlay release in the afternoon) becomes `2026.08.04.2`, so one tag always names exactly one matrix. +**Why a branch rather than `main`.** `main` is protected and this job is unattended: the pre-receive +hook declines a push from CI, which is not something a nightly cron can resolve. A branch of its own +keeps everything the original choice was for — a reviewable diff, a git history of the compat +matrix, plain anonymous raw URLs, no credentials on the shard host — and needs no protection +exception. Whitelisting a scheduled job for pushes to the default branch would buy nothing this does +not. + +This directory keeps the documentation, because that is what belongs on `main`: the branch carries +data, and only data. + ## How the installer fetches these Plain anonymous `GET`s against a public repo. The shard host gets no git and no Gitea credentials (`PLAN.md` §1), so nothing here may require auth: ``` -https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/main/bundles/current.json -https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/main/bundles/bundle-2026.08.04.json +https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/current.json +https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/bundle-2026.08.04.json ``` Bundles are committed rather than published as Gitea releases because this repo's *own* releases are @@ -58,9 +71,8 @@ protocol) or to either component's release version. All three move independently "protocol": 3, "assets": { // per-platform: the installer runs on each "linux-x86_64": { "name": "…", "url": "…", "sha256": "…" }, + "linux-aarch64": { "name": "…", "url": "…", "sha256": "…" }, "windows-x86_64": { "name": "…", "url": "…", "sha256": "…" } - // "linux-aarch64" joins these from link's first release built for it; - // bundle.yml already knows the name (PLAN.md §5.2) } }, diff --git a/bundles/bundle-2026.08.04.json b/bundles/bundle-2026.08.04.json deleted file mode 100644 index 7d99bb5..0000000 --- a/bundles/bundle-2026.08.04.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "schema": 1, - "bundle": "2026.08.04", - "generated": "2026-08-04T16:07:13Z", - "protocol": 3, - "link": { - "repo": "RunicGateway/link", - "tag": "v1.1.0", - "version": "1.1.0", - "protocol": 3, - "assets": { - "linux-x86_64": { - "name": "uo-link-sidecar-linux-x86_64", - "url": "https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v1.1.0/uo-link-sidecar-linux-x86_64", - "sha256": "27d491efda3fc6859dd38da9b2aa3b97b5fdf1dc5fc488a8916bb88b03443ad9" - }, - "windows-x86_64": { - "name": "uo-link-sidecar-windows-x86_64.exe", - "url": "https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v1.1.0/uo-link-sidecar-windows-x86_64.exe", - "sha256": "fbefd886af0355bf128f1f4c65657b772a58b128d32438061adb0069978e0b8f" - } - } - }, - "overlay": { - "repo": "RunicGateway/servuo-plugins", - "tag": "v0.1.1", - "version": "0.1.1", - "commit": "3a52abbd77047e7c94883934533edcfef3ede555", - "protocol": 3, - "servuo": { - "min_version": "57.4", - "patches_verified_against": "57.4" - }, - "asset": { - "name": "runicgateway-overlay-0.1.1.tar.gz", - "url": "https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/releases/download/v0.1.1/runicgateway-overlay-0.1.1.tar.gz", - "sha256": "75dc6d6ce08322b753a30303b3b2df6f15cf1e84658507d97430af44ec4d34d7" - } - } -} diff --git a/bundles/current.json b/bundles/current.json deleted file mode 100644 index 7d99bb5..0000000 --- a/bundles/current.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "schema": 1, - "bundle": "2026.08.04", - "generated": "2026-08-04T16:07:13Z", - "protocol": 3, - "link": { - "repo": "RunicGateway/link", - "tag": "v1.1.0", - "version": "1.1.0", - "protocol": 3, - "assets": { - "linux-x86_64": { - "name": "uo-link-sidecar-linux-x86_64", - "url": "https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v1.1.0/uo-link-sidecar-linux-x86_64", - "sha256": "27d491efda3fc6859dd38da9b2aa3b97b5fdf1dc5fc488a8916bb88b03443ad9" - }, - "windows-x86_64": { - "name": "uo-link-sidecar-windows-x86_64.exe", - "url": "https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v1.1.0/uo-link-sidecar-windows-x86_64.exe", - "sha256": "fbefd886af0355bf128f1f4c65657b772a58b128d32438061adb0069978e0b8f" - } - } - }, - "overlay": { - "repo": "RunicGateway/servuo-plugins", - "tag": "v0.1.1", - "version": "0.1.1", - "commit": "3a52abbd77047e7c94883934533edcfef3ede555", - "protocol": 3, - "servuo": { - "min_version": "57.4", - "patches_verified_against": "57.4" - }, - "asset": { - "name": "runicgateway-overlay-0.1.1.tar.gz", - "url": "https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/releases/download/v0.1.1/runicgateway-overlay-0.1.1.tar.gz", - "sha256": "75dc6d6ce08322b753a30303b3b2df6f15cf1e84658507d97430af44ec4d34d7" - } - } -} From ea7e491ba322eda5f681a7281ca0f71cc18f70bf Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 5 Aug 2026 17:18:42 -0500 Subject: [PATCH 3/3] fix(release): tag only, before the cutover fires this for the first time The same two faults link/release.yml has, in the copy this repo was forked from -- and this one has never run at all, so the cutover would have been its first execution. An empty template expression written literally in a comment makes the runner fail to build the "Commit version bump and push tag" step and skip it WITHOUT failing the job. link carried that for six releases, which is why its Cargo.toml still says 0.1.0 while its tags reach v1.1.1; the tags exist because the release API creates one when it publishes. And the step pushes to main, which is protected -- the bundle job proved that today with `pre-receive hook declined`. A first release must not depend on a write to a protected branch. So the tag is the version, as in servuo-plugins. The version is still written into Cargo.toml before building, so a released binary self-reports correctly; it is simply not committed back. The prerequisites header said `main` must accept a direct push from the CI user. It does not, and it should not; that line is replaced with the reason. Co-Authored-By: Claude --- .gitea/workflows/release.yml | 47 +++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index bd85c6a..47ce699 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -39,11 +39,11 @@ # Prerequisites (Settings → Actions → Secrets on RunicGateway/installer): # REGISTRY_USER — Gitea username the token below belongs to # REGISTRY_TOKEN — Gitea access token with `write:repository`, so it can push -# the bump commit + tag and create the release. -# Also: `main` must accept a direct push from that user (disable branch -# protection for it, or add it as an exception) — the bump commit lands on main. +# the release tag and create the release. # -# The bump commit carries `[skip ci]`, so it does not re-trigger this workflow. +# `main` needs NO push exception: this workflow tags and publishes, and never +# writes to a branch. Keeping it that way is deliberate — a first release that +# depends on a write to a protected branch fails at the worst possible moment. name: Release installer @@ -66,7 +66,8 @@ env: jobs: release: runs-on: ubuntu-latest - # Don't loop on our own bump commit (belt-and-suspenders with [skip ci]). + # Vestigial since this workflow stopped writing a bump commit, and kept as + # belt-and-braces in case one ever returns. # Quoted because the expression contains a colon (`chore(release):`), which an # unquoted YAML scalar would misparse as a mapping value. if: "${{ !contains(github.event.head_commit.message, 'chore(release): bump version') }}" @@ -300,33 +301,45 @@ jobs: ls -l dist && echo "----" && cat dist/SHA256SUMS # ── RELEASE ENGINE: commit the bump, tag, push ─────────────────────── - - name: Commit version bump and push tag + # Tag only — `main` is never pushed to. + # + # This step used to commit the version bump back to main first, and it has + # never executed in any repo that carries it: an EMPTY template expression + # written literally in the comment below (the `$`+`{{ }}` token, spelled + # out here for that reason) makes the runner fail to build the script and + # skip the step WITHOUT failing the job. link/release.yml carried the same + # bug for six releases, which is why its Cargo.toml still says 0.1.0 while + # its tags reach v1.1.1 — the release API creates the tag when it + # publishes, so the pipeline worked by accident. + # + # It also would have been declined if it had run: `main` is protected, and + # the bundle job proved that on 2026-08-05 (`pre-receive hook declined`). + # A first release must not depend on a write to a protected branch. + # + # So the tag is the version, as in servuo-plugins. The version is still + # written into Cargo.toml before building, so a released binary + # self-reports correctly; it is simply not committed back. The next + # version is computed from the newest tag, never from the file. + - name: Push the release tag if: ${{ steps.plan.outputs.release == 'true' }} env: REGISTRY_USER: ${{ secrets.REGISTRY_USER }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -euo pipefail - VERSION="${{ steps.plan.outputs.version }}" TAG="${{ steps.plan.outputs.tag }}" # Secrets can arrive with a trailing newline (depending on how they were # pasted); a stray CR/LF corrupts the remote URL ("credential url cannot - # be parsed"). Strip line breaks before building the URL. Passing them via - # env (not inline ${{ }}) also keeps a newline from breaking this script. + # be parsed"). Strip line breaks before building the URL. They are passed + # via env rather than interpolated into this script, so a newline cannot + # break it — do NOT write a template token literally in a comment here, + # or the runner will skip this step without failing the job. CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')" CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')" git config user.name "installer-ci" git config user.email "ci@whitlocktech.com" git remote set-url origin \ "https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git" - - git add Cargo.toml Cargo.lock - if ! git diff --cached --quiet; then - git commit -m "chore(release): bump version to ${TAG} [skip ci]" - git push origin "HEAD:main" - else - echo "Version unchanged (first release) — no bump commit needed." - fi # 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