From d9fd02256a5697ba3b99df59ac351c9aa651d3a9 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 29 Sep 2026 23:04:33 -0700 Subject: [PATCH] ci: build on GitHub via the mirror, switchable with BUILD_ON Gitea stays the source of truth and push-mirrors every commit and tag to GitHub. With the organization variable BUILD_ON=github, GitHub's ci.yml runs the tests, the Docker build check and, on tags, the multi-arch image publish on native amd64 and arm64 runners, pushing to the Gitea registry and posting a commit status back. Gitea's build jobs skip, and a new `github` job waits for that status so the Gitea run still passes or fails with the real result. With BUILD_ON unset nothing changes: GitHub's jobs skip and Gitea builds as before, which is also the fallback if GitHub is unavailable. The GitHub-era release, publish and cleanup workflows are removed -- the weekly release is cut on Gitea and images no longer go to GHCR -- and so is dependabot.yml, whose pull request branches every mirror sync would delete. --- .gitea/workflows/ci.yml | 51 ++++++- .github/dependabot.yml | 44 ------ .github/workflows/ci.yml | 251 ++++++++++++++++++++++++++++++---- .github/workflows/cleanup.yml | 69 ---------- .github/workflows/publish.yml | 202 --------------------------- .github/workflows/release.yml | 163 ---------------------- 6 files changed, 274 insertions(+), 506 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 0b1fc77..c099ab3 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,6 +1,14 @@ # 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. +# +# THE SWITCH is the organization variable BUILD_ON. With BUILD_ON=github the +# build jobs below skip: the mirror carries each commit and tag to GitHub, +# .github/workflows/ci.yml does the same work there on native runners, and +# the `github` job here waits for the status it reports back, so this run +# still passes or fails with the real result. With BUILD_ON unset, everything +# builds here as before -- which is the whole fallback if GitHub is ever +# unavailable. # # Every job runs in an image pinned by digest (tag in the trailing comment), # and the only action used is coffey-labs/actions/checkout pinned by SHA. The @@ -27,8 +35,43 @@ concurrency: cancel-in-progress: true jobs: + # ------------------------------------------------------------ github ------ + # With BUILD_ON=github, the one job that runs here: it waits for the commit + # status GitHub's ci.yml posts for this commit -- "github/ci (branch)", or + # "(tag)" for a tag -- and passes or fails with it. For a pull request that + # is the head commit, which is what the mirror pushed. Nothing arriving + # within the timeout usually means the mirror or GitHub is down; unset + # BUILD_ON and re-run. + github: + if: ${{ vars.BUILD_ON == 'github' }} + runs-on: light + timeout-minutes: 150 + container: + image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim + env: + READ_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SHA: ${{ github.event.pull_request.head.sha || github.sha }} + CONTEXT: github/ci (${{ startsWith(github.ref, 'refs/tags/') && '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 ${READ_TOKEN}" "$url" | + jq -r --arg c "$CONTEXT" '[.[] | select(.context == $c)] | sort_by(.id) | last | .status // "missing"')" + case "$state" in + success) echo "GitHub: success"; exit 0 ;; + failure|error) echo "GitHub: $state -- see the status link on the commit"; exit 1 ;; + esac + sleep 20 + done + # -------------------------------------------------------------- test ------ node: + if: ${{ vars.BUILD_ON != 'github' }} runs-on: light container: image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim @@ -68,7 +111,7 @@ jobs: # Dockerfile builds everything itself, so nothing is handed over from the # node job; `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: @@ -97,7 +140,7 @@ jobs: # image reporting itself unversioned -- which is exactly what # version.test.ts calls looking wrong. version: - if: ${{ startsWith(github.ref, 'refs/tags/') }} + if: ${{ vars.BUILD_ON != 'github' && startsWith(github.ref, 'refs/tags/') }} runs-on: light container: image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim @@ -128,7 +171,7 @@ jobs: # its push tokens from its own name, so unlike on GitLab nothing here has to # be pointed at a public address. publish: - if: ${{ startsWith(github.ref, 'refs/tags/') }} + if: ${{ vars.BUILD_ON != 'github' && startsWith(github.ref, 'refs/tags/') }} needs: [node, version] runs-on: docker container: 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..797e8db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,32 +1,76 @@ -name: CI +# CI and image publishing on GitHub, 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 runners -- native arm64 above all, +# which Gitea's runners can only emulate. Gitea's own .gitea/workflows/ci.yml +# is the other half: it waits for the result this workflow reports back. +# +# THE SWITCH is the organization variable BUILD_ON, set on both forges. With +# BUILD_ON=github every job here runs and Gitea's build jobs skip; with it +# unset every job here skips and Gitea builds exactly as it did before. If +# GitHub is ever unavailable, unsetting it on Gitea is the whole fallback. +# +# No pull_request trigger: pull requests are opened on Gitea, and their branch +# arrives here as an ordinary push. Nothing is scheduled here either -- the +# weekly release is cut on Gitea, and its tag reaches this workflow through +# the mirror. +# +# Organization settings this relies on (none of them live in the repository): +# vars.BUILD_ON github +# vars.REGISTRY the Gitea container registry's DNS-only name +# vars.GITEA_URL Gitea's public URL +# secrets.GITEA_TOKEN jcoffey-dev token, write:repository + write:package -- +# commit statuses, the registry push and the package link +# +# Every `uses:` is pinned to a full commit SHA with the release in the trailing +# comment, and the repository requires it. Read the comment for the version; +# the SHA is what runs. 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: ['**'] + # A run GitHub queues and then orphans can be neither rerun nor canceled; + # dispatching by hand is the way back to a result for that commit. workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref_type != 'tag' }} + jobs: - build: + # Tells Gitea a result is on its way, so the pull request shows a pending + # check rather than nothing while the runners start. + pending: + if: ${{ vars.BUILD_ON == 'github' }} + runs-on: ubuntu-latest + steps: + - env: + GITEA_URL: ${{ vars.GITEA_URL }} + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + CONTEXT: github/ci (${{ github.ref_type }}) + run: | + jq -n --arg c "$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 ------ + # The same checks Gitea's node job runs. Full history, because version.test.ts + # computes a build version from it. The runner is not root, so config.test.ts's + # refused write holds 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 @@ -35,5 +79,164 @@ jobs: - 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 != 'tag' }} + needs: [node] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 + - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + push: false + provenance: false + sbom: false + cache-from: type=gha,scope=linux/amd64 + cache-to: type=gha,mode=max,scope=linux/amd64 + + # ----------------------------------------------------------- publish ------ + # Tag-driven, as on Gitea: the weekly release is created there with a token + # whose tag is an ordinary push, the mirror carries the tag here, and this + # builds it. The version comes from scripts/version.mjs, which needs node + # and the full history; the build is told the real form (IHASMAIL_VERSION, + # what About and /api/health report), and the Docker tag gets the same + # string with '+' turned into '-', because a tag may not contain '+'. + version: + if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' }} + 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 + run: | + V="$(node scripts/version.mjs)" + echo "version=$V" >> "$GITHUB_OUTPUT" + echo "docker_tag=${V/+/-}" >> "$GITHUB_OUTPUT" + echo "version $V -> tag ${V/+/-}" + + # Each architecture on its own native runner rather than under QEMU, which + # is where the time goes on Gitea: emulated arm64 runs `npm ci` and the Vite + # build through instruction translation. Each runner pushes an untagged + # image by digest, and the publish job joins the two into one tag. + build: + if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' }} + 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 + - name: Image name + # A registry path must be lowercase, and the owner here is spelled + # Coffey-Labs. + 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 }} + - 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 would add manifests of their own to the index, and + # `imagetools create` below expects just the two it pushed. + 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 + # Stored as the bare hash; the prefix goes back on in the publish job. + digest="${{ steps.push.outputs.digest }}" + 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 two digests into : and :latest -- every tag moves + # :latest, as it does on Gitea -- and links the package to the repository so + # it shows on the Packages tab there. + publish: + if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' }} + 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 + - 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 }} + - 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}" + - name: Link the package + env: + GITEA_URL: ${{ vars.GITEA_URL }} + 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" + + # ------------------------------------------------------------ report ------ + # One commit status on Gitea for the whole run. Gitea's ci.yml waits for it, + # so the pull request's own checks pass or fail with this run. + report: + if: ${{ always() && vars.BUILD_ON == 'github' }} + needs: [pending, node, docker-build, version, build, publish] + runs-on: ubuntu-latest + steps: + - env: + GITEA_URL: ${{ vars.GITEA_URL }} + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + CONTEXT: github/ci (${{ github.ref_type }}) + STATE: ${{ (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) && 'failure' || 'success' }} + run: | + jq -n --arg s "$STATE" --arg c "$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" 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 20797c2..0000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,202 +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. - IMAGE: ghcr.io/coffey-labs/ihasmail - -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