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