diff --git a/.gitea/workflows/bundle.yml b/.gitea/workflows/bundle.yml index ddfa4c4..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 @@ -173,22 +211,33 @@ 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 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 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 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 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. @@ -330,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 @@ -340,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 @@ -350,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}" @@ -387,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 }}" \ @@ -470,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/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 627b169..da51b73 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -43,11 +43,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 @@ -73,7 +73,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') }}" @@ -312,33 +313,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 diff --git a/bundles/README.md b/bundles/README.md index 315c949..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 @@ -56,8 +69,9 @@ 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": "…" }, + "linux-aarch64": { "name": "…", "url": "…", "sha256": "…" }, "windows-x86_64": { "name": "…", "url": "…", "sha256": "…" } } },