diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 41db656..9cd25c5 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -99,6 +99,10 @@ jobs: # for before it will run. # libcap2-bin provides capsh, which the Test step uses to drop the two # DAC capabilities so root obeys permission bits. + # jq parses the tags API for the releasable check below. Named rather + # than assumed: the release job gets it from its image, and a base + # image change that dropped it would turn that check into a silent + # pass rather than a failure. # zsync and appstream are for build-appimage.sh: appimagetool shells # out to zsyncmake rather than bundling it, and reports success while # writing nothing when it is absent, so the script checks for it up @@ -107,7 +111,44 @@ jobs: apt-get install -y --no-install-recommends \ build-essential perl pkg-config \ binutils dpkg-dev desktop-file-utils gzip libcap2-bin \ - zsync appstream + zsync appstream jq + + - name: Check the release tag is free + # The other half of the guard above, for the path that actually cuts + # most releases: pushing a Release* branch. There the tag comes from + # [workspace.package] rather than the ref, so the mistake is not a + # mismatched tag but a *forgotten bump* — the version still points at a + # release that already shipped. + # + # The release job checks this too and remains the authority; it just + # cannot check it until both build jobs are green, so forgetting the + # bump used to cost two full release builds, packaging and an artifact + # upload before anything said so. This says so in seconds. + # + # Deliberately as lenient as the release job: a tag at *this* commit is + # a re-run and fine, and an unreachable API reads as "not found" and + # lets the build proceed rather than failing on a network hiccup. An + # early check that blocks a good release is worse than one that misses + # a bad one, because the late check still catches it. + if: >- + startsWith(github.ref, 'refs/heads/Release') + || startsWith(github.ref, 'refs/heads/release') + env: + RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + version=$(sed -n '/^\[workspace\.package\]/,/^\[/{ s/^version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p }' Cargo.toml) + [ -n "$version" ] || { echo "could not read the version from Cargo.toml" >&2; exit 1; } + tag="v$version" + api="${GITHUB_API_URL:-$GITHUB_SERVER_URL/api/v1}" + at=$(curl -sS -H "Authorization: token $RELEASE_TOKEN" \ + "$api/repos/$GITHUB_REPOSITORY/tags/$tag" | jq -r '.commit.sha // empty') + if [ -n "$at" ] && [ "$at" != "$GITHUB_SHA" ]; then + echo "ERROR: $tag already exists at $at, not $GITHUB_SHA." >&2 + echo "Bump [workspace.package] version in Cargo.toml, refresh Cargo.lock" >&2 + echo "with 'cargo update -w', commit and push again." >&2 + exit 1 + fi + echo "OK: $tag is free (or already at this commit)" - name: Trust the workspace # checkout writes as root into a directory git then considers dubiously @@ -168,7 +209,23 @@ jobs: echo "capabilities in test shell: $(grep CapEff /proc/self/status | tr -d "\t")" cargo test --release --locked --workspace' + # Everything from here down produces release assets, so it runs only + # where a release can actually come out: a v* tag or a Release* branch. + # On master and pull requests the job stops after Build and Test, which + # is what those runs are for — the packaging that used to follow built a + # .deb, an AppImage, a tarball and a 14-day artifact upload that nothing + # would ever download, because the release job is skipped there anyway. + # + # The condition is repeated rather than hoisted into an env var: Actions + # has no workflow-level expression alias, YAML anchors are not supported, + # and `env.X` inside `if:` would fail *closed* on a runner that did not + # populate it — silently skipping packaging on a real release. Spelled + # out, it is the same form the release job's own gate uses. - name: Build the .deb + if: >- + startsWith(github.ref, 'refs/tags/v') + || startsWith(github.ref, 'refs/heads/Release') + || startsWith(github.ref, 'refs/heads/release') # --no-build reuses the binaries from the Build step rather than # recompiling. SOURCE_DATE_EPOCH pins the generated changelog date so # repeat builds of the same commit are byte-identical. @@ -177,8 +234,14 @@ jobs: ./packaging/build-deb.sh --no-build - name: Check the glibc floor + if: >- + startsWith(github.ref, 'refs/tags/v') + || startsWith(github.ref, 'refs/heads/Release') + || startsWith(github.ref, 'refs/heads/release') # The whole point of pinning the container. If someone bumps the image, # this fails loudly instead of quietly shipping an uninstallable package. + # Gated with the .deb it inspects — there is no package to read without + # the step above. run: | deb=$(ls dist/*.deb) depends=$(dpkg-deb -f "$deb" Depends) @@ -194,6 +257,10 @@ jobs: echo "OK: glibc floor $floor <= $MAX_GLIBC" - name: Build the AppImage + if: >- + startsWith(github.ref, 'refs/tags/v') + || startsWith(github.ref, 'refs/heads/Release') + || startsWith(github.ref, 'refs/heads/release') # After the glibc gate, so the cheaper check still fails first. --no-build # reuses the binaries from the Build step, as the .deb step does, and # SOURCE_DATE_EPOCH pins the date substituted into the AppStream release @@ -207,6 +274,10 @@ jobs: ./packaging/build-appimage.sh --no-build - name: Package the binaries + if: >- + startsWith(github.ref, 'refs/tags/v') + || startsWith(github.ref, 'refs/heads/Release') + || startsWith(github.ref, 'refs/heads/release') # A tarball for anyone not installing the .deb, stripped to match what # build-deb.sh ships. run: | @@ -231,6 +302,12 @@ jobs: # node16, which current Forgejo runner images no longer ship; the # -node20 tags are Forgejo's builds for precisely this combination. - uses: actions/upload-artifact@v3-node20 + # `if-no-files-found: error` below would fail every master push once the + # packaging steps above are gated, so this carries the same gate. + if: >- + startsWith(github.ref, 'refs/tags/v') + || startsWith(github.ref, 'refs/heads/Release') + || startsWith(github.ref, 'refs/heads/release') with: name: linux-x86_64 # The .zsync is not optional: the update URL baked into every AppImage @@ -322,7 +399,16 @@ jobs: fi done + # As in the linux job, the asset-producing steps run only where a release + # can come out. The DLL check above deliberately stays ungated: it + # validates the .exe itself rather than packaging it, costs an objdump, + # and is exactly the kind of regression worth catching on master rather + # than at release time. - name: Build the installer + if: >- + startsWith(github.ref, 'refs/tags/v') + || startsWith(github.ref, 'refs/heads/Release') + || startsWith(github.ref, 'refs/heads/release') # --no-build reuses the binaries from the Build step rather than # cross-compiling them a second time. The installer and the .zip below # are alternatives, not a two-step download: the installer puts the app @@ -332,6 +418,10 @@ jobs: run: ./packaging/build-installer.sh --no-build - name: Package the binaries + if: >- + startsWith(github.ref, 'refs/tags/v') + || startsWith(github.ref, 'refs/heads/Release') + || startsWith(github.ref, 'refs/heads/release') run: | version=$(sed -n '/^\[workspace\.package\]/,/^\[/{ s/^version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p }' Cargo.toml) [ -n "$version" ] || { echo "could not read the version from Cargo.toml" >&2; exit 1; } @@ -354,6 +444,12 @@ jobs: # node16, which current Forgejo runner images no longer ship; the # -node20 tags are Forgejo's builds for precisely this combination. - uses: actions/upload-artifact@v3-node20 + # Same gate as the packaging steps, for the same `if-no-files-found` + # reason as the linux job. + if: >- + startsWith(github.ref, 'refs/tags/v') + || startsWith(github.ref, 'refs/heads/Release') + || startsWith(github.ref, 'refs/heads/release') with: name: windows-x86_64 path: | diff --git a/README.md b/README.md index c2f5508..33bca15 100644 --- a/README.md +++ b/README.md @@ -695,13 +695,26 @@ microseconds regardless of row count. allocation counts, measure that separately before concluding a path is cheap, and measure the GUI rather than a one-shot `quicksearch-cli` run — a short-lived process cannot show what a typing session retains. -- `.forgejo/workflows/ci.yml`: builds both platforms on every push to `master` - and every pull request. To cut a release, bump `[workspace.package] version` +- `.forgejo/workflows/ci.yml`: builds and tests both platforms on every push to + `master` and every pull request. Those runs stop there — packaging (the + `.deb`, the AppImage, the tarball, the Windows installer and `.zip`, and the + artifact upload) runs only where a release can actually come out, which is a + `v*` tag or a `Release...` branch. The Windows non-system-DLL check is + deliberately not gated that way: it validates the `.exe` rather than + packaging it, so it runs everywhere and catches a regression on `master` + rather than at release time. + + To cut a release, bump `[workspace.package] version` in `Cargo.toml` and push the commit on a branch named `Release...`; CI runs `cargo update -w` first, so a lockfile still pinning the old member versions is not something you have to remember. That only re-resolves the workspace crates, so the `--locked` build after it still fails on a dependency added or - bumped without committing `Cargo.lock`. Once both + bumped without committing `Cargo.lock`. Forgetting the version bump is caught + in seconds rather than after two full release builds: each release path gets + a guard before anything is compiled — a `v*` tag is checked against the + workspace version, and a `Release...` branch is checked against the tags that + already exist. Both are accelerators, not the authority; the release job + re-checks and remains the thing that actually refuses. Once both build jobs are green, CI tags that commit `v` and publishes a release with the `.deb`, an AppImage and its `.zsync` sidecar, a Linux tarball, the Windows installer and a Windows zip attached; pushing a `v*` tag by hand does