From d9ff87d2ad64240e73ccaa9b97e4a00c02f94373 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Wed, 16 Sep 2026 13:04:09 -0500 Subject: [PATCH 1/6] Add release automation --- .github/workflows/ci.yml | 35 +---- .github/workflows/release.yml | 241 ++++++++++++++++++++++++++++++++++ scripts/check-manifest.sh | 53 ++++++++ scripts/set-version.sh | 65 +++++++++ tests/test-set-version.sh | 69 ++++++++++ 5 files changed, 435 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100755 scripts/check-manifest.sh create mode 100755 scripts/set-version.sh create mode 100755 tests/test-set-version.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 138c50d..23541e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,35 +14,14 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - # The same checks `omarchy plugin validate` runs: schema version, required - # fields, a well-formed id outside the reserved namespace, entry points - # that are safe relative paths and exist, and no symlinks in the tree. + # The same checks `omarchy plugin validate` runs; release.yml runs the + # same script before it tags and again before it publishes. - name: Validate manifest.json - run: | - set -euo pipefail - jq -e '.schemaVersion == 1' manifest.json - for field in id name version kinds entryPoints; do - jq -e --arg f "$field" 'has($f)' manifest.json >/dev/null - done - jq -e '.id | test("^[A-Za-z0-9][A-Za-z0-9._-]*$")' manifest.json - jq -e '.id | (startswith("omarchy.") | not) and (contains("..") | not)' manifest.json - jq -e '(.kinds | type) == "array" and (.kinds | length) > 0' manifest.json - jq -e '(.entryPoints | type) == "object"' manifest.json - jq -e '.barWidget.defaultSection as $s | $s == null or (["left","center","right"] | index($s)) != null' manifest.json - jq -r '.entryPoints[]' manifest.json | while IFS= read -r ep; do - case "$ep" in - /*|*..*) echo "unsafe entry point: $ep"; exit 1 ;; - esac - test -f "$ep" || { echo "missing entry point: $ep"; exit 1; } - done - for kind in bar bar-widget menu overlay panel service; do - jq -e --arg k "$kind" '(.kinds | index($k)) == null' manifest.json >/dev/null && continue - key="$kind"; [[ $kind == bar-widget ]] && key=barWidget - jq -e --arg k "$key" '.entryPoints | has($k)' manifest.json >/dev/null \ - || { echo "kind '$kind' has no entryPoints.$key"; exit 1; } - done - link=$(find . -name .git -prune -o -type l -print -quit) - [[ -z $link ]] || { echo "symlink in plugin tree: $link"; exit 1; } + run: ./scripts/check-manifest.sh + # Bumps a disposable copy to a throwaway version and back, proving the + # release version rewrite is surgical and round-trips byte-for-byte. + - name: Set-version round trip + run: ./tests/test-set-version.sh - name: Data files parse run: | jq -e 'type == "array" and length > 0' cities.json >/dev/null diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9bd379d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,241 @@ +name: Release + +# Two ways in: +# 1. workflow_dispatch (`cut`): sets the version in manifest.json on `main`, +# commits that if it changed anything, tags, and pushes. That push uses +# the default GITHUB_TOKEN, which GitHub deliberately does not let +# trigger further workflow runs -- so `publish` runs in this SAME run +# right after `cut`, rather than relying on the tag push to start a +# second run. +# 2. push of a `v*` tag (e.g. a tag pushed by a maintainer directly): +# `cut` is skipped and `publish` runs against that tag. +# +# There is no build step and no packaged asset. omarchy-pkgs builds the +# `elsewhen` package from the tag's source archive +# (https://github.com/omacom/elsewhen/archive/refs/tags/vX.Y.Z.tar.gz), so +# the tag itself is the release artifact; the GitHub release only marks it +# as published (and, for a `-` version, as a prerelease to be skipped). +on: + workflow_dispatch: + inputs: + version: + description: "Version to release, without a leading v (e.g. 1.2.3)" + required: true + type: string + push: + tags: + - "v*" + +concurrency: + group: release + cancel-in-progress: false + +permissions: + contents: read + +jobs: + cut: + # Only the manual entry point cuts a release; a direct tag push has + # already done the equivalent of this job by hand. + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: write + outputs: + tag: ${{ steps.tag.outputs.tag }} + steps: + - name: Require a release cut from main + if: github.ref != 'refs/heads/main' + env: + REF: ${{ github.ref }} + run: | + echo "::error::Release cuts must run from refs/heads/main (got $REF)" + exit 1 + + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: main + fetch-depth: 0 + fetch-tags: true + + - name: Compute release tag + id: tag + env: + VERSION: ${{ inputs.version }} + run: | + tag="v$VERSION" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + - name: Fail if the tag already exists + env: + TAG: ${{ steps.tag.outputs.tag }} + run: | + if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + echo "::error::tag $TAG already exists locally" + exit 1 + fi + if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then + echo "::error::tag $TAG already exists on origin" + exit 1 + fi + + - name: Set the release version + env: + VERSION: ${{ inputs.version }} + run: ./scripts/set-version.sh "$VERSION" + + - name: Validate manifest.json + run: ./scripts/check-manifest.sh + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + + - name: Install Qt test runner and iso-codes + run: | + sudo apt-get update + sudo apt-get install --yes \ + iso-codes \ + qt6-declarative-dev-tools \ + qml6-module-qtqml-workerscript \ + qml6-module-qtquick \ + qml6-module-qtquick-window \ + qml6-module-qttest + + - name: Run tests + run: ./tests/run --offline + + - name: Commit and tag the release + env: + TAG: ${{ steps.tag.outputs.tag }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + # main may already carry this version: the first release, 0.1.0, + # was written into manifest.json by hand before this workflow + # existed. Then there is nothing to commit and the tag goes on the + # existing HEAD; the branch push below is a no-op for that case. + if git diff --quiet; then + echo "main is already at $TAG; tagging HEAD as it is" + else + git diff --stat + git add -u + git commit -m "Release $TAG" + fi + git tag -a "$TAG" -m "Release $TAG" + + - name: Push the release commit and tag + env: + TAG: ${{ steps.tag.outputs.tag }} + run: git push --atomic origin HEAD:refs/heads/main "refs/tags/$TAG" + + publish: + needs: cut + # Runs whether `cut` just ran (workflow_dispatch) or was skipped (a + # direct tag push); does not run if `cut` ran and failed. + if: | + always() && + (needs.cut.result == 'success' || needs.cut.result == 'skipped') + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: write + steps: + - name: Resolve the release tag + id: resolve + env: + CUT_TAG: ${{ needs.cut.outputs.tag }} + run: | + if [[ -n "$CUT_TAG" ]]; then + tag="$CUT_TAG" + else + tag="${GITHUB_REF#refs/tags/}" + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ steps.resolve.outputs.tag }} + fetch-depth: 0 + + - name: Require the tag to be on main + # The dispatch path only ever tags a commit `cut` just pushed to + # main, so this always holds there; it's the tag-push path (a + # maintainer pushing a `v*` tag directly, bypassing `cut`) that this + # guards: publishing must not happen from a commit main hasn't seen. + env: + TAG: ${{ steps.resolve.outputs.tag }} + run: | + git fetch origin main + if ! git merge-base --is-ancestor HEAD origin/main; then + echo "::error::tag $TAG is not an ancestor of origin/main; refusing to publish" + exit 1 + fi + + - name: Verify the tag matches the manifest version + env: + TAG: ${{ steps.resolve.outputs.tag }} + run: | + manifest_version=$(jq -r '.version' manifest.json) + [[ $TAG == "v$manifest_version" ]] || { + echo "::error::tag $TAG does not match manifest.json version $manifest_version" + exit 1 + } + + - name: Validate manifest.json + run: ./scripts/check-manifest.sh + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + + - name: Install Qt test runner and iso-codes + run: | + sudo apt-get update + sudo apt-get install --yes \ + iso-codes \ + qt6-declarative-dev-tools \ + qml6-module-qtqml-workerscript \ + qml6-module-qtquick \ + qml6-module-qtquick-window \ + qml6-module-qttest + + - name: Run tests + run: ./tests/run --offline + + - name: Determine the prerelease flag + id: prerelease + env: + TAG: ${{ steps.resolve.outputs.tag }} + run: | + if [[ $TAG == *-* ]]; then + echo "flag=--prerelease" >> "$GITHUB_OUTPUT" + else + echo "flag=" >> "$GITHUB_OUTPUT" + fi + + - name: Fail if a release for this tag already exists + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.resolve.outputs.tag }} + run: | + if gh release view "$TAG" >/dev/null 2>&1; then + echo "::error::a release for $TAG already exists" + exit 1 + fi + + - name: Create the GitHub release + # No assets: omarchy-pkgs consumes the tag's source archive, so the + # tag is the artifact and the release is the signal that it's ready. + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.resolve.outputs.tag }} + PRERELEASE_FLAG: ${{ steps.prerelease.outputs.flag }} + run: | + # shellcheck disable=SC2086 + gh release create "$TAG" \ + --verify-tag \ + --title "$TAG" \ + --generate-notes \ + $PRERELEASE_FLAG diff --git a/scripts/check-manifest.sh b/scripts/check-manifest.sh new file mode 100755 index 0000000..cf115f8 --- /dev/null +++ b/scripts/check-manifest.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +# The same checks `omarchy plugin validate` runs: schema version, required +# fields, a well-formed id outside the reserved namespace, entry points that +# are safe relative paths and exist, and no symlinks in the tree. CI runs +# this on every push; release.yml runs it before it tags and again before it +# publishes. Runs from any directory. + +repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +cd -- "$repo_root" + +fail() { + printf 'check-manifest: %s\n' "$*" >&2 + exit 1 +} + +command -v jq >/dev/null || fail "jq is required" +jq -e . manifest.json >/dev/null || fail "manifest.json is not valid JSON" + +jq -e '.schemaVersion == 1' manifest.json +for field in id name version kinds entryPoints; do + jq -e --arg f "$field" 'has($f)' manifest.json >/dev/null +done +jq -e '.id | test("^[A-Za-z0-9][A-Za-z0-9._-]*$")' manifest.json +jq -e '.id | (startswith("omarchy.") | not) and (contains("..") | not)' manifest.json +jq -e '(.kinds | type) == "array" and (.kinds | length) > 0' manifest.json +jq -e '(.entryPoints | type) == "object"' manifest.json +jq -e '.barWidget.defaultSection as $s | $s == null or (["left","center","right"] | index($s)) != null' manifest.json +jq -r '.entryPoints[]' manifest.json | while IFS= read -r ep; do + case "$ep" in + /*|*..*) echo "unsafe entry point: $ep"; exit 1 ;; + esac + test -f "$ep" || { echo "missing entry point: $ep"; exit 1; } +done +for kind in bar bar-widget menu overlay panel service; do + jq -e --arg k "$kind" '(.kinds | index($k)) == null' manifest.json >/dev/null && continue + key="$kind"; [[ $kind == bar-widget ]] && key=barWidget + jq -e --arg k "$key" '.entryPoints | has($k)' manifest.json >/dev/null \ + || { echo "kind '$kind' has no entryPoints.$key"; exit 1; } +done +link=$(find . -name .git -prune -o -type l -print -quit) +[[ -z $link ]] || { echo "symlink in plugin tree: $link"; exit 1; } + +# manifest.json is the release version (see scripts/set-version.sh); this +# checks its shape, not a value. SemVer-style only: X.Y.Z with an optional +# "-"-introduced pre-release suffix, no "."-introduced suffix and no build +# metadata, so release.yml's `*-*` prerelease test can't be fooled by a +# version like "1.2.3.rc1". +jq -e '.version | test("^[0-9]+\\.[0-9]+\\.[0-9]+(-[0-9A-Za-z][0-9A-Za-z.-]*)?$")' manifest.json >/dev/null \ + || fail "manifest version is not a semantic version: $(jq -r '.version' manifest.json)" + +printf 'check-manifest: ok\n' diff --git a/scripts/set-version.sh b/scripts/set-version.sh new file mode 100755 index 0000000..a8704ca --- /dev/null +++ b/scripts/set-version.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Sets the Elsewhen release version. manifest.json is the only version pin in +# this repository: `git grep -n '"version"'` finds nothing else (no +# package.json, no lockfile, no version constant in the QML or JS), so there +# is exactly one line to rewrite. Idempotent: a second run with the same +# version changes nothing. Runs entirely offline. + +usage() { + cat <<'USAGE' +Usage: scripts/set-version.sh X.Y.Z[-pre] + +Rewrite the "version" field of manifest.json to the given semantic version +(no leading v). Idempotent: running it again with the same version leaves the +tree unchanged. Runs entirely offline. +USAGE +} + +repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +manifest="$repo_root/manifest.json" + +fail() { + printf 'set-version: %s\n' "$*" >&2 + exit 1 +} + +if [[ $# -eq 1 && ($1 == -h || $1 == --help) ]]; then + usage + exit 0 +fi +[[ $# -eq 1 ]] || { usage >&2; exit 2; } + +version=$1 +# SemVer-style: major.minor.patch with an optional "-"-introduced pre-release +# suffix. No leading "v", no "."-introduced suffix and no build metadata, so +# release.yml's `*-*` prerelease test (and omarchy-pkgs' "latest +# non-prerelease" lookup) can't be fooled by a version like "1.2.3.rc1". +semver_re='^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z][0-9A-Za-z.-]*)?$' +[[ $version =~ $semver_re ]] || fail "not a semantic version: '$version' (want X.Y.Z or X.Y.Z-pre, no leading v)" + +command -v jq >/dev/null || fail "jq is required" +[[ -f "$manifest" ]] || fail "missing manifest.json" +jq -e . "$manifest" >/dev/null || fail "manifest.json is not valid JSON" + +current=$(jq -r '.version' "$manifest") +if [[ $current == "$version" ]]; then + printf 'set-version: already at %s (no changes)\n' "$version" + exit 0 +fi + +# manifest.json is hand-formatted (two-space indent, one array element per +# line); `jq` would reflow it, so the one "version" line is patched with sed +# rather than rewriting the whole document. Refuse if that line is not +# unique, so a "version" key added deeper in the schema can never be hit. +pins=$(grep -c -E '^[[:space:]]*"version": "[^"]*",?$' "$manifest" || true) +[[ $pins == 1 ]] || fail "expected exactly one \"version\" line in manifest.json, found $pins" +sed -E -i 's/^([[:space:]]*"version": ")[^"]*(",?)$/\1'"$version"'\2/' "$manifest" + +jq -e . "$manifest" >/dev/null || fail "manifest.json does not parse after the edit" +[[ $(jq -r '.version' "$manifest") == "$version" ]] \ + || fail "failed to update the version in manifest.json" + +printf 'set-version: set version to %s (was %s)\n' "$version" "$current" +printf ' updated manifest.json\n' diff --git a/tests/test-set-version.sh b/tests/test-set-version.sh new file mode 100755 index 0000000..c5ca465 --- /dev/null +++ b/tests/test-set-version.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Exercises scripts/set-version.sh against a disposable copy of the tracked +# tree: bump manifest.json to a throwaway prerelease version, prove +# scripts/check-manifest.sh accepts it, restore the original version, and +# prove the round trip is a byte-for-byte no-op. Then the versions the +# script must refuse. Needs jq and git; nothing else, and no network. + +repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) + +fail() { + printf 'test-set-version: %s\n' "$*" >&2 + exit 1 +} + +command -v jq >/dev/null || fail "jq is required" +command -v git >/dev/null || fail "git is required" + +original_version=$(jq -r '.version' "$repo_root/manifest.json") + +work_root=$(mktemp -d "${TMPDIR:-/tmp}/elsewhen-set-version-test.XXXXXX") +trap 'rm -rf -- "$work_root"' EXIT + +copy="$work_root/copy" +mkdir -p -- "$copy" +(cd "$repo_root" && git ls-files -z | tar --null -T - -cf -) | tar -C "$copy" -xf - + +# Snapshot the pristine copy in its own throwaway index, so every "nothing +# else moved" claim below is a plain `git diff --exit-code`. +(cd "$copy" && git init -q && git add -A) + +test_version="9.9.9-rc.1" +(cd "$copy" && ./scripts/set-version.sh "$test_version") +(cd "$copy" && ./scripts/check-manifest.sh) + +actual=$(jq -r '.version' "$copy/manifest.json") +[[ $actual == "$test_version" ]] \ + || fail "manifest.json .version reads '$actual', expected '$test_version'" + +# Surgical: exactly one line of one file changed. +numstat=$(cd "$copy" && git diff --numstat | tr '\t' ' ') +[[ $numstat == "1 1 manifest.json" ]] \ + || fail "expected exactly one changed line in manifest.json, got: ${numstat:-nothing}" + +# A second run with the same version is a no-op and says so. +again=$(cd "$copy" && ./scripts/set-version.sh "$test_version") +[[ $again == *"already at $test_version"* ]] \ + || fail "second run with the same version was not reported as a no-op: $again" + +(cd "$copy" && ./scripts/set-version.sh "$original_version") + +if ! (cd "$copy" && git diff --exit-code >/dev/null); then + (cd "$copy" && git diff --stat) >&2 + fail "restoring $original_version did not reproduce the original tree byte-for-byte" +fi + +# Versions the script must refuse, each leaving the copy untouched: a +# "."-introduced suffix, a leading v, too few components, empty, and a +# dangling "-". +for bad in 1.2.3.rc1 v1.2.3 1.2 "" 1.2.3-; do + if (cd "$copy" && ./scripts/set-version.sh "$bad" 2>/dev/null); then + fail "accepted invalid version '$bad'" + fi + (cd "$copy" && git diff --exit-code >/dev/null) \ + || fail "rejected '$bad' but changed the tree" +done + +printf 'set-version round trip: ok\n' From aac9cce0a49eec88296371ea70fe38e20b2715c6 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Wed, 16 Sep 2026 13:05:00 -0500 Subject: [PATCH 2/6] Set version to 0.1.0 --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index b1c9d76..1384306 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "omacom.elsewhen", "name": "Elsewhen", - "version": "1.0.0", + "version": "0.1.0", "author": "Jason Fried", "license": "MIT", "description": "A world clock for the Omarchy shell: the time in several cities at once, with a spinnable globe.", From 8a18c89d66c4c7e02a2c095a2ba24af958b318f7 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Wed, 16 Sep 2026 13:05:30 -0500 Subject: [PATCH 3/6] Describe the package and how releases are cut --- README.md | 43 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 976a87b..9b54104 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,23 @@ clocks, one row per city, with a spinnable globe behind it. ## Installing +On Omarchy, Elsewhen is the `elsewhen` package: installed by default, kept +current by `omarchy update`, and living in +`/usr/share/omarchy/plugins/omacom.elsewhen`. There is nothing to add. + +On an Omarchy from before the package, or to hack on it, the plugin can be +added from this repository instead: + ```bash omarchy plugin add https://github.com/omacom/elsewhen.git --enable ``` That clones this repository into `~/.config/omarchy/plugins/omacom.elsewhen` and places the widget in the bar's right section. Without `--enable` it asks -first. To take it out again: +first. While the package is installed the shell prefers the packaged copy: a +checkout under `~/.config/omarchy/plugins` with the same id is rejected with +a warning, so it only takes effect where the package is absent. To take it +out again: ```bash omarchy plugin remove omacom.elsewhen @@ -1261,3 +1271,34 @@ omarchy-shell omacom.elsewhen add America/New_York Miami omarchy-shell omacom.elsewhen remove America/New_York omarchy-shell omacom.elsewhen refresh # re-probe offsets ``` + +## Releasing + +Releases are cut from GitHub: Actions, Release, Run workflow, with `version` +set to the new version and no leading `v` (`0.2.0`, or `0.2.0-rc.1` for a +prerelease). The `cut` job runs on `main` and refuses any other branch. It +writes the version into `manifest.json` with `scripts/set-version.sh` - the +only place a version is recorded - runs `scripts/check-manifest.sh` and +`tests/run --offline`, commits `Release vX.Y.Z` as `github-actions[bot]` if +that changed anything, tags the commit `vX.Y.Z`, and pushes both. When `main` +already carries the version, as it did for `0.1.0`, there is nothing to +commit and the tag goes on the existing head. + +The `publish` job then runs in the same workflow run, because a push made +with the workflow's own token does not start another one. It checks out the +tag, requires its commit to be on `main` and the tag to equal `v` plus the +manifest's version, runs the same checks again, and creates the GitHub +release with generated notes. A version with a `-` in it is marked a +prerelease, which `omarchy-pkgs` skips when it looks for the latest release; +that is also why `set-version.sh` refuses a `.`-introduced suffix like +`0.2.0.rc1`, which would slip past. Nothing is attached to the release: +`omarchy-pkgs` builds the `elsewhen` package from the tag's source archive, +so the tag is the release. + +Pushing a `vX.Y.Z` tag by hand skips `cut` and publishes that tag the same +way. If `cut` succeeds and `publish` fails, re-run the failed job of that same +run from the Actions page; the commit and tag are already on `main`, so +dispatching the workflow again with the same version stops at the +tag-already-exists check, and bumping past it would leave the first version +unpublished. The repository has to be public before `omarchy-pkgs` can fetch +the archive at all. From dc77475acb4ad39924a7c7ee92299cc529aa73ca Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Wed, 16 Sep 2026 13:19:29 -0500 Subject: [PATCH 4/6] Refuse a release version that does not move forward --- .github/workflows/ci.yml | 4 ++ .github/workflows/release.yml | 7 +++ scripts/check-version-bump.sh | 65 ++++++++++++++++++++++++++ tests/test-check-version-bump.sh | 79 ++++++++++++++++++++++++++++++++ 4 files changed, 155 insertions(+) create mode 100755 scripts/check-version-bump.sh create mode 100755 tests/test-check-version-bump.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23541e7..17511a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,10 @@ jobs: # release version rewrite is surgical and round-trips byte-for-byte. - name: Set-version round trip run: ./tests/test-set-version.sh + # Tags of the test's own making, so the release guard is proven without + # touching this repository's tags. + - name: Version bump guard + run: ./tests/test-check-version-bump.sh - name: Data files parse run: | jq -e 'type == "array" and length > 0' cities.json >/dev/null diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9bd379d..0d67555 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -80,6 +80,13 @@ jobs: exit 1 fi + - name: Require the version to move forward + # Above every existing v* tag and not below the manifest, so a + # mistyped version cannot become the "latest" release. + env: + VERSION: ${{ inputs.version }} + run: ./scripts/check-version-bump.sh "$VERSION" + - name: Set the release version env: VERSION: ${{ inputs.version }} diff --git a/scripts/check-version-bump.sh b/scripts/check-version-bump.sh new file mode 100755 index 0000000..d1708bd --- /dev/null +++ b/scripts/check-version-bump.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Refuses a release version that would not move forward. release.yml runs this +# before it writes anything: the version must be strictly above every existing +# v* tag and not below the version manifest.json already carries. Without it +# a mistyped dispatch (0.4.0 after 1.0.0) would tag, publish, and become the +# repository's "latest" release while main's manifest walked backwards. + +usage() { + cat <<'USAGE' +Usage: scripts/check-version-bump.sh X.Y.Z[-pre] + +Exit 0 when the version is strictly greater than every existing v* tag and +not lower than the version in manifest.json; exit 1 otherwise. Reads tags +from the checkout it lives in, so fetch them first. +USAGE +} + +repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) + +fail() { + printf 'check-version-bump: %s\n' "$*" >&2 + exit 1 +} + +if [[ $# -eq 1 && ($1 == -h || $1 == --help) ]]; then + usage + exit 0 +fi +[[ $# -eq 1 ]] || { usage >&2; exit 2; } + +version=$1 +semver_re='^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z][0-9A-Za-z.-]*)?$' +[[ $version =~ $semver_re ]] || fail "not a semantic version: '$version'" + +command -v jq >/dev/null || fail "jq is required" +command -v git >/dev/null || fail "git is required" + +# GNU sort -V puts "0.2.0-rc.1" after "0.2.0", the opposite of SemVer. The +# Debian convention sorts right: "~" orders before the end of the string, so +# with "-" swapped for "~" a prerelease lands just below its release. +highest() { + tr -- '-' '~' | sort -V | tail -n 1 | tr -- '~' '-' +} + +# Only tags shaped like releases take part; anything else under v* is not a +# version this repository cut. +latest=$(git -C "$repo_root" tag --list 'v*' | sed 's/^v//' | grep -E "$semver_re" | highest || true) +if [[ -n $latest ]]; then + top=$(printf '%s\n%s\n' "$latest" "$version" | highest) + [[ $top == "$version" && $version != "$latest" ]] \ + || fail "version $version is not above the latest tag v$latest" +fi + +current=$(jq -r '.version' "$repo_root/manifest.json") +top=$(printf '%s\n%s\n' "$current" "$version" | highest) +[[ $top == "$version" ]] \ + || fail "version $version is below the $current that manifest.json already carries" + +if [[ -n $latest ]]; then + printf 'check-version-bump: %s is above v%s and manifest %s\n' "$version" "$latest" "$current" +else + printf 'check-version-bump: %s is the first release (manifest %s)\n' "$version" "$current" +fi diff --git a/tests/test-check-version-bump.sh b/tests/test-check-version-bump.sh new file mode 100755 index 0000000..9dce364 --- /dev/null +++ b/tests/test-check-version-bump.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Exercises scripts/check-version-bump.sh against a disposable copy of the +# tracked tree with tags of our own making: releases, prereleases, and a +# manifest ahead of the tags. Needs jq and git; no network. + +repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) + +fail() { + printf 'test-check-version-bump: %s\n' "$*" >&2 + exit 1 +} + +command -v jq >/dev/null || fail "jq is required" +command -v git >/dev/null || fail "git is required" + +work_root=$(mktemp -d "${TMPDIR:-/tmp}/elsewhen-version-bump-test.XXXXXX") +trap 'rm -rf -- "$work_root"' EXIT + +copy="$work_root/copy" +mkdir -p -- "$copy" +(cd "$repo_root" && git ls-files -z | tar --null -T - -cf -) | tar -C "$copy" -xf - +( + cd "$copy" + git init -q + git -c user.name=test -c user.email=test@example.com commit -q --allow-empty -m "root" +) + +accepts() { + local version="$1" why="$2" + (cd "$copy" && ./scripts/check-version-bump.sh "$version" >/dev/null 2>&1) \ + || fail "refused $version: $why" +} + +refuses() { + local version="$1" why="$2" + if (cd "$copy" && ./scripts/check-version-bump.sh "$version" >/dev/null 2>&1); then + fail "accepted $version: $why" + fi +} + +set_manifest() { + (cd "$copy" && ./scripts/set-version.sh "$1" >/dev/null) +} + +# No tags yet: anything at or above the manifest is the first release. +set_manifest 0.1.0 +accepts 0.1.0 "the first release may equal the manifest" +accepts 0.2.0 "the first release may be above the manifest" +refuses 0.0.9 "the first release may not be below the manifest" +refuses v0.2.0 "a leading v is not a version" +refuses 0.2 "two components are not a version" + +# Tags exist: the version must be strictly above the highest release-shaped +# one, whatever order the tags were created in, and odd v* tags are ignored. +(cd "$copy" && git tag v0.2.0 && git tag v0.1.0 && git tag v0.2.0-rc.1 && git tag vendor-drop) +accepts 0.2.1 "a patch above the latest tag" +accepts 0.3.0-rc.1 "a prerelease above the latest tag" +accepts 0.10.0 "a numeric, not lexical, comparison" +refuses 0.2.0 "the latest tag itself" +refuses 0.2.0-rc.2 "a prerelease of an already released version" +refuses 0.1.5 "below the latest tag" + +# A prerelease tag on top: its release and later prereleases go forward, the +# earlier ones do not. +(cd "$copy" && git tag v0.3.0-rc.1) +accepts 0.3.0 "the release of the latest prerelease" +accepts 0.3.0-rc.2 "a later prerelease" +refuses 0.3.0-rc.1 "the latest prerelease itself" +refuses 0.2.5 "below the latest prerelease" + +# The manifest may run ahead of the tags, but a release never pulls it back. +set_manifest 0.4.0 +accepts 0.4.0 "the manifest's own version" +accepts 0.5.0 "above the manifest" +refuses 0.3.5 "below the manifest even though above the tags" + +printf 'check-version-bump: ok\n' From fde904e6c78f8b7be40ba41f00fc969369fb2447 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Wed, 16 Sep 2026 13:19:29 -0500 Subject: [PATCH 5/6] Prove set-version refuses a second version line --- tests/test-set-version.sh | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test-set-version.sh b/tests/test-set-version.sh index c5ca465..d13d998 100755 --- a/tests/test-set-version.sh +++ b/tests/test-set-version.sh @@ -66,4 +66,18 @@ for bad in 1.2.3.rc1 v1.2.3 1.2 "" 1.2.3-; do || fail "rejected '$bad' but changed the tree" done +# The one "version" line must be unique: a second one anywhere in the +# manifest, say under a nested object, means the script no longer knows which +# to rewrite and must refuse rather than touch both. +(cd "$copy" && git checkout -q -- manifest.json) +sed -i 's/^ "author": / "nested": {\n "version": "0.0.0"\n },\n "author": /' "$copy/manifest.json" +jq -e . "$copy/manifest.json" >/dev/null || fail "the duplicate-version fixture is not valid JSON" +if (cd "$copy" && ./scripts/set-version.sh "$test_version" 2>/dev/null); then + fail "accepted a manifest with two version lines" +fi +[[ $(jq -r '.version' "$copy/manifest.json") == "$original_version" ]] \ + || fail "refused the duplicate-version manifest but rewrote it anyway" +[[ $(jq -r '.nested.version' "$copy/manifest.json") == "0.0.0" ]] \ + || fail "refused the duplicate-version manifest but rewrote the nested line" + printf 'set-version round trip: ok\n' From 84288b1f7419082861ed39edff03b5ab970a3751 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Wed, 16 Sep 2026 13:19:29 -0500 Subject: [PATCH 6/6] Say which Omarchy carries the package --- README.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9b54104..d2035be 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ clocks, one row per city, with a spinnable globe behind it. ## Installing -On Omarchy, Elsewhen is the `elsewhen` package: installed by default, kept -current by `omarchy update`, and living in +Newer Omarchy installs carry Elsewhen as the `elsewhen` package: installed +by default, kept current by `omarchy update`, and living in `/usr/share/omarchy/plugins/omacom.elsewhen`. There is nothing to add. On an Omarchy from before the package, or to hack on it, the plugin can be @@ -1276,9 +1276,12 @@ omarchy-shell omacom.elsewhen refresh # re-probe offset Releases are cut from GitHub: Actions, Release, Run workflow, with `version` set to the new version and no leading `v` (`0.2.0`, or `0.2.0-rc.1` for a -prerelease). The `cut` job runs on `main` and refuses any other branch. It -writes the version into `manifest.json` with `scripts/set-version.sh` - the -only place a version is recorded - runs `scripts/check-manifest.sh` and +prerelease). The `cut` job runs on `main` and refuses any other branch, a +tag that already exists, and a version that is not above every existing tag +and at least the manifest's own, so a mistyped version cannot become the +latest release. It writes the version into `manifest.json` with +`scripts/set-version.sh` - the only place a version is recorded - runs +`scripts/check-manifest.sh` and `tests/run --offline`, commits `Release vX.Y.Z` as `github-actions[bot]` if that changed anything, tags the commit `vX.Y.Z`, and pushes both. When `main` already carries the version, as it did for `0.1.0`, there is nothing to