From 56a50e5aa301d9999e89e154dd9033956ec3b4a7 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 29 Sep 2026 23:06:04 -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. --- .gitea/workflows/ci.yml | 60 +++++++++++-- .github/workflows/ci.yml | 154 ++++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 69 --------------- 3 files changed, 209 insertions(+), 74 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/release.yml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index d4b553e..2f0909f 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 @@ -11,6 +11,16 @@ # reproducible. GitLab needed a generic package registry plus release-cli # 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. +# +# 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. name: ci on: @@ -25,6 +35,7 @@ concurrency: jobs: test: + if: ${{ vars.BUILD_ON != 'github' }} runs-on: light container: image: golang:1.26-bookworm@sha256:a688600ca24f8a4d3ca77f95b0dd40704a9fc787c826660eb7ba0b641b8b175d # 1.26-bookworm @@ -38,7 +49,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: @@ -95,12 +106,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..172ced8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,154 @@ +# 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. +# +# 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. 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 + + report: + if: always() && vars.BUILD_ON == 'github' + needs: [pending, test, release] + 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/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 05ef77d..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.15, and -# v2026.9.15.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.15" - 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