From 348eb0ea28194885e7313c89a4fbe73c99fb274e Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 29 Sep 2026 23:06:06 -0700 Subject: [PATCH] ci: build on GitHub when BUILD_ON=github GitHub now holds a push mirror of this repository. The org variable BUILD_ON picks which forge builds: set to 'github', the test and release jobs run in .github/workflows/ci.yml on GitHub's runners, attach the archives to the Gitea Release, and report back as the commit status "github/ci (branch)" or "github/ci (tag)". Gitea's ci.yml skips its own test and release jobs and waits on that status instead, so its run still passes or fails with the real result, and it announces the release once GitHub reports success. Unset, nothing changes: Gitea builds as before. The GitHub-era release workflow is replaced by ci.yml. The end-to-end test moves into ci.yml as a job run by hand: GitHub's runners are the throwaway VMs it needs, and its weekly schedule is dropped because schedules belong to Gitea. --- .gitea/workflows/ci.yml | 71 +++++++++++--- .github/workflows/ci.yml | 179 ++++++++++++++++++++++++++++++++++ .github/workflows/e2e.yml | 41 -------- .github/workflows/release.yml | 69 ------------- 4 files changed, 239 insertions(+), 121 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/e2e.yml delete mode 100644 .github/workflows/release.yml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index e90b786..8b1a6ba 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,6 +1,6 @@ # 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 GitHub's side of the switch below. # # 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 @@ -12,12 +12,21 @@ # links; Gitea attaches the tarballs to the Release itself, as GitHub did, so # the build and the release are one job and nothing is handed between jobs. # -# e2e.yml is deliberately NOT ported. e2e/public.sh publishes 25, 80, 443, -# 465, 993, 995 and 4190 on the machine it runs on, which on GitHub was a -# throwaway VM and here would be the CI host -- where 80 and 443 are nginx -# and the mail ports belong to the mail netns. It stays a manual check on a -# disposable host until there is a runner that can safely be given those -# ports. +# BUILD ON GITHUB. The org variable BUILD_ON decides which forge builds. +# Unset (or anything but 'github'), the test and release jobs below run here, +# as they always have. Set to 'github', they skip and .github/workflows/ci.yml +# does the same work on GitHub's runners -- GitHub holds a push mirror of this +# repository, updated on every commit -- and reports back as the commit status +# "github/ci (branch)" or "github/ci (tag)". The `github` job waits for that +# status and passes or fails with it, so a run here still says whether the +# commit is good. If GitHub is ever unavailable, unset BUILD_ON and everything +# builds here again. +# +# The end-to-end test is deliberately NOT here. e2e/public.sh publishes 25, +# 80, 443, 465, 993, 995 and 4190 on the machine it runs on, which here would +# be the CI host -- where 80 and 443 are nginx and the mail ports belong to the +# mail netns. It lives in .github/workflows/ci.yml as a job run by hand, on a +# GitHub runner, which is a throwaway VM. name: ci on: @@ -32,6 +41,7 @@ concurrency: jobs: test: + if: ${{ vars.BUILD_ON != 'github' }} runs-on: light container: image: golang:1.26-bookworm@sha256:a688600ca24f8a4d3ca77f95b0dd40704a9fc787c826660eb7ba0b641b8b175d # 1.26-bookworm @@ -45,7 +55,7 @@ jobs: - run: go run golang.org/x/vuln/cmd/govulncheck@latest ./... release: - if: startsWith(github.ref, 'refs/tags/') + if: ${{ vars.BUILD_ON != 'github' && startsWith(github.ref, 'refs/tags/') }} needs: [test] runs-on: light container: @@ -102,12 +112,51 @@ jobs: || { curl --silent "${auth[@]}" -X DELETE "$API/releases/$id"; exit 1; } done + # Stands in for test and release while GitHub builds: waits for the status + # GitHub's ci.yml posts on this commit and passes or fails with it. A pull + # request is checked at its head commit, which is what GitHub built when + # the branch reached the mirror. Two and a half hours covers a slow queue; + # a timeout here with BUILD_ON=github usually means GitHub never got the + # push -- check the mirror's last error in the repository settings. + github: + if: ${{ vars.BUILD_ON == 'github' }} + runs-on: light + timeout-minutes: 150 + container: + image: golang:1.26-bookworm@sha256:a688600ca24f8a4d3ca77f95b0dd40704a9fc787c826660eb7ba0b641b8b175d # 1.26-bookworm + steps: + - shell: bash + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + SHA: ${{ github.event.pull_request.head.sha || github.sha }} + IS_TAG: ${{ startsWith(github.ref, 'refs/tags/') }} + run: | + set -euo pipefail + apt-get update -qq && apt-get install -y -qq --no-install-recommends jq >/dev/null + ctx="github/ci (branch)"; [ "$IS_TAG" = true ] && ctx="github/ci (tag)" + echo "waiting for '$ctx' on $SHA" + while :; do + s="$(curl -fsS -H "Authorization: token $TOKEN" \ + "$CI_SERVER_INTERNAL/api/v1/repos/$REPO/commits/$SHA/statuses?limit=50" \ + | jq -c --arg c "$ctx" '[.[] | select(.context == $c)] | sort_by(.id) | last // empty')" || s="" + state="$(printf '%s' "$s" | jq -r '.status // .state // empty')" + case "$state" in + success) echo "GitHub: success"; exit 0 ;; + failure|error) echo "GitHub: $state -- $(jq -r '.target_url' <<<"$s")"; exit 1 ;; + esac + sleep 20 + done + # 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. When GitHub + # builds, the Release is made there and published only once its files are + # attached; this waits for GitHub's success and announces then. The action + # makes one topic per tag, so a second announce for the same tag is a no-op. announce: - needs: [release] - if: startsWith(github.ref, 'refs/tags/') + needs: [release, github] + if: ${{ always() && startsWith(github.ref, 'refs/tags/') && (needs.release.result == 'success' || needs.github.result == 'success') }} runs-on: light steps: - uses: coffey-labs/actions/discourse-release@e9293996e2efa770839121fa8f8da93083f216be diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..614b9de --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,179 @@ +# CI on GitHub Actions, for the GitHub copy of this repository. +# +# The repository lives on the self-hosted Gitea; GitHub holds a push mirror +# that Gitea updates on every commit. Nothing is merged here -- pull requests +# happen on Gitea, and their branches reach GitHub as ordinary pushes, which is +# why this workflow runs on `push` and not on `pull_request`. +# +# Which forge does the building is one switch, the variable BUILD_ON, set on +# both forges at the organization level: +# +# BUILD_ON=github every job here runs; .gitea/workflows/ci.yml skips its own +# test and release jobs and waits for the status this +# workflow reports back instead. +# unset / other every job here skips; Gitea builds as it always has. +# +# The result goes back to Gitea as one commit status, "github/ci (branch)" or +# "github/ci (tag)", which is what Gitea's `github` job waits on. +# +# Tags do what the Gitea release job does: check the tag is on main, build +# the reproducible archives, and attach them to the Gitea Release -- Gitea is +# where the install guide points, so that is where the files go. The Release +# is created as a draft, filled, then published, so it is never visible with +# assets missing; if an upload fails, a Release this run created is deleted. +# Gitea announces the release once this run has reported success. +# +# Go cross-compiles both architectures from one amd64 runner, so there is no +# arm64 job. +# +# The end-to-end test (e2e job) runs only when this workflow is started by +# hand. e2e/public.sh publishes 25, 80, 443, 465, 993, 995 and 4190 on the +# machine it runs on, so it needs a throwaway VM -- which a GitHub runner is and +# the self-hosted runners are not. It used to run every Monday on its own +# schedule; schedules belong to Gitea now, so for the moment it is a manual +# check: run this workflow from main after ihasmail's weekly release. +# +# Configuration, all at the organization level on GitHub: variables BUILD_ON, +# GITEA_URL; secret GITEA_TOKEN (write:repository on Gitea). +# +# Every `uses:` is pinned to a full commit SHA, with the release it was in the +# trailing comment. Nothing schedules here: schedules belong to Gitea. +name: ci + +on: + push: + branches: ['**'] + tags: ['**'] + # For a run GitHub queued and then orphaned, and for the end-to-end test. + # Run it from a tag to redo that tag's release; attaching skips any file + # already on the Release. + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref_type != 'tag' }} + +permissions: + contents: read + +env: + GITEA_URL: ${{ vars.GITEA_URL }} + CONTEXT: github/ci (${{ github.ref_type }}) + +jobs: + # Tells Gitea a result is on its way, so a status that is still missing + # reads as "running" rather than "never started". + pending: + if: vars.BUILD_ON == 'github' + runs-on: ubuntu-latest + steps: + - env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" -H 'Content-Type: application/json' \ + "$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" \ + -d "$(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"}')" + + test: + if: vars.BUILD_ON == 'github' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + - run: go vet ./... + - run: go test ./... + # `@latest` on purpose: a vulnerability check should use today's + # database, not a pinned copy of last month's. + - run: go run golang.org/x/vuln/cmd/govulncheck@latest ./... + + release: + if: vars.BUILD_ON == 'github' && github.ref_type == 'tag' + needs: [test] + runs-on: ubuntu-latest + env: + TAG: ${{ github.ref_name }} + steps: + # Full history: the ancestry check cannot be answered from a shallow + # clone. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + # A release can never describe code that was not reviewed onto main. + - run: | + git fetch --quiet origin main + git merge-base --is-ancestor "$(git rev-parse "${TAG}^{commit}")" origin/main \ + || { echo "::error::$TAG is not on main"; exit 1; } + # SOURCE_DATE_EPOCH makes the tarballs reproducible: two builds of one + # tag give byte-identical archives. + - run: | + SOURCE_DATE_EPOCH="$(git log -1 --format=%ct "$TAG")" scripts/build-release.sh "$TAG" dist + sha256sum dist/*.tar.gz + - env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + set -euo pipefail + API="$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY" + auth=(-H "Authorization: token $GITEA_TOKEN") + # Reuse the Release if the tag already has one (a re-run), else make a + # draft of our own. + created=0 + id="$(curl -fsS "${auth[@]}" "$API/releases/tags/$TAG" 2>/dev/null | jq -r '.id // empty' || true)" + if [ -z "$id" ]; then + id="$(curl -fsS "${auth[@]}" -H 'Content-Type: application/json' \ + -d "$(jq -n --arg t "$TAG" '{tag_name:$t, name:$t, draft:true, + body:"Binaries for linux/amd64 and linux/arm64. Verify with SHA256SUMS."}')" \ + "$API/releases" | jq -r '.id // empty')" + [ -n "$id" ] || { echo "::error::could not create the release"; exit 1; } + created=1 + fi + have="$(curl -fsS "${auth[@]}" "$API/releases/$id/assets" | jq -r '.[].name')" + for f in dist/*; do + n="$(basename "$f")" + if grep -qxF "$n" <<<"$have"; then echo "already attached: $n"; continue; fi + echo "uploading $n" + curl -fsS -o /dev/null "${auth[@]}" --form "attachment=@$f" "$API/releases/$id/assets?name=$n" || { + [ "$created" = 1 ] && curl -sS -o /dev/null "${auth[@]}" -X DELETE "$API/releases/$id" + exit 1 + } + done + if [ "$created" = 1 ]; then + curl -fsS -o /dev/null "${auth[@]}" -H 'Content-Type: application/json' -X PATCH \ + -d '{"draft":false}' "$API/releases/$id" + fi + + # A deploy with no --ihasmail-image takes whatever ihasmail release is + # newest, so this is what notices when a new release stops working with the + # Stalwart and Caddy versions pinned here. Pebble stands in for Let's + # Encrypt and a DNS stub answers every name: nothing but images comes from + # the internet. + e2e: + if: vars.BUILD_ON == 'github' && github.event_name == 'workflow_dispatch' + needs: [test] + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + - run: e2e/public.sh + + report: + if: always() && vars.BUILD_ON == 'github' + needs: [pending, test, release, e2e] + runs-on: ubuntu-latest + steps: + - env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + STATE: ${{ (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) && 'failure' || 'success' }} + run: | + curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" -H 'Content-Type: application/json' \ + "$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" \ + -d "$(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"}')" diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml deleted file mode 100644 index ae2d0ee..0000000 --- a/.github/workflows/e2e.yml +++ /dev/null @@ -1,41 +0,0 @@ -# The end-to-end test, every week, against ihasmail's newest release. -# -# A deploy with no --ihasmail-image takes whatever ihasmail release is newest -# when it runs, rather than one this repository pinned. That is only safe if -# something notices when a new release stops working with the Stalwart and -# Caddy versions pinned here -- this is that something. It runs on Mondays -# three hours after ihasmail's weekly release (09:00 UTC), so a release that -# breaks the one shot shows up the same day, before most people deploy it. -# -# e2e/public.sh needs nothing from the internet but images: Pebble stands in for -# Let's Encrypt and a DNS stub answers every name, and it publishes 25, 80, 443, -# 465, 993, 995 and 4190 on the runner while it runs. -name: End-to-end - -on: - schedule: - - cron: "0 12 * * 1" - workflow_dispatch: - -concurrency: - group: e2e - cancel-in-progress: false - -permissions: - contents: read - -jobs: - public: - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-go@v7 - with: - go-version-file: go.mod - - name: Vet and test - run: | - go vet ./... - go test ./... - - name: Deploy and check a public stack - run: e2e/public.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 7577f81..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,69 +0,0 @@ -# Publish a release when a version tag is pushed: test, check for known -# vulnerabilities, build the Linux binaries, attach them with their checksums. -# -# Tags are the date of the commit, as ihasmail's are: v2026.9.13, and -# v2026.9.13.1 for a second release on the same day. -name: Release - -on: - push: - tags: ["v*"] - # For a tag whose run never started. GitHub has queued and then orphaned - # runs before, and a pushed tag has no other way to trigger this again. - workflow_dispatch: - inputs: - tag: - description: "Existing tag to release, e.g. v2026.9.13" - required: true - type: string - -concurrency: - group: release-${{ github.event.inputs.tag || github.ref_name }} - cancel-in-progress: false - -permissions: - contents: write - -jobs: - release: - runs-on: ubuntu-latest - env: - TAG: ${{ github.event.inputs.tag || github.ref_name }} - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ github.event.inputs.tag || github.ref }} - fetch-depth: 0 - - uses: actions/setup-go@v7 - with: - go-version-file: go.mod - - - name: Check the tag names a commit on main - run: | - set -euo pipefail - git fetch --quiet origin main - git merge-base --is-ancestor "$(git rev-parse "$TAG^{commit}")" origin/main \ - || { echo "::error::$TAG is not on main"; exit 1; } - - - name: Vet and test - run: | - go vet ./... - go test ./... - - - name: Known vulnerabilities - run: go run golang.org/x/vuln/cmd/govulncheck@latest ./... - - - name: Build - run: | - SOURCE_DATE_EPOCH="$(git log -1 --format=%ct "$TAG")" scripts/build-release.sh "$TAG" dist - - - name: Publish - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - if gh release view "$TAG" >/dev/null 2>&1; then - gh release upload "$TAG" dist/* --clobber - else - gh release create "$TAG" dist/* --verify-tag --title "$TAG" --generate-notes - fi -- 2.54.0