From 216ebd4dfea17a5fe695442da7cd1c3ad3329779 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 29 Sep 2026 23:06:27 -0700 Subject: [PATCH] Build on GitHub Actions when BUILD_ON=github Gitea stays the source of truth and push-mirrors this repository to GitHub. The org variable BUILD_ON, set on both forges, picks where the heavy work runs: - unset: nothing changes. Gitea's jobs run as before and every job in the GitHub workflow is skipped. - github: Gitea skips its test, build and publish jobs. GitHub Actions runs them on hosted runners, arm64 natively rather than under QEMU, publishes to the same Gitea registry, and posts a commit status back to Gitea. A new `github` job in Gitea's ci.yml waits for that status and passes or fails with it, so the Gitea run still decides a PR. Announcing and releasing stay on Gitea whatever BUILD_ON says. The GitHub-era workflows go: cleanup.yml pruned GHCR, release.yml was a second weekly scheduler, and publish.yml pushed to GHCR. Their work is in the new .github/workflows/ci.yml or stays on Gitea. dependabot.yml goes too: its pull request branches would exist only on GitHub, and every mirror sync would delete them. --- .gitea/workflows/ci.yml | 66 ++++++++- .github/dependabot.yml | 44 ------ .github/workflows/ci.yml | 266 +++++++++++++++++++++++++++++++--- .github/workflows/cleanup.yml | 69 --------- .github/workflows/publish.yml | 206 -------------------------- .github/workflows/release.yml | 163 --------------------- 6 files changed, 301 insertions(+), 513 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 a49a539..68d6648 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,6 +1,19 @@ # CI on the self-hosted Gitea, ported from .gitlab-ci.yml during the move off # GitLab (2026-09-22). Gitea reads .gitea/workflows and ignores .github/ once -# this directory exists; .github/workflows stays as it was for GitHub. +# this directory exists; .github/workflows is the GitHub side, below. +# +# WHERE THE BUILD RUNS. Gitea push-mirrors this repository to GitHub, and the +# org variable BUILD_ON picks which forge does the heavy work: +# * unset (or anything but `github`): every job here runs, as it always did, +# and GitHub's workflow skips all of its jobs. +# * `github`: the test, build and publish jobs here are skipped, GitHub +# Actions runs .github/workflows/ci.yml on its hosted runners (native +# arm64, no QEMU), and the `github` job below waits for the commit status +# that run posts back, passing or failing with it. So this run's result is +# still the one that counts, for a PR's checks as for anything that merges +# on green CI. The variable is set on both forges, and must agree. +# If GitHub is ever unavailable, unsetting BUILD_ON here is the whole +# fallback: the jobs below take over again unchanged. # # Releases are cut by pushing a tag named `inbuxa-v`, where # is what scripts/version.mjs says for the tagged commit with the @@ -35,6 +48,7 @@ concurrency: jobs: # -------------------------------------------------------------- test ------ node: + if: ${{ vars.BUILD_ON != 'github' }} runs-on: light container: image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim @@ -73,7 +87,7 @@ jobs: # equivalent of ci.yml's final `docker build -t ihasmail:ci .` step. The # Dockerfile builds everything itself; `needs` only keeps the order. docker-build: - if: ${{ !startsWith(github.ref, 'refs/tags/') }} + if: ${{ vars.BUILD_ON != 'github' && !startsWith(github.ref, 'refs/tags/') }} needs: [node] runs-on: docker container: @@ -93,7 +107,7 @@ jobs: # all agree, and the commit has to be on main, so a release never describes # code that was not reviewed onto the default branch. version: - if: ${{ startsWith(github.ref, 'refs/tags/inbuxa-v') }} + if: ${{ vars.BUILD_ON != 'github' && startsWith(github.ref, 'refs/tags/inbuxa-v') }} runs-on: light container: image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim @@ -125,7 +139,7 @@ jobs: # the job's own token is refused by the container registry. The release is # created last, so a release on the page always has its image behind it. publish: - if: ${{ startsWith(github.ref, 'refs/tags/inbuxa-v') }} + if: ${{ vars.BUILD_ON != 'github' && startsWith(github.ref, 'refs/tags/inbuxa-v') }} needs: [node, version] runs-on: docker container: @@ -180,10 +194,13 @@ 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. + # 'on: release' never fires for it -- so announce it from here. With + # BUILD_ON=github the release is created by GitHub's run instead, and this + # follows the `github` job. Announcing stays on Gitea either way; the + # action posts once per tag, so a second attempt is a no-op. announce: - needs: [publish] - if: ${{ startsWith(github.ref, 'refs/tags/inbuxa-v') }} + needs: [publish, github] + if: ${{ always() && startsWith(github.ref, 'refs/tags/inbuxa-v') && (needs.publish.result == 'success' || needs.github.result == 'success') }} runs-on: light steps: - uses: coffey-labs/actions/discourse-release@e9293996e2efa770839121fa8f8da93083f216be @@ -191,3 +208,38 @@ jobs: api-key: ${{ secrets.DISCOURSE_RELEASE_KEY }} discord-webhook: ${{ secrets.DISCORD_RELEASE_WEBHOOK }} tag: ${{ github.ref_name }} + + # ------------------------------------------------------------ github ------ + # With BUILD_ON=github, the work above happens in GitHub Actions, which + # posts one commit status back here when it finishes: "github/ci (branch)" + # for a branch push, "github/ci (tag)" for a tag. This job waits for that + # status on the commit under test -- the PR's head for a pull request -- and + # passes or fails with it. Nothing arriving within the timeout means GitHub + # never built the commit (a mirror that failed to sync, or GitHub being + # down): check the mirror, or unset BUILD_ON to build here. + github: + if: ${{ vars.BUILD_ON == 'github' }} + runs-on: light + timeout-minutes: 150 + container: + image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + SHA: ${{ github.event.pull_request.head.sha || github.sha }} + CONTEXT: github/ci (${{ github.ref_type == 'tag' && 'tag' || 'branch' }}) + steps: + - run: apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates curl jq >/dev/null + - shell: bash + run: | + set -uo pipefail + url="$CI_SERVER_INTERNAL/api/v1/repos/$GITHUB_REPOSITORY/commits/$SHA/statuses?limit=50" + echo "waiting for '$CONTEXT' on $SHA" + while :; do + state="$(curl -fsS -H "Authorization: token $TOKEN" "$url" \ + | jq -r --arg c "$CONTEXT" '[.[] | select(.context == $c)] | sort_by(.id) | last | .status // empty')" + case "$state" in + success) echo "GitHub reported success"; exit 0 ;; + failure|error) echo "GitHub reported $state -- see the status's link for the run" >&2; exit 1 ;; + esac + sleep 20 + done diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index cab9c31..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,44 +0,0 @@ -version: 2 -updates: - # The npm entry sits at the root because that is where the single lockfile - # is: root, server and web are one npm workspace, so one entry covers all - # three. Pointing entries at server/ or web/ would find package.json files - # with no lockfile beside them and update nothing. - - package-ecosystem: npm - directory: "/" - schedule: - interval: weekly - day: tuesday - time: "09:00" - timezone: Etc/UTC - open-pull-requests-limit: 5 - groups: - # Everything routine arrives as one PR a week, so the dashboard is not - # the only place these get noticed. Majors are deliberately left out of - # the group: they are migrations, not bumps -- vitest 3 to 4 is one -- - # and each deserves its own PR and its own CI run. - 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 runtime and build stages both pin node:22-alpine, so this is what - # keeps the published container images off a stale base between the weekly - # 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 5dfc44a..f3d682e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,39 +1,257 @@ -name: CI +# CI and publishing on GitHub Actions, for a repository whose source of truth +# is the self-hosted Gitea. Gitea push-mirrors every commit and tag here, and +# this workflow does the heavy work on GitHub's hosted runners -- native arm64 +# included -- then reports the result back to Gitea as a commit status. +# +# THE SWITCH. Every job here runs only when the org variable BUILD_ON is +# `github`. Gitea's .gitea/workflows/ci.yml reads the same variable (set on +# the Gitea org too): with it set, Gitea skips its own build jobs and waits for +# the status this workflow posts; without it, Gitea builds everything itself, +# exactly as before, and every job here is skipped. If GitHub is ever +# unavailable, unsetting BUILD_ON on Gitea is the whole fallback. +# +# There is no pull_request trigger: pull requests live on Gitea. A PR's branch +# arrives here as an ordinary push, and the status lands on its head commit, +# which is where Gitea's PR looks for it. +# +# Releases are cut by pushing a tag named `inbuxa-v` (see +# .gitea/workflows/ci.yml for why the prefix matters: this repository carries +# upstream ihasmail's own `v...` tags, and only `inbuxa-v` tags publish). +# +# Org configuration, not in this file: +# vars.BUILD_ON `github` to build here +# vars.REGISTRY the Gitea container registry's DNS-only name +# vars.GITEA_URL Gitea's public URL, for statuses, releases and packages +# secrets.GITEA_TOKEN jcoffey-dev, write:repository + write:package +# +# Every `uses:` is pinned to a full commit SHA with the release in the +# trailing comment. A tag is a mutable pointer, so trusting `@v7` is trusting +# every future version of that action. Do not "simplify" a pin back to a tag. +name: ci + on: push: - branches: [main] - pull_request: - # Lets CI be run by hand against any ref, including a specific commit. - # Without this there is no way to re-run a check that never started: a run - # GitHub queues and then orphans -- as it did to every run created during the - # Actions outage on 2026-08-26 -- can be neither rerun ("already running") - # nor canceled ("already completed"), and the workflow has no other trigger - # to reach for. Useful too for putting a check on a commit that predates a CI - # change, without pushing an empty commit to move it. + branches: ['**'] + tags: ['**'] workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + GITEA_URL: ${{ vars.GITEA_URL }} + REGISTRY: ${{ vars.REGISTRY }} + # A registry path must be lowercase; the repository name already is. + IMAGE: ${{ vars.REGISTRY }}/inbuxa/ihasmail-inbuxa + STATUS_CONTEXT: github/ci (${{ github.ref_type }}) + jobs: - build: + # Tells Gitea a result is on its way, so a PR shows the check as running + # rather than missing. + pending: + 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" + + # -------------------------------------------------------------- test ------ + # version.test.ts shells out to git to resolve a build version from the + # history, so the checkout is a full one. The hosted runner runs as an + # unprivileged user, so config.test.ts's read-only directory holds here + # without the `su node` Gitea needs. + node: + if: ${{ vars.BUILD_ON == 'github' }} runs-on: ubuntu-latest steps: - # Every `uses:` in this repository is pinned to a full commit SHA, with - # the release it belongs to in the trailing comment, and the repository - # requires it -- an unpinned ref fails the run rather than quietly - # resolving. A tag is a mutable pointer: `@v7` is whatever the publisher - # last moved it to, so trusting one is trusting every future version of - # that action, including the one pushed by whoever compromises the - # account. Read the comment for the version; the SHA is what runs. - # - # Dependabot updates both halves together on its weekly github-actions - # run, so this costs nothing to keep current -- do not "simplify" a pin - # back to a tag. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 26 cache: npm + # --ignore-scripts: a postinstall script in any transitive dependency + # would otherwise run with the job's credentials in its environment. - run: npm ci --ignore-scripts - run: npm run typecheck - run: npm test - run: npm run build - - name: Docker build - run: docker build -t ihasmail:ci . + + # ------------------------------------------------------------- build ------ + # Proves the Dockerfile still builds on every change, without pushing. + docker-build: + if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'branch' }} + needs: [node] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - run: docker build -t "ihasmail:ci-${GITHUB_SHA::8}" . + + # ----------------------------------------------------------- release ------ + # Only for `inbuxa-v` tags. The tag has to name its own commit's version, so + # the image, the release and the About screen all agree, and the commit has + # to be on main, so a release never describes code that was not reviewed + # onto the default branch. + version: + if: ${{ vars.BUILD_ON == 'github' && startsWith(github.ref, 'refs/tags/inbuxa-v') }} + runs-on: ubuntu-latest + outputs: + version: ${{ steps.v.outputs.version }} + docker_tag: ${{ steps.v.outputs.docker_tag }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 26 + - id: v + env: + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + V="$(node scripts/version.mjs)" + want="inbuxa-v${V/+/-}" + [ "$TAG" = "$want" ] || { echo "::error::$TAG does not name this commit's version; expected $want"; exit 1; } + git merge-base --is-ancestor "$(git rev-parse "${TAG}^{commit}")" origin/main \ + || { echo "::error::$TAG is not on main"; exit 1; } + echo "version=$V" >> "$GITHUB_OUTPUT" + # A Docker tag may not contain '+', so build metadata becomes '-'. + echo "docker_tag=${V/+/-}" >> "$GITHUB_OUTPUT" + echo "version $V -> tag ${V/+/-}" + + # Each architecture on its own native runner, pushed as an untagged image by + # digest; `publish` joins the two digests into one multi-arch tag. + build: + if: ${{ vars.BUILD_ON == 'github' && startsWith(github.ref, 'refs/tags/inbuxa-v') }} + needs: [node, version] + runs-on: ${{ matrix.runner }} + 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 + - 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 }} + - name: Build and push by digest + id: push + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + platforms: ${{ matrix.platform }} + build-args: IHASMAIL_VERSION=${{ needs.version.outputs.version }} + # Attestations add manifests of their own to the index, and + # `imagetools create` below expects the two entries pushed here. + 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 + env: + DIGEST: ${{ steps.push.outputs.digest }} + run: | + mkdir -p /tmp/digests + # Bare hash as the filename; the prefix is put back when joining. + touch "/tmp/digests/${DIGEST#sha256:}" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: digest-${{ strategy.job-index }} + path: /tmp/digests/* + retention-days: 1 + if-no-files-found: error + + # Joins the digests into `:` and `:latest`, links the package to + # the repository on Gitea, then creates the release there -- last, so a + # release on the page always has its image behind it. The release is made + # with GITEA_TOKEN, a user's token, so Gitea's announce.yml fires for it; + # Gitea's ci.yml announces as well once this run's status arrives, and the + # announce action posts once per tag whichever gets there first. + publish: + if: ${{ vars.BUILD_ON == 'github' && startsWith(github.ref, 'refs/tags/inbuxa-v') }} + needs: [version, build] + runs-on: ubuntu-latest + 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: ${{ vars.REGISTRY }} + username: jcoffey-dev + password: ${{ secrets.GITEA_TOKEN }} + - name: Create the manifest + env: + DOCKER_TAG: ${{ needs.version.outputs.docker_tag }} + run: | + refs=() + for f in /tmp/digests/*; do refs+=("${IMAGE}@sha256:$(basename "$f")"); done + docker buildx imagetools create -t "${IMAGE}:${DOCKER_TAG}" -t "${IMAGE}:latest" "${refs[@]}" + docker buildx imagetools inspect "${IMAGE}:${DOCKER_TAG}" + # Shows the package on the repository's Packages tab. Idempotent. + - env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" \ + "$GITEA_URL/api/v1/packages/inbuxa/container/ihasmail-inbuxa/-/link/ihasmail-inbuxa" \ + || echo "package already linked (or link refused); not fatal" + - env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + TAG: ${{ github.ref_name }} + VERSION: ${{ needs.version.outputs.version }} + DOCKER_TAG: ${{ needs.version.outputs.docker_tag }} + run: | + set -eu + API="$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/releases" + # A re-run finds the release already there. + if curl -fsS -o /dev/null -H "Authorization: token $GITEA_TOKEN" "$API/tags/$TAG"; then + echo "release $TAG already exists"; exit 0 + fi + body="$(printf 'INBUXA webmail %s.\n\nImage: `%s:%s` (linux/amd64, linux/arm64), also tagged `latest`.' "$VERSION" "$IMAGE" "$DOCKER_TAG")" + jq -n --arg tag "$TAG" --arg body "$body" '{tag_name:$tag, name:$tag, body:$body}' \ + | curl -fsS -o /dev/null -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" \ + --data @- "$API" + echo "release $TAG created" + + # One commit status on Gitea for the whole run: what Gitea's ci.yml waits + # for, and what a Gitea PR shows. + report: + if: ${{ always() && vars.BUILD_ON == 'github' }} + needs: [pending, node, docker-build, version, build, publish] + 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 "reported $STATE as '$STATUS_CONTEXT'" diff --git a/.github/workflows/cleanup.yml b/.github/workflows/cleanup.yml deleted file mode 100644 index 95fb156..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: Coffey-Labs - package: ihasmail - 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 2428663..0000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,206 +0,0 @@ -# Publish the container image to GHCR. -# -# The README and the docs site have told people to run -# `ghcr.io/coffey-labs/ihasmail: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: a registry path must - # be lowercase and the owner is spelled `Coffey-Labs`, so deriving it means - # remembering to lowercase it. This is the string the docs already name. - # inbuxa: this fork publishes to INBUXA's own path. Inherited from public - # ihasmail, which publishes ghcr.io/coffey-labs/ihasmail -- leaving that - # here would push INBUXA's webmail over the image every public ihasmail - # install pulls, which SPEC.md 5 exists to prevent. - IMAGE: ghcr.io/inbuxa/ihasmail-inbuxa - -jobs: - # The version is worked out once and handed to both builds, so the two - # architectures cannot disagree about what they are. scripts/version.mjs - # reads the commit date and how the commit arrived, so it needs real history - # rather than a shallow clone. - version: - runs-on: ubuntu-latest - outputs: - version: ${{ steps.v.outputs.version }} - docker_tag: ${{ steps.v.outputs.docker_tag }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs.ref || github.ref }} - fetch-depth: 0 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 26 - - id: v - run: | - V="$(node scripts/version.mjs)" - echo "version=$V" >> "$GITHUB_OUTPUT" - # A Docker tag may not contain '+', so build metadata becomes '-'. - # The build is still *told* the real form, which is what About and - # /api/health report. - echo "docker_tag=${V/+/-}" >> "$GITHUB_OUTPUT" - echo "version $V -> tag ${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 }} - build-args: IHASMAIL_VERSION=${{ needs.version.outputs.version }} - # 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/coffey-labs/ihasmail:` 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.docker_tag }}") - # :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.docker_tag }}" - - # 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 be509e9..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,163 +0,0 @@ -# Cut a release once a week, but only if there is something in it. -# -# Releases had drifted 184 commits behind main, which made `:latest` describe -# a build nobody was running -- the demo, prod and anyone building from source -# were all ahead of it. Publishing on release is the right trigger only if -# releases actually happen, so this is the part that makes that true without -# anyone having to remember. -# -# 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. -name: Weekly release - -on: - schedule: - # Mondays, 09:17 UTC. GitHub runs scheduled jobs on a best-effort basis and - # can delay a run by a good while when the queue is busy, so do not read - # the exact minute as a promise. The odd minute is deliberate: the top of - # the hour is when most schedules fire, and at 09:00 the first scheduled - # run started almost six hours late and the second had not started at all - # four and a half hours in. Moving off the hour does not make GitHub keep - # time, but it stops competing for the busiest slot. A missed week can be - # cut by hand with workflow_dispatch; a late scheduled run that follows - # finds the tag already there and does nothing. - # - # Note also that GitHub disables scheduled workflows in a repository with - # no activity for 60 days -- not a concern while this one is being worked - # on weekly, but it is why a silent stop is worth checking for before - # assuming the file is broken. - - cron: "17 9 * * 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 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 }} - tag: ${{ steps.decide.outputs.tag }} - title: ${{ steps.decide.outputs.title }} - sha: ${{ steps.decide.outputs.sha }} - previous: ${{ steps.decide.outputs.previous }} - count: ${{ steps.decide.outputs.count }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: main - fetch-depth: 0 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 26 - - 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 - - version="$(node scripts/version.mjs)" - # A Docker tag may not contain '+', and neither should the git tag, - # so the two always agree about what to call a build. - tag="v${version/+/-}" - title="v${version%%+*}" - sha="$(git rev-parse HEAD)" - - should_release=true - reason="" - if [ "$count" -eq 0 ]; then - should_release=false - reason="no commits since ${previous}" - elif git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then - # Same commit, different week: the version is derived from the - # commit, so nothing new means the tag already exists. - should_release=false - reason="tag ${tag} already exists" - fi - - { - echo "should_release=$should_release" - echo "tag=$tag" - echo "title=$title" - echo "sha=$sha" - 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 **${tag}** — ${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 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: main - fetch-depth: 0 - - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - args=(--target "${{ needs.check.outputs.sha }}" - --title "${{ needs.check.outputs.title }}" - --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 with older 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.check.outputs.sha }} - tag_latest: true -- 2.54.0