diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 7fb0982..12dbe1e 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. +# +# BUILD_ON: an org variable, set on both forges. Gitea push-mirrors this +# repository to GitHub, and with BUILD_ON=github the build jobs here skip and +# GitHub Actions does the same work on the mirrored copy +# (.github/workflows/ci.yml, on faster and native-arm64 runners). The `github` +# job at the end waits for the result that workflow reports back, so this run +# still passes or fails with the build. Unset, everything builds here as +# before -- which is also the fallback if GitHub is ever unavailable. # # 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 @@ -37,6 +45,7 @@ jobs: # against the placeholder too.) The image build does its own frontend build # in the Dockerfile. web: + if: ${{ vars.BUILD_ON != 'github' }} runs-on: light container: image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim @@ -50,6 +59,7 @@ jobs: npm run build go: + if: ${{ vars.BUILD_ON != 'github' }} needs: [web] runs-on: light container: @@ -63,7 +73,7 @@ jobs: - run: go run golang.org/x/vuln/cmd/govulncheck@latest ./... docker-build: - if: ${{ !startsWith(github.ref, 'refs/tags/') }} + if: ${{ vars.BUILD_ON != 'github' && (!startsWith(github.ref, 'refs/tags/')) }} needs: [web] runs-on: docker container: @@ -95,7 +105,7 @@ jobs: # 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/') }} + if: ${{ vars.BUILD_ON != 'github' && (startsWith(github.ref, 'refs/tags/')) }} needs: [web, go] runs-on: docker container: @@ -133,3 +143,37 @@ jobs: || echo "package already linked (or link refused); not fatal" - if: always() run: docker logout "$REGISTRY" || true + + # ------------------------------------------------------------ github ------ + # With BUILD_ON=github the jobs above skip and GitHub Actions builds this + # commit on the push-mirrored copy (.github/workflows/ci.yml). This job waits + # for the commit status that workflow reports back, and passes or fails with + # it, so this run -- what pull requests and merges look at -- still carries + # the real result. If GitHub never reports, it times out and fails: the cue + # to unset BUILD_ON and build here again. + 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.ref_type == 'tag' && 'github/ci (tag)' || 'github/ci (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 + # The newest status for the context is the one that counts. + s="$(curl -fsS -H "Authorization: token ${READ_TOKEN}" "$url" | jq -r --arg c "$CONTEXT" '[.[] | select(.context == $c)] | sort_by(.id) | last | "\(.status) \(.target_url)"' 2>/dev/null)" + case "$s" in + success\ *) echo "GitHub: ${s}"; exit 0 ;; + failure\ *|error\ *) echo "GitHub: ${s}" >&2; exit 1 ;; + esac + sleep 20 + done diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ce162f..de090f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,33 +1,210 @@ -name: CI +# CI and image publishing on GitHub Actions, for the GitHub replica of this +# repository. Gitea (git.coffeylabs.org) is the source of truth: it pushes +# every commit and tag here through a push mirror, and this workflow builds +# what arrives. Pull requests live on Gitea, so there is no pull_request +# trigger -- a PR's branch reaches this copy as an ordinary push. +# +# The switch is the org variable BUILD_ON, set on both forges. With +# BUILD_ON=github every job here runs, the heavy jobs in .gitea/workflows/ci.yml +# skip, and Gitea's own run waits for the result this workflow reports back. +# With it unset, every job here skips and Gitea builds everything as before -- +# which is also the way back if GitHub is ever unavailable: unset it on Gitea. +# +# This file does what .gitea/workflows/ci.yml does, job for job; that file is +# the spec for what CI covers. The difference is the image publish: here each +# architecture builds on its own native runner (`ubuntu-24.04-arm` is free for +# public repositories) instead of arm64 under QEMU, and the two digests are +# joined into one tag. +# +# Images go to the Gitea registry (vars.REGISTRY), as they do from Gitea, not +# to GHCR. Credentials: the org secret GITEA_TOKEN, a jcoffey-dev Gitea token +# with write:repository and write:package -- the registry push, the package +# link, and the commit status reported back to Gitea. +# +# Every `uses:` is pinned to a full commit SHA with the release in a trailing +# comment. Do not "simplify" one back to a tag. +name: ci + on: push: - branches: [main] - pull_request: - # Lets a run be started by hand against any ref, including one GitHub - # queued and then orphaned. + branches: ['**'] + tags: ['**'] workflow_dispatch: + permissions: contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + GITEA_URL: ${{ vars.GITEA_URL }} + REGISTRY: ${{ vars.REGISTRY }} + jobs: - check: + # Tells Gitea a result is on its way, so the commit does not look unchecked + # while the build runs. + pending: + if: ${{ vars.BUILD_ON == 'github' }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - env: + 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" + + # The Go binary embeds the built web assets, so the frontend build comes + # first. As on Gitea, its output is not handed on: the committed + # dist/.gitkeep is enough for the embed to compile, and the image build does + # its own frontend build in the Dockerfile. + web: + if: ${{ vars.BUILD_ON == 'github' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 26 cache: npm cache-dependency-path: web/package-lock.json - - run: npm ci --ignore-scripts --no-audit --no-fund - working-directory: web - - run: npm run build - working-directory: web - - uses: actions/setup-go@v6 + - working-directory: web + run: | + npm ci --ignore-scripts --no-audit --no-fund + npm run build + + go: + if: ${{ vars.BUILD_ON == 'github' }} + needs: [web] + 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 -count=1 ./... - - name: govulncheck - run: go run golang.org/x/vuln/cmd/govulncheck@latest ./... - - name: Docker build - run: docker build -t ihasvpn:ci . + # Unpinned on purpose, as on Gitea: 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 ./... + + # Proves the Dockerfile still builds, without pushing. Branches only; a tag + # builds the real image below. + docker-build: + if: ${{ vars.BUILD_ON == 'github' && github.ref_type != 'tag' }} + needs: [web] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - run: docker build -t "ihasvpn:ci-${GITHUB_SHA::8}" . + + # ----------------------------------------------------------- publish ------ + # The version is the tag without its leading "v", the same string the Gitea + # publish job builds with, so /api/health reports the same thing whichever + # forge built the image. + build: + if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' }} + needs: [web, go] + 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 + - run: | + echo "IMAGE=${REGISTRY}/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV" + echo "VERSION=${GITHUB_REF_NAME#v}" >> "$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: IHASVPN_VERSION=${{ env.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 + 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 per-architecture digests into : and :latest -- every + # tag moves :latest, as it does on Gitea -- then links the package to the + # repository so it shows on its Packages tab (idempotent). + publish: + if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' }} + needs: [build] + runs-on: ubuntu-latest + 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: ${{ vars.REGISTRY }} + username: jcoffey-dev + password: ${{ secrets.GITEA_TOKEN }} + - name: Create the manifest + run: | + IMAGE="${REGISTRY}/${GITHUB_REPOSITORY,,}" + VERSION="${GITHUB_REF_NAME#v}" + refs=() + for f in /tmp/digests/*; do + refs+=("${IMAGE}@sha256:$(basename "$f")") + done + docker buildx imagetools create -t "${IMAGE}:${VERSION}" -t "${IMAGE}:latest" "${refs[@]}" + docker buildx imagetools inspect "${IMAGE}:${VERSION}" + - env: + 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" + + # One commit status on Gitea for the whole run. Gitea's ci.yml `github` job + # waits on exactly this context. + report: + if: ${{ always() && vars.BUILD_ON == 'github' }} + needs: [pending, web, go, docker-build, build, publish] + runs-on: ubuntu-latest + steps: + - env: + 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/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index a6ed582..0000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,127 +0,0 @@ -# Publish the container image to GHCR. -# -# FIRST RUN: a package GHCR creates for the first time is private, even in a -# public repository. Set it to public by hand under the package's settings -# and check with a logged-out `docker pull`. -# -# Two architectures, each built on its own native runner rather than under -# QEMU (`ubuntu-24.04-arm` is free for public repositories). Each runner -# pushes an untagged image by digest and a final job joins the two into one -# multi-arch tag. -name: Publish image - -on: - release: - types: [published] - 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: - IMAGE: ghcr.io/coffey-labs/ihasvpn - -jobs: - version: - runs-on: ubuntu-latest - outputs: - version: ${{ steps.v.outputs.version }} - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ inputs.ref || github.ref }} - fetch-depth: 0 - - id: v - run: | - V="$(git describe --tags --always --dirty)" - V="${V#v}" - echo "version=$V" >> "$GITHUB_OUTPUT" - echo "version $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@v7 - with: - ref: ${{ inputs.ref || github.ref }} - - uses: docker/setup-buildx-action@v4 - - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push by digest - id: push - uses: docker/build-push-action@v7 - with: - context: . - platforms: ${{ matrix.platform }} - build-args: IHASVPN_VERSION=${{ needs.version.outputs.version }} - 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 - digest="${{ steps.push.outputs.digest }}" - touch "/tmp/digests/${digest#sha256:}" - - uses: actions/upload-artifact@v7 - with: - name: digest-${{ strategy.job-index }} - path: /tmp/digests/* - retention-days: 1 - if-no-files-found: error - - publish: - needs: [version, build] - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - uses: actions/download-artifact@v8 - with: - path: /tmp/digests - pattern: digest-* - merge-multiple: true - - uses: docker/setup-buildx-action@v4 - - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Create the manifest - run: | - tags=(-t "${IMAGE}:${{ needs.version.outputs.version }}") - 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 - docker buildx imagetools create "${tags[@]}" "${refs[@]}" - - name: Show what landed - run: docker buildx imagetools inspect "${IMAGE}:${{ needs.version.outputs.version }}"