From 3450c313454a5b1e5cd3048b47a0ae47e0e1bff0 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 29 Sep 2026 23:06:52 -0700 Subject: [PATCH] Build on the GitHub mirror when BUILD_ON=github Gitea stays where the project lives and push-mirrors every branch and tag to GitHub. With the Actions variable BUILD_ON set to 'github' on both forges, the GitHub copy does the building and reports back to Gitea as a commit status; unset, nothing changes and Gitea builds as before. .github/workflows/ci.yml replaces the GitHub-era files. Branch pushes run what Gitea's ci.yml checks (fork checks, dev build, test targets, the release profile on main). v* tags run what publish.yml does, with the same two guards: the image per architecture on native runners side by side, the multi-arch index and :latest, the Gitea Release if the tag has none, and the host-install binaries taken out of the image. A final job posts "github/ci (branch)" or "github/ci (tag)" to the commit on Gitea. On Gitea, the heavy jobs skip under BUILD_ON=github and a `github` job waits for that status and passes or fails with it, so pull requests and merges still look at a Gitea run. The weekly release, the upstream watch and the announcement stay on Gitea. Removed: cleanup.yml and publish.yml (GHCR), release.yml (a second weekly schedule), and dependabot.yml, whose pull request branches every mirror sync would delete. --- .gitea/workflows/ci.yml | 43 +++- .gitea/workflows/publish.yml | 53 ++++- .github/dependabot.yml | 42 ---- .github/workflows/ci.yml | 409 ++++++++++++++++++++++++++++++---- .github/workflows/cleanup.yml | 69 ------ .github/workflows/publish.yml | 198 ---------------- .github/workflows/release.yml | 246 -------------------- 7 files changed, 465 insertions(+), 595 deletions(-) delete mode 100644 .github/dependabot.yml delete mode 100644 .github/workflows/cleanup.yml delete mode 100644 .github/workflows/publish.yml delete mode 100644 .github/workflows/release.yml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index c6d9a3a..df34165 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -7,7 +7,12 @@ # instance resolves short `uses:` against itself, never GitHub, so nothing # unreviewed can be pulled in. # -# Not ported, as on GitLab: publish.yml and release.yml still need doing. +# BUILD_ON: when the Actions variable BUILD_ON is 'github' (org or repo), +# fork-checks and build skip here and the `github` job below waits for the +# same work done by .github/workflows/ci.yml on the GitHub mirror, passing or +# failing with it -- so this run still carries the answer pull requests and +# merges look at. Unset, everything builds here as before. If GitHub is +# unavailable, unset BUILD_ON and nothing else has to change. name: ci on: @@ -25,6 +30,7 @@ jobs: # without the AGPL 5(a) notice. Seconds, and needs no toolchain. The notice # check diffs against the upstream snapshot branch, hence the full fetch. fork-checks: + if: ${{ vars.BUILD_ON != 'github' }} runs-on: light container: image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim @@ -52,6 +58,7 @@ jobs: run: python3 -m unittest discover -s tools/fork/tests build: + if: ${{ vars.BUILD_ON != 'github' }} # Either runner (host1 or host2): the build needs no docker socket. runs-on: light container: @@ -101,3 +108,37 @@ jobs: used=$(du -s --block-size=1G /cache/target 2>/dev/null | cut -f1) echo "target dir: ${used:-0} GB" if [ "${used:-0}" -gt 60 ]; then rm -rf /cache/target && echo "over 60 GB: target dir cleared"; fi + + # BUILD_ON=github: the GitHub mirror builds this commit and posts the result + # back as the commit status "github/ci (branch)". This waits for that status + # and takes its answer. The mirror pushes on every commit, so a missing + # status means GitHub has not got the push or is not running: after the + # timeout this fails, which is the cue to unset BUILD_ON. + github: + if: ${{ vars.BUILD_ON == 'github' }} + runs-on: light + timeout-minutes: 150 + container: + image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim + steps: + - env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + SHA: ${{ github.event.pull_request.head.sha || github.sha }} + CONTEXT: github/ci (branch) + run: | + python3 - <<'EOF' + import json, os, time, urllib.request + url = (f"{os.environ['CI_SERVER_INTERNAL']}/api/v1/repos/{os.environ['GITHUB_REPOSITORY']}" + f"/commits/{os.environ['SHA']}/statuses?limit=50") + req = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['TOKEN']}"}) + ctx, last = os.environ["CONTEXT"], None + print(f"waiting for '{ctx}' on {os.environ['SHA']}", flush=True) + while True: + mine = [s for s in json.load(urllib.request.urlopen(req)) if s["context"] == ctx] + state = max(mine, key=lambda s: s["id"]) if mine else None + if state and state["status"] != last: + last = state["status"]; print(f"{ctx}: {last} {state.get('target_url', '')}", flush=True) + if last == "success": raise SystemExit(0) + if last in ("failure", "error"): raise SystemExit(1) + time.sleep(20) + EOF diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml index 451e36f..23a1cc9 100644 --- a/.gitea/workflows/publish.yml +++ b/.gitea/workflows/publish.yml @@ -42,6 +42,14 @@ # # The push logs in with PACKAGE_TOKEN (jcoffey-dev, write:package): the job's # own token is refused by the container registry. +# +# BUILD_ON: when the Actions variable BUILD_ON is 'github' (org or repo), every +# job here but the announcement skips, and the tag is published by +# .github/workflows/ci.yml on the GitHub mirror instead -- same guards, same +# tags, the same Release and binaries, created here through the API. The +# `github` job waits for that run's commit status, "github/ci (tag)", and the +# announcement follows it as it follows the binaries here. Unset, everything +# runs here as before. name: publish on: @@ -50,6 +58,7 @@ on: jobs: version: + if: ${{ vars.BUILD_ON != 'github' }} runs-on: light container: image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim @@ -88,6 +97,7 @@ jobs: echo "version $V" publish-amd64: + if: ${{ vars.BUILD_ON != 'github' }} needs: [version] runs-on: docker container: @@ -128,6 +138,7 @@ jobs: run: docker logout "$REGISTRY" || true publish-arm64: + if: ${{ vars.BUILD_ON != 'github' }} needs: [version, publish-amd64] runs-on: docker container: @@ -162,11 +173,44 @@ jobs: - if: always() run: docker logout "$REGISTRY" || true + # BUILD_ON=github: waits for the GitHub mirror's run for this tag, which + # posts its result back as the commit status "github/ci (tag)", and takes + # its answer. Fails after the timeout if no answer comes. + github: + if: ${{ vars.BUILD_ON == 'github' }} + runs-on: light + timeout-minutes: 240 + container: + image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim + steps: + - env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + SHA: ${{ github.sha }} + CONTEXT: github/ci (tag) + run: | + python3 - <<'EOF' + import json, os, time, urllib.request + url = (f"{os.environ['CI_SERVER_INTERNAL']}/api/v1/repos/{os.environ['GITHUB_REPOSITORY']}" + f"/commits/{os.environ['SHA']}/statuses?limit=50") + req = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['TOKEN']}"}) + ctx, last = os.environ["CONTEXT"], None + print(f"waiting for '{ctx}' on {os.environ['SHA']}", flush=True) + while True: + mine = [s for s in json.load(urllib.request.urlopen(req)) if s["context"] == ctx] + state = max(mine, key=lambda s: s["id"]) if mine else None + if state and state["status"] != last: + last = state["status"]; print(f"{ctx}: {last} {state.get('target_url', '')}", flush=True) + if last == "success": raise SystemExit(0) + if last in ("failure", "error"): raise SystemExit(1) + time.sleep(20) + EOF + # The weekly release creates its Release (and so the tag) first; a tag # pushed by hand has none. Either way the tag ends up with exactly one # Release, created once the amd64 image exists so its pull instructions # work; arm64 and the binaries follow. release: + if: ${{ vars.BUILD_ON != 'github' }} needs: [version, publish-amd64] runs-on: light container: @@ -216,6 +260,7 @@ jobs: # `docker create` does not start anything, so pulling an arm64 image on an # amd64 runner and copying a file out of it needs no emulation. binaries: + if: ${{ vars.BUILD_ON != 'github' }} needs: [version, publish-arm64, release] runs-on: docker container: @@ -289,8 +334,14 @@ jobs: # The release above is made with the job's own token, and Gitea starts no # workflow for events the Actions bot causes -- announce.yml's # 'on: release' never fires for it -- so announce it from here. + # + # With BUILD_ON=github the release and binaries come from the GitHub run, + # so the announcement waits for the `github` job instead. The Release that + # run creates for a hand-pushed tag is made with a user token, so + # announce.yml fires for it too; discourse-release keeps one topic per tag. announce: - needs: [release, binaries] + needs: [release, binaries, github] + if: ${{ always() && ((needs.release.result == 'success' && needs.binaries.result == 'success') || needs.github.result == 'success') }} runs-on: light steps: - uses: coffey-labs/actions/discourse-release@e9293996e2efa770839121fa8f8da93083f216be diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index cfacd74..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,42 +0,0 @@ -version: 2 -updates: - # Cargo. One entry: the workspace has a single lockfile at the root, and - # ~30 manifests that upstream bumps on every release -- pointing entries at - # individual crates would find manifests with no lockfile beside them. - # - # Minor and patch arrive as one pull request a week. Majors are left out of - # the group on purpose: they are migrations rather than bumps, and each one - # deserves its own pull request and its own CI run. - - package-ecosystem: cargo - directory: "/" - schedule: - interval: weekly - day: tuesday - time: "09:00" - timezone: Etc/UTC - open-pull-requests-limit: 5 - groups: - minor-and-patch: - update-types: - - minor - - patch - - package-ecosystem: github-actions - directory: "/" - schedule: - interval: weekly - day: tuesday - time: "09:00" - timezone: Etc/UTC - groups: - actions: - patterns: - - "*" - # The Dockerfiles pin their base images, so this is what keeps a published - # image off a stale base between releases. - - package-ecosystem: docker - directory: "/" - schedule: - interval: weekly - day: tuesday - time: "09:00" - timezone: Etc/UTC diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6df1757..cec45f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,51 +1,384 @@ -# What CI can check without a mail server's worth of infrastructure. +# CI and publishing on GitHub, for the repository Gitea mirrors here. # -# The build, and that every test target compiles. It deliberately does not -# *run* the test suites: the unit tests only build with the integration crate -# in the graph, because that is what switches on the `test_mode` features they -# rely on (docs/spec/SPEC.md 2.2b), and the integration suites need a `STORE`, -# fixed ports, and in most cases a container apiece (docs/spec/ -# container-tests.md). Running them here would mean either a green tick that -# skipped everything, or a red one that means "the runner has no Redis". +# Gitea (git.coffeylabs.org) is where this project lives: pull requests, +# issues, releases and the container registry are all there, and it pushes +# every branch and tag to this GitHub copy as it changes. GitHub's hosted +# runners are faster than the self-hosted ones -- and have native arm64 -- so +# the building happens here, and the answer goes back to Gitea as a commit +# status that Gitea's own ci.yml / publish.yml wait on. # -# So this catches what it can honestly catch -- code that does not compile, -# including test code -- and the suites are run by hand, one at a time, as -# that page describes. If that changes, it changes because someone made the -# suites runnable unattended, not because CI started ignoring failures. -name: CI +# One switch decides which side builds: the Actions variable BUILD_ON, set on +# both forges. BUILD_ON=github runs every job below and turns Gitea's heavy +# jobs into a wait for this one; anything else leaves Gitea building exactly +# as before and every job here skips. If GitHub is ever unavailable, unset it +# on Gitea and nothing else has to change. +# +# Needs, as organization settings rather than anything in this file: +# variables BUILD_ON=github, REGISTRY (the Gitea container registry), +# GITEA_URL (the Gitea base URL) +# secret GITEA_TOKEN -- jcoffey-dev, write:repository + write:package: +# commit statuses, the release and its assets, the registry push +# +# There is no pull_request trigger: pull requests happen on Gitea, and their +# branch arrives here as an ordinary push. Branch pushes get what Gitea's +# ci.yml checks; v* tags get what its publish.yml does. Schedules (the weekly +# release, the upstream watch) and the release announcement stay on Gitea. +# +# Every `uses:` is pinned to a full commit SHA with the release in the +# trailing comment. A tag is a mutable pointer; do not "simplify" a pin back +# to one. Only GitHub's own actions and the three docker/* ones are used. +name: ci + on: push: - branches: [main] - pull_request: - # Lets CI be run by hand against any ref, including one that predates a CI - # change, without pushing an empty commit to move it. + branches: ['**'] + tags: ['**'] workflow_dispatch: -# A second push to a branch cancels the run still going for the first: the -# older run's answer is about code nobody is looking at any more. +# A newer push to a branch cancels the run for the older one, whose answer is +# about code nobody is looking at any more. A tag run is never cancelled: it +# publishes. concurrency: group: ci-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.ref_type == 'branch' }} + +permissions: + contents: read + +env: + GITEA_URL: ${{ vars.GITEA_URL }} + # The Gitea status this run answers for. Gitea waits on the one matching + # its own event: "(branch)" from ci.yml, "(tag)" from publish.yml. + STATUS_CONTEXT: github/ci (${{ github.ref_type }}) jobs: - build: + # Tells Gitea a run has started, so a pull request shows it as pending + # rather than missing while the build is still going. + start: + if: ${{ vars.BUILD_ON == 'github' }} + runs-on: ubuntu-latest + steps: + - env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + jq -n --arg c "$STATUS_CONTEXT" \ + --arg u "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \ + '{state:"pending", context:$c, target_url:$u, description:"GitHub Actions"}' | + curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" \ + -H 'Content-Type: application/json' --data @- \ + "$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" + + # ----------------------------------------------------------- branches ------ + # What an upstream merge can bring in or leave behind without a conflict: + # the upstream name in a new string literal, and a changed upstream file + # without the AGPL 5(a) notice. Seconds, and needs no toolchain. The notice + # check diffs against the upstream snapshot in the history, hence the full + # fetch. + fork-checks: + if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'branch' }} runs-on: ubuntu-latest steps: - # Every `uses:` here is pinned to a full commit SHA, with the release it - # belongs to in the trailing comment. A tag is a mutable pointer, so - # trusting `@v7` is trusting every future version of that action, - # including one pushed by whoever compromises the account. Dependabot - # updates both halves together -- do not "simplify" a pin back to a tag. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - - name: System dependencies - # foundationdb and the search backends are off by default, but the - # default feature set still links against the system's C libraries. - run: sudo apt-get update && sudo apt-get install -y --no-install-recommends clang - - name: Build the server - run: cargo build -p inbuxa --locked - - name: Compile every test target - # `--no-run` is the point: it builds the unit tests and the integration - # crate together, which is the combination that resolves the test - # features, and stops short of running anything that wants a store. - run: cargo test --workspace --locked --no-run + with: + fetch-depth: 0 + - run: python3 tools/fork/name-check.py + - if: always() + run: python3 tools/fork/notice-check.py + # Cargo can patch a dependency to a directory in this repository, and + # the image builds from a context .dockerignore prunes to almost + # nothing. CI never sees the difference; a release does. + - if: always() + run: python3 tools/fork/context-check.py + # The personal-data catalog must classify every object and field the + # schema has, and name nothing that is gone. + - if: always() + run: python3 tools/fork/privacy-check.py + # The admin reads each expression field's allowed values and variables + # from the schema; they're generated from the registry and must match it. + - if: always() + run: python3 tools/fork/expr-schema.py --check + - if: always() + run: python3 -m unittest discover -s tools/fork/tests + + # The build, and that every test target compiles. The suites are not run: + # they need a store, fixed ports and containers (docs/spec/ + # container-tests.md), and are run by hand. + build: + if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'branch' }} + runs-on: ubuntu-latest + env: + CARGO_INCREMENTAL: "0" + # Debug info is most of a dev target dir, and nothing here runs a + # debugger. Without it the dev and test builds fit the runner's disk and + # the cache below stays small enough to be worth restoring. + CARGO_PROFILE_DEV_DEBUG: "0" + CARGO_PROFILE_TEST_DEBUG: "0" + steps: + # The hosted image carries toolchains this build never touches; a dev, + # test and release build of RocksDB and the workspace needs the room. + - run: | + sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL + df -h / + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # Current stable, as Gitea's rust:1 image is. + - id: rust + run: | + rustup toolchain install stable --profile minimal + rustup default stable + echo "version=$(rustc -V | cut -d' ' -f2)" >> "$GITHUB_OUTPUT" + - run: sudo apt-get update -qq && sudo apt-get install -y -qq --no-install-recommends clang >/dev/null + # Cargo's download cache and the dev/test target dir, keyed on the + # lockfile and the compiler. Saved from main only, so the one cache + # every branch restores is main's, and branches cannot evict it. + - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + target/debug + key: cargo-${{ steps.rust.outputs.version }}-${{ hashFiles('Cargo.lock') }} + restore-keys: cargo-${{ steps.rust.outputs.version }}- + - run: cargo build -p inbuxa --locked + # --no-run: compiles every test target without running them, which + # catches a test that no longer builds without needing a store. + - run: cargo test --workspace --locked --no-run + - if: github.ref == 'refs/heads/main' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + target/debug + key: cargo-${{ steps.rust.outputs.version }}-${{ hashFiles('Cargo.lock') }} + # The release profile, on main only. It is the profile the image is + # built with, and it fails in ways the dev profile does not: v2026.9.24 + # was tagged on a commit whose CI was green and whose release build + # could not compile the scim crate at all. + - if: github.ref == 'refs/heads/main' + run: cargo build -p inbuxa --locked --release + + # --------------------------------------------------------------- tags ------ + # Two guards before anything is pushed, the same as Gitea's publish.yml: + # * the tag must be v. The version is a string in + # crates/types/src/branding.rs, not Cargo.toml, and the image is tagged + # with it, so a tag beside an unbumped macro would publish an image that + # reports a different version from its tag. + # * the tag must be on main or on a release/* branch, so an image never + # describes code that was never reviewed onto one of them. A release/* + # branch carries a hotfix cut from an earlier release tag. + version: + if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' && startsWith(github.ref_name, 'v') }} + runs-on: ubuntu-latest + outputs: + version: ${{ steps.v.outputs.version }} + steps: + # Full history, and every branch as origin/*: the ancestry check cannot + # be answered from a shallow clone. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - id: v + env: + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + # Scoped to the macro body: branding.rs holds other string literals, + # and tagging an image from one of those would be worse than failing. + V="$(awk '/macro_rules! brand_version /,/^}/' crates/types/src/branding.rs \ + | grep -om1 '"[0-9][^"]*"' | tr -d '"')" + [ -n "$V" ] || { echo "could not read brand_version! from branding.rs" >&2; exit 1; } + if [ "$TAG" != "v$V" ]; then + echo "Tag $TAG names a commit whose brand_version! says $V." >&2 + echo "Refusing to publish an image that would report the wrong version." >&2 + exit 1 + fi + commit="$(git rev-parse "${TAG}^{commit}")" + on="" + for ref in origin/main $(git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/release/*'); do + if git merge-base --is-ancestor "$commit" "$ref"; then on="$ref"; break; fi + done + [ -n "$on" ] || { echo "$TAG is not on main or a release/* branch" >&2; exit 1; } + echo "$TAG is on $on" + echo "version=$V" >> "$GITHUB_OUTPUT" + + # Each architecture on its own native runner, side by side. The Dockerfile + # cross-compiles from the build platform, and on the self-hosted runners one + # machine built both one after the other; here two machines build at once, + # each natively (the builder stage picks the matching target, and the + # aarch64 toolchain it installs exists on arm64 too), and the small final + # stage needs no QEMU. amd64 also moves : as soon as it is done, so + # a production deploy can start from it; :latest waits for the index below, + # so it never names an image without arm64. + publish: + needs: [version] + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-24.04-arm + env: + VERSION: ${{ needs.version.outputs.version }} + steps: + - run: | + sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL + echo "IMAGE=${{ vars.REGISTRY }}/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV" + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ${{ vars.REGISTRY }} + username: jcoffey-dev + password: ${{ secrets.GITEA_TOKEN }} + # Attestations off: they add manifests of their own, and the index + # should hold the two images and nothing else. The GitHub Actions cache + # keeps the dependency layer (`cargo chef cook`), which only a + # dependency change alters, between releases. + - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + platforms: linux/${{ matrix.arch }} + provenance: false + sbom: false + cache-from: type=gha,scope=image-${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=image-${{ matrix.arch }} + push: true + tags: | + ${{ env.IMAGE }}:${{ env.VERSION }}-${{ matrix.arch }} + ${{ matrix.arch == 'amd64' && format('{0}:{1}', env.IMAGE, env.VERSION) || '' }} + + # Joins the two per-architecture tags into : and :latest. Built + # from the per-architecture tags rather than :, which by now is + # the amd64 image and would be read as such. + index: + needs: [version, publish] + runs-on: ubuntu-latest + env: + VERSION: ${{ needs.version.outputs.version }} + steps: + - run: echo "IMAGE=${{ vars.REGISTRY }}/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV" + - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ${{ vars.REGISTRY }} + username: jcoffey-dev + password: ${{ secrets.GITEA_TOKEN }} + - run: | + docker buildx imagetools create \ + --tag "$IMAGE:$VERSION" \ + --tag "$IMAGE:latest" \ + "$IMAGE:$VERSION-amd64" "$IMAGE:$VERSION-arm64" + docker buildx imagetools inspect "$IMAGE:$VERSION" + # Gitea keeps a container package on its owner; linking it shows it on + # the repository's Packages tab. Idempotent. + - env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + owner="${GITHUB_REPOSITORY%%/*}"; name="${GITHUB_REPOSITORY#*/}" + curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" \ + "$GITEA_URL/api/v1/packages/${owner,,}/container/$name/-/link/$name" \ + || echo "package already linked (or link refused); not fatal" + + # The weekly release creates its Release (and so the tag) on Gitea first; a + # tag pushed by hand has none. Either way the tag ends up with exactly one + # Release there, created once the image exists so its pull instructions + # work. + release: + needs: [version, index] + runs-on: ubuntu-latest + steps: + - env: + TAG: ${{ github.ref_name }} + VERSION: ${{ needs.version.outputs.version }} + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + REGISTRY: ${{ vars.REGISTRY }} + run: | + set -euo pipefail + api="$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY" + code="$(curl -sS -o /dev/null -w '%{http_code}' -H "Authorization: token $GITEA_TOKEN" "$api/releases/tags/$TAG")" + if [ "$code" = 200 ]; then echo "$TAG already has a release"; exit 0; fi + [ "$code" = 404 ] || { echo "looking up the release for $TAG answered $code" >&2; exit 1; } + image="$REGISTRY/${GITHUB_REPOSITORY,,}:$VERSION" + body="Container image: \`$image\` (linux/amd64, linux/arm64); also \`:latest\`. + + Binaries for a host install are attached: \`inbuxa-linux-amd64.tar.gz\` and \`inbuxa-linux-arm64.tar.gz\`, with \`SHA256SUMS\`. Each is the binary out of this release's image for that architecture, so it is the same build. The image grants it \`cap_net_bind_service\`; a host install has to grant that itself (\`setcap\`, or \`AmbientCapabilities\` in the unit) to bind port 25." + jq -n --arg tag "$TAG" --arg name "INBUXA $VERSION" --arg body "$body" \ + '{tag_name:$tag, name:$name, body:$body}' | + curl -fsS -X POST -H "Authorization: token $GITEA_TOKEN" -H 'Content-Type: application/json' \ + --data @- "$api/releases" | jq -r '"created release " + .tag_name' + + # The binaries for a host install, taken out of the image that was just + # pushed rather than compiled again: the binary in the tarball is the file + # the image runs. `docker create` starts nothing, so copying a file out of + # the arm64 image on an amd64 runner needs no emulation. + binaries: + needs: [version, index, release] + runs-on: ubuntu-latest + env: + VERSION: ${{ needs.version.outputs.version }} + TAG: ${{ github.ref_name }} + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + steps: + - run: echo "IMAGE=${{ vars.REGISTRY }}/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV" + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ${{ vars.REGISTRY }} + username: jcoffey-dev + password: ${{ secrets.GITEA_TOKEN }} + - name: take the binaries out of the image + run: | + set -euo pipefail + mkdir -p out && cd out + for arch in amd64 arm64; do + docker pull -q --platform "linux/$arch" "$IMAGE:$VERSION" + id="$(docker create --platform "linux/$arch" "$IMAGE:$VERSION")" + docker cp "$id:/usr/local/bin/inbuxa" inbuxa + docker rm -f "$id" >/dev/null + chmod 0755 inbuxa + tar -czf "inbuxa-linux-$arch.tar.gz" inbuxa + rm inbuxa + done + sha256sum inbuxa-linux-*.tar.gz > SHA256SUMS + cat SHA256SUMS + # A re-run of a tag replaces its assets rather than leaving two files + # with the same name and different contents. + - name: attach them to the release + run: | + set -euo pipefail + api="$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY" + auth="Authorization: token $GITEA_TOKEN" + rel="$(curl -fsS -H "$auth" "$api/releases/tags/$TAG" | jq -r .id)" + assets="$(curl -fsS -H "$auth" "$api/releases/$rel/assets")" + for f in out/inbuxa-linux-amd64.tar.gz out/inbuxa-linux-arm64.tar.gz out/SHA256SUMS; do + name="$(basename "$f")" + old="$(jq -r --arg n "$name" '.[] | select(.name == $n) | .id' <<<"$assets")" + for id in $old; do curl -fsS -o /dev/null -X DELETE -H "$auth" "$api/releases/$rel/assets/$id"; done + curl -fsS -o /dev/null -X POST -H "$auth" -F "attachment=@$f" "$api/releases/$rel/assets?name=$name" + echo "attached $name" + done + + # ------------------------------------------------------------- report ------ + # One commit status on Gitea for the whole run: what Gitea's ci.yml and + # publish.yml wait on. Skipped jobs (the tag jobs on a branch, and the other + # way round) count as passing; a failed or cancelled one does not. + report: + if: ${{ always() && vars.BUILD_ON == 'github' }} + needs: [start, fork-checks, build, version, publish, index, release, binaries] + runs-on: ubuntu-latest + steps: + - env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + STATE: ${{ (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) && 'failure' || 'success' }} + run: | + jq -n --arg s "$STATE" --arg c "$STATUS_CONTEXT" \ + --arg u "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \ + '{state:$s, context:$c, target_url:$u, description:"GitHub Actions"}' | + curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" \ + -H 'Content-Type: application/json' --data @- \ + "$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" + echo "$STATUS_CONTEXT: $STATE" diff --git a/.github/workflows/cleanup.yml b/.github/workflows/cleanup.yml deleted file mode 100644 index dd46cf1..0000000 --- a/.github/workflows/cleanup.yml +++ /dev/null @@ -1,69 +0,0 @@ -# Prune old image versions from GHCR. -# -# Releases are kept forever -- they carry no assets and their generated notes -# are this project's only changelog, so deleting one destroys history that -# cannot be reconstructed for nothing saved. Images are the opposite: a -# multi-arch build a week, and the by-digest push in publish.yml leaves two -# untagged per-architecture manifests behind each time on top of the tagged -# index. Those accumulate and nobody wants fifty of them. -# -# THE FOOTGUN: the obvious tool for this -- delete-package-versions with -# `delete-only-untagged-versions` -- will happily delete the per-architecture -# manifests that a multi-arch tag points *at*, because they are untagged by -# design. Nothing appears to break: the tag still exists, and pulls simply -# start failing for one architecture. This action understands manifest lists -# and will not orphan a retained index, and `validate` re-checks every -# multi-arch manifest against the registry afterwards. -# -# Separate from publish.yml, and dispatchable on its own, so `dry_run` can show -# exactly what would be deleted without rebuilding and re-pushing an image to -# find out. -name: Prune images - -on: - workflow_call: - inputs: - dry_run: - type: boolean - default: false - workflow_dispatch: - inputs: - dry_run: - description: "List what would be deleted, delete nothing" - type: boolean - default: true - -jobs: - prune: - runs-on: ubuntu-latest - permissions: - packages: write - steps: - # The only third-party action here that is not published by GitHub or - # Docker, and the one with the most to lose: it is handed - # `packages: write` and its whole job is deletion, so a ref repointed at - # something else -- by a compromise or a mistake upstream -- is a bad - # day. It was pinned to a commit long before the rest of them were. - - uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f # v1.2.2 - with: - owner: inbuxa - package: inbuxa-server - token: ${{ secrets.GITHUB_TOKEN }} - # Ten weekly releases is roughly a quarter of history, which is more - # than enough to roll back to and far less than the year's worth that - # would otherwise pile up. Older *releases* stay either way; this - # only removes the images. - keep-n-tagged: 10 - # Belt and braces on top of the action's own manifest awareness: - # `latest` is never a candidate for deletion under any counting. - exclude-tags: latest - delete-untagged: true - # Sweeps the wreckage of a half-failed run: an index whose platform - # images did not all land, and referrers whose parent is gone. - delete-partial-images: true - delete-orphaned-images: true - # Checks every remaining multi-architecture manifest still resolves - # in the registry. This is the step that would catch the footgun - # above rather than leaving a reader to discover it on `docker pull`. - validate: true - dry-run: ${{ inputs.dry_run }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index 1681796..0000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,198 +0,0 @@ -# Publish the container image to GHCR. -# -# The README and the docs site have told people to run -# `ghcr.io/inbuxa/inbuxa-server:latest` for a long time, and nothing ever -# pushed it: `docker pull` answered `denied`, because the package did not -# exist. This is the workflow that makes those instructions true. It is also -# the prerequisite for the self-hosted app catalogs -- TrueNAS and Unraid -# both install by pulling an image and neither builds from source. -# -# FIRST RUN: a package GHCR creates for the first time is **private**, even in -# a public repository, and an anonymous `docker pull` will still answer -# `denied`. Nothing in a workflow can change that -- the visibility is set once -# by hand under the package's settings, and until it is, this looks like it -# worked while the docs stay just as wrong as before. Check with a logged-out -# pull, not with one from a machine that has credentials. -# -# Two architectures, each built on its own native runner rather than under -# QEMU. Emulated arm64 has to run `npm ci` and the Vite build through -# instruction translation, which takes tens of minutes and occasionally runs -# out of memory; `ubuntu-24.04-arm` is free for public repositories and does -# the same work at native speed. The cost is the by-digest dance below: each -# runner pushes an untagged image, and a final job joins the two digests into -# one multi-arch tag. -name: Publish image - -on: - release: - types: [published] - # Callable, so release.yml can build the release it just cut. This is not a - # stylistic choice: a release created with GITHUB_TOKEN does **not** raise a - # `release` event -- GitHub refuses to let a token trigger another workflow, - # to stop a workflow looping on its own output. A scheduled job that cut a - # release and expected this file to notice would silently never publish. The - # alternatives are a personal access token kept as a secret, or calling the - # workflow directly. This is the one that needs no credential. - workflow_call: - inputs: - ref: - description: "Tag, branch or SHA to build" - required: true - type: string - tag_latest: - description: "Also move :latest to this build" - type: boolean - default: false - # Same reasoning as ci.yml's dispatch trigger: a run GitHub queues and then - # orphans can be neither rerun nor canceled, and this workflow otherwise - # only fires on a release -- which is not something to cut twice because a - # runner died. `ref` also allows publishing an image for a tag that predates - # this workflow, which is how the first one gets built. - workflow_dispatch: - inputs: - ref: - description: "Tag, branch or SHA to build" - required: true - default: main - tag_latest: - description: "Also move :latest to this build" - type: boolean - default: false - -env: - # Hardcoded rather than derived from github.repository, which would have to - # be lowercased to be a legal registry path. This is the string the docs name. - IMAGE: ghcr.io/inbuxa/inbuxa-server - -jobs: - # The version is read once and handed to both builds, so the two - # architectures cannot disagree about what they are. It is read from the - # macro the binary itself compiles in, which the weekly release commits - # before this runs -- so the image is tagged with the version it reports. - version: - runs-on: ubuntu-latest - outputs: - version: ${{ steps.v.outputs.version }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs.ref || github.ref }} - - id: v - run: | - set -euo pipefail - # Scoped to the macro body: branding.rs holds other string literals, - # and tagging an image from one of those would be worse than failing. - V="$(awk '/macro_rules! brand_version/,/^}/' crates/types/src/branding.rs \ - | grep -om1 '"[0-9][^"]*"' | tr -d '"')" - [ -n "$V" ] || { echo "could not read brand_version! from branding.rs" >&2; exit 1; } - # A date version carries nothing a Docker tag objects to, so there is - # no second, sanitized form of it here. - echo "version=$V" >> "$GITHUB_OUTPUT" - echo "version $V" - - build: - needs: version - runs-on: ${{ matrix.runner }} - permissions: - contents: read - packages: write - strategy: - fail-fast: false - matrix: - include: - - platform: linux/amd64 - runner: ubuntu-latest - - platform: linux/arm64 - runner: ubuntu-24.04-arm - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs.ref || github.ref }} - - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 - - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push by digest - id: push - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 - with: - context: . - platforms: ${{ matrix.platform }} - # Attestations are off deliberately: they add manifests of their own - # to the index, and `imagetools create` below expects the two entries - # it pushed rather than four. - provenance: false - sbom: false - cache-from: type=gha,scope=${{ matrix.platform }} - cache-to: type=gha,mode=max,scope=${{ matrix.platform }} - outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true - - name: Save the digest - run: | - mkdir -p /tmp/digests - # The prefix is stripped here and put back in the merge job, so the - # filename is the bare hash. Leaving it on produces - # `image@sha256:sha256:...` when the reference is rebuilt. - digest="${{ steps.push.outputs.digest }}" - touch "/tmp/digests/${digest#sha256:}" - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - # One artifact per platform; the merge job globs them back together. - name: digest-${{ strategy.job-index }} - path: /tmp/digests/* - retention-days: 1 - if-no-files-found: error - - # Joins the per-architecture digests into a single tagged manifest, so - # `docker pull ghcr.io/inbuxa/inbuxa-server:` resolves on both. - publish: - needs: [version, build] - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - path: /tmp/digests - pattern: digest-* - merge-multiple: true - - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 - - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Create the manifest - run: | - # Arrays rather than a string: the tags and the digest references - # have to reach docker as separate arguments, and building them by - # word-splitting an unquoted variable is the version of this that - # breaks the day a value contains a space. - tags=(-t "${IMAGE}:${{ needs.version.outputs.version }}") - # :latest follows real releases only. A prerelease that moved it - # would hand every `:latest` deployment an unfinished build, and a - # dispatch run has to ask for it on purpose. - if [ "${{ github.event_name }}" = "release" ] && [ "${{ github.event.release.prerelease }}" = "false" ]; then - tags+=(-t "${IMAGE}:latest") - elif [ "${{ inputs.tag_latest }}" = "true" ]; then - tags+=(-t "${IMAGE}:latest") - fi - refs=() - for f in /tmp/digests/*; do - refs+=("${IMAGE}@sha256:$(basename "$f")") - done - echo "tags: ${tags[*]}" - echo "refs: ${refs[*]}" - docker buildx imagetools create "${tags[@]}" "${refs[@]}" - - name: Show what landed - run: docker buildx imagetools inspect "${IMAGE}:${{ needs.version.outputs.version }}" - - # Runs only after a successful publish, because that is the only moment the - # package grows. See cleanup.yml for why this is not the obvious one-liner. - prune: - needs: publish - permissions: - packages: write - uses: ./.github/workflows/cleanup.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index bccb9b2..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,246 +0,0 @@ -# Cut a release once a week, but only if there is something in it. -# -# It does nothing on a quiet week. A release with no commits in it is worse -# than no release: it moves `:latest` to an identical build, spends a version -# number, and mails everybody watching the repository about nothing. -# -# INBUXA's version is a string in crates/types/src/branding.rs, deliberately -# not in Cargo.toml so that upstream's version bumps merge without conflicts. -# So this writes it: the bump is committed to main, and the tag names that -# commit. The tree a tag points at therefore reports the version the tag -# claims, which a tag placed beside an unbumped macro cannot promise. -name: Weekly release - -on: - schedule: - # Mondays, 10:07 UTC, and last of the three: INBUXA Admin and the webmail - # release ahead of the server they talk to. Staggered rather than - # simultaneous so three releases do not compete for runners, and so a bad - # Monday names one repository instead of three. GitHub runs scheduled jobs - # best-effort and can delay a run considerably, so the exact minute is not - # a promise; the odd minute keeps it off the crowded top of the hour. - # - # Note also that GitHub disables scheduled workflows in a repository with - # no activity for 60 days, which is worth checking for before assuming - # this file is broken. - - cron: "7 10 * * 1" - workflow_dispatch: - inputs: - dry_run: - description: "Work out what would be released, then stop" - type: boolean - default: false - -# One at a time. Two overlapping runs would race to write the same version and -# create the same tag, and the loser fails noisily for a reason that has -# nothing to do with the code. -concurrency: - group: weekly-release - cancel-in-progress: false - -jobs: - check: - runs-on: ubuntu-latest - permissions: - contents: read - outputs: - should_release: ${{ steps.decide.outputs.should_release }} - version: ${{ steps.decide.outputs.version }} - tag: ${{ steps.decide.outputs.tag }} - previous: ${{ steps.decide.outputs.previous }} - count: ${{ steps.decide.outputs.count }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: main - fetch-depth: 0 - - id: decide - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - - # The newest published release, or empty on a repository that has - # never had one -- in which case everything counts as new. Drafts are - # excluded: an unpublished draft is not a release anybody has, so - # counting from it would hide commits that have never shipped. - previous="$(gh release list --limit 1 --exclude-drafts --json tagName --jq '.[0].tagName // ""')" - # A tag named by a release is normally present after a full checkout, - # but a release can outlive its tag. Falling back to the whole - # history is the safe direction to be wrong in: it over-counts, which - # cuts a release that was due anyway, where under-counting would skip - # one that was. - if [ -n "$previous" ] && git rev-parse -q --verify "refs/tags/${previous}" >/dev/null; then - count="$(git rev-list --count "${previous}..HEAD")" - else - count="$(git rev-list --count HEAD)" - fi - - # INBUXA's version is the date: YYYY.M.D, unpadded, as branding.rs - # documents. A second release on one day takes a `.N` suffix, - # counting from 2, which is why this asks the tags rather than - # assuming today is free. - today="$(date -u +%Y.%-m.%-d)" - version="$today" - n=2 - while git rev-parse -q --verify "refs/tags/v${version}" >/dev/null; do - version="${today}.${n}" - n=$((n + 1)) - done - - should_release=true - reason="" - if [ "$count" -eq 0 ]; then - should_release=false - reason="no commits since ${previous}" - fi - - { - echo "should_release=$should_release" - echo "version=$version" - echo "tag=v${version}" - echo "previous=$previous" - echo "count=$count" - } >> "$GITHUB_OUTPUT" - - # Written to the run summary so a skipped week reads as a decision - # rather than as a workflow that quietly did nothing. - { - echo "### Weekly release" - echo - if [ "$should_release" = "true" ]; then - echo "Releasing **v${version}** — ${count} commit(s) since ${previous:-the beginning}." - else - echo "Nothing to release: ${reason}." - fi - } >> "$GITHUB_STEP_SUMMARY" - - cut: - needs: check - if: needs.check.outputs.should_release == 'true' && !inputs.dry_run - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - outputs: - sha: ${{ steps.land.outputs.sha }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: main - fetch-depth: 0 - - id: bump - env: - VERSION: ${{ needs.check.outputs.version }} - BRANCH: release/v${{ needs.check.outputs.version }} - run: | - set -euo pipefail - - # Scoped to the macro body rather than replacing the first quoted - # string in the file, and asserted to have matched exactly once. - # branding.rs holds other string literals, and a bump that silently - # edited one of those -- or none -- would ship a build whose version - # disagrees with its tag. - python3 - <<'PY' - import os, re - path = "crates/types/src/branding.rs" - src = open(path, encoding="utf-8").read() - pattern = re.compile(r'(macro_rules! brand_version \{\s*\(\) => \{\s*")[^"]+(")') - out, n = pattern.subn(lambda m: m.group(1) + os.environ["VERSION"] + m.group(2), src, count=1) - assert n == 1, f"brand_version! not found in {path}" - open(path, "w", encoding="utf-8").write(out) - PY - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add crates/types/src/branding.rs - git commit -m "Version ${VERSION}" - git push origin "HEAD:refs/heads/${BRANCH}" - - # main is protected: it takes a pull request with a green build, and - # GITHUB_TOKEN is not among the bypass actors. So the bump lands the way - # every other change does. The alternative was to hand the release a - # credential that outranks the rule, which is a worse thing to own than - # a slower Monday. - - id: land - env: - VERSION: ${{ needs.check.outputs.version }} - BRANCH: release/v${{ needs.check.outputs.version }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - - url="$(gh pr create --base main --head "${BRANCH}" \ - --title "Version ${VERSION}" \ - --body "Weekly release. Bumps \`brand_version!\` to ${VERSION} so the tag names a tree that reports the version the tag claims.")" - # The number, not the branch: the branch is deleted on merge, and a - # deleted branch no longer resolves to its pull request. - pr="${url##*/}" - echo "Opened #${pr}" - - # The build is what the rule actually requires, and it is also the - # thing worth waiting for: a release cut from a tree that does not - # compile is the failure this whole arrangement exists to prevent. - # A full build of this tree is long, so the deadline is generous. - deadline=$(( SECONDS + 3600 )) - while :; do - state="$(gh pr view "${pr}" --json statusCheckRollup \ - --jq '[.statusCheckRollup[]? | .conclusion // "PENDING"] | join(",")')" - case "${state}" in - *FAILURE*|*CANCELLED*|*TIMED_OUT*) - echo "::error::CI failed on ${BRANCH} (${state}); no release cut. PR #${pr} is left open." - exit 1 ;; - *SUCCESS*) break ;; - esac - if [ "${SECONDS}" -ge "${deadline}" ]; then - echo "::error::timed out waiting for CI on ${BRANCH}. PR #${pr} is left open." - exit 1 - fi - sleep 30 - done - - gh pr merge "${pr}" --rebase --delete-branch - - # A rebase merge rewrites the commit, so the sha to tag is the one - # GitHub recorded for the merge, not the tip that was pushed. It can - # take a moment to appear. - sha="" - for _ in $(seq 1 30); do - sha="$(gh pr view "${pr}" --json mergeCommit --jq '.mergeCommit.oid // ""')" - [ -n "${sha}" ] && break - sleep 5 - done - if [ -z "${sha}" ]; then - echo "::error::#${pr} merged but GitHub reported no merge commit; nothing safe to tag." - exit 1 - fi - - echo "sha=${sha}" >> "$GITHUB_OUTPUT" - - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - args=(--target "${{ steps.land.outputs.sha }}" - --title "INBUXA ${{ needs.check.outputs.version }}" - --generate-notes) - # Bound the notes to what is actually new. Without a start tag the - # generator reaches back to whatever it decides is previous, which on - # a repository carrying upstream's tag shapes is not always the last - # release. - if [ -n "${{ needs.check.outputs.previous }}" ]; then - args+=(--notes-start-tag "${{ needs.check.outputs.previous }}") - fi - gh release create "${{ needs.check.outputs.tag }}" "${args[@]}" - - # Called rather than left to the `release` trigger on purpose: see the note - # at the top of publish.yml. A release created with GITHUB_TOKEN raises no - # event, so without this the tag would exist and no image would follow it. - publish: - needs: [check, cut] - permissions: - contents: read - packages: write - uses: ./.github/workflows/publish.yml - with: - ref: ${{ needs.cut.outputs.sha }} - tag_latest: true