# 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 one mattered more than most. ghcr.io/coffey-labs/ihasvpn went dark with # the account while the deployment was still pulling from it, and the only # surviving copy was the image already on the host -- amd64 only, because that # is the platform it runs. The multi-arch tag is rebuilt here. # # 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 # instance resolves short `uses:` against itself, never GitHub, so nothing # unreviewed can be pulled in. # # Jobs run on the runner's `ci-net` network and clone from Gitea's internal # address, never through the Cloudflare-proxied public name. Images go to the # registry's own DNS-only name (vars.REGISTRY, an org variable). name: ci on: push: branches: [main] tags: ['**'] pull_request: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: # The Go binary embeds the built web assets (internal/server/static/dist, # `//go:embed all:dist`), so the frontend build comes first. Its output is # not handed to the Go job: there is no artifact store here, and the Go # checks do not need it -- the committed dist/.gitkeep is enough for the # embed to compile. (The GitLab port passed web/dist as an artifact, but the # build writes to internal/server/static/dist, so its Go job compiled # against the placeholder too.) The image build does its own frontend build # in the Dockerfile. web: runs-on: light container: image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim env: NPM_CONFIG_CACHE: ${{ github.workspace }}/.npm steps: - uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec - working-directory: web run: | npm ci --ignore-scripts --no-audit --no-fund npm run build go: needs: [web] runs-on: light container: image: golang:1.27-bookworm@sha256:69a7b9788769bec032d238959b61854e9ae87f57be9029ec04e9885fabf99195 # 1.27-bookworm steps: - uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec - run: go vet ./... - run: go test -count=1 ./... # Left as `go run ...@latest`, as the workflow had it: a vulnerability # check wants today's database, not a pinned copy of last month's. - run: go run golang.org/x/vuln/cmd/govulncheck@latest ./... docker-build: if: ${{ !startsWith(github.ref, 'refs/tags/') }} needs: [web] runs-on: docker container: image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli volumes: - /var/run/docker.sock:/var/run/docker.sock steps: - uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec - run: | tag="ihasvpn:ci-$(echo "$GITHUB_SHA" | cut -c1-8)" docker build -t "$tag" . docker image rm "$tag" # The workflow built each platform on its own native runner and joined the # two digests into one tag. There is a single amd64 runner here, so arm64 # goes through QEMU instead -- slower, but this is tag-driven and the # alternative is shipping amd64 only, which is what the account suspension # already cost us once. TrueNAS and Unraid users pull arm64. # # The version is the tag without its leading "v". publish.yml used # `git describe --tags --always`, which on a tag build is exactly the tag; # passing the tag with the "v" still on would make /api/health report a # different string from every earlier build. # # web and go run on tags too, so publish waits for both. (In the GitLab port # they were limited to merge requests and main, while publish still needed # them, so a tag pipeline could not be created at all.) # # The push logs in with PACKAGE_TOKEN (jcoffey-dev, write:package): Gitea's # per-job token is refused by the container registry. publish: if: ${{ startsWith(github.ref, 'refs/tags/') }} needs: [web, go] runs-on: docker container: image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli volumes: - /var/run/docker.sock:/var/run/docker.sock env: REGISTRY: ${{ vars.REGISTRY }} IMAGE: ${{ vars.REGISTRY }}/${{ github.repository }} PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }} steps: - uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec - run: | test -n "$REGISTRY" echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV" test -n "$PACKAGE_TOKEN" || { echo "PACKAGE_TOKEN secret is not set on this repository" >&2; exit 1; } echo "$PACKAGE_TOKEN" | docker login -u jcoffey-dev --password-stdin "$REGISTRY" docker run --privileged --rm tonistiigi/binfmt --install arm64 docker buildx create --use --name gitea-builder --driver docker-container || docker buildx use gitea-builder - run: | docker buildx build \ --platform linux/amd64,linux/arm64 \ --build-arg IHASVPN_VERSION="$VERSION" \ --provenance=false --sbom=false \ --tag "$IMAGE:$VERSION" \ --tag "$IMAGE:latest" \ --push . docker buildx imagetools inspect "$IMAGE:$VERSION" # Gitea keeps a container package on its owner; linking it shows it on # the repository's Packages tab. Idempotent. - run: | apk add --no-cache -q curl curl -fsS -o /dev/null -X POST -H "Authorization: token $PACKAGE_TOKEN" \ "$CI_SERVER_INTERNAL/api/v1/packages/${GITHUB_REPOSITORY%%/*}/container/${GITHUB_REPOSITORY#*/}/-/link/${GITHUB_REPOSITORY#*/}" \ || echo "package already linked (or link refused); not fatal" - if: always() run: docker logout "$REGISTRY" || true