From 6bfd105ad27ee9ebb81682158e9d00ca31963634 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 20 Sep 2026 19:37:18 -0700 Subject: [PATCH 1/3] Run CI on the self-hosted GitLab GitHub Actions stopped being reachable when the account was suspended, so this ports ci.yml and publish.yml to a .gitlab-ci.yml running on a group runner on Web_Host. The Actions workflows stay in the tree: they are the reference this was written from, and they work again unchanged if the appeal succeeds. Two differences worth knowing. Images are pinned by digest rather than the workflows' SHA-pinned actions, because GitLab has no action allowlist to back a tag with. And arm64 is built under QEMU instead of on a native runner, which is slow enough that publish is tag-only. --- .gitlab-ci.yml | 101 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 .gitlab-ci.yml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..2eca60a --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,101 @@ +# CI for the self-hosted GitLab that replaced GitHub Actions when the account +# was suspended on 2026-09-20. This is a port of .github/workflows/ci.yml and +# publish.yml, which are kept in the tree for reference and for the day the +# appeal succeeds. +# +# Every `image:` here is pinned to a digest, with the tag it belonged to in the +# trailing comment. That is the direct replacement for the SHA-pinned `uses:` +# in the Actions workflows: GitLab has no equivalent of an action allowlist, so +# the only thing standing between this pipeline and whatever the publisher +# pushes to a tag next is the digest. Read the comment for the version; the +# digest is what runs. Do not "simplify" one back to a bare tag. +# +# The runner is a group runner on Web_Host with the host docker socket bound +# in, reached over the internal container network rather than +# https://git.coffeylabs.org -- that name is Cloudflare-proxied on the Free +# plan, which caps request bodies at 100 MB and would break artifact uploads. + +stages: [test, build, publish] + +variables: + # Jobs talk to the registry directly on its DNS-only name, never through the + # proxy, for the same 100 MB reason. + IMAGE: $CI_REGISTRY_IMAGE + GIT_DEPTH: "0" + +default: + interruptible: true + +# ---------------------------------------------------------------- test ------ +node: + stage: test + image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim + cache: + key: + files: [package-lock.json] + paths: [.npm/] + before_script: + - npm config set cache .npm --global + script: + - npm ci --ignore-scripts + - npm run typecheck + - npm test + - npm run build + artifacts: + paths: [dist/] + expire_in: 1 week + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + - if: $CI_COMMIT_TAG + +# --------------------------------------------------------------- build ------ +# Proves the Dockerfile still builds on every change, without pushing. The +# equivalent of ci.yml's final `docker build -t ihasmail:ci .` step. +# +# Not called `image`: that is a reserved keyword, and a job by that name is +# silently read as the global image: setting instead ("image name should be a +# string"). Same trap for `stages`, `cache`, `services` and `variables`. +docker-build: + stage: build + image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli + needs: [node] + script: + - docker build -t ihasmail:ci-$CI_COMMIT_SHORT_SHA . + - docker image rm ihasmail:ci-$CI_COMMIT_SHORT_SHA + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + +# ------------------------------------------------------------- publish ------ +# Tag-driven, replacing the release -> publish workflow_call chain. GitHub +# needed that dance because a release cut with GITHUB_TOKEN raises no event; +# GitLab has no such rule, so a tag pipeline is enough. +# +# arm64 is built under QEMU on this amd64 host, not on a native runner as +# GitHub's free `ubuntu-24.04-arm` did. It is slow -- tens of minutes for the +# npm install and Vite build through instruction translation -- which is +# tolerable for a weekly tag and would not be for every push. That is why this +# job is tag-only. If arm64 ever starts timing out, the fix is an arm64 runner, +# not dropping the platform: TrueNAS and Unraid users pull it. +publish: + stage: publish + image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli + needs: [node] + variables: + DOCKER_BUILDKIT: "1" + before_script: + - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" + - docker run --privileged --rm tonistiigi/binfmt --install arm64 + - docker buildx create --use --name ci-builder --driver docker-container || docker buildx use ci-builder + script: + - | + docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --tag "$IMAGE:$CI_COMMIT_TAG" \ + --tag "$IMAGE:latest" \ + --push . + after_script: + - docker logout "$CI_REGISTRY" || true + rules: + - if: $CI_COMMIT_TAG -- 2.54.0 From 6e23c14132780db89be56f711c24dc9aa5035ebc Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 20 Sep 2026 19:55:43 -0700 Subject: [PATCH 2/3] Make the CI job environment match what the tests assume Three tests failed on the runner and pass locally, all because the job container differs from a workstation rather than because anything regressed: config.test.ts chmods a directory and expects the write to be refused, which root ignores; imageproxy.test.ts binds to ::1 and asks for localhost, which resolves to IPv4 first here; and version.test.ts shells out to git, which the slim image does not ship. So the job installs git, runs the suite as the image's unprivileged node user, and asks Node for the address order the proxy test was written against. No test changed. --- .gitlab-ci.yml | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 2eca60a..dcb0a54 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -30,17 +30,35 @@ default: node: stage: test image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim + variables: + NPM_CONFIG_CACHE: "$CI_PROJECT_DIR/.npm" + # imageproxy.test.ts binds its "reached by name" server to ::1 and then + # asks for localhost, on the assumption that localhost resolves to IPv6 + # first. That holds on a workstation and on GitHub's ubuntu-latest; inside + # this container /etc/hosts answers 127.0.0.1 first, the request lands on + # the pinned server instead and the control case fails. Node 17 onwards + # returns getaddrinfo order verbatim, so ask for the order the test + # expects rather than rewriting the test around the runner. + NODE_OPTIONS: "--dns-result-order=ipv6first" cache: key: files: [package-lock.json] paths: [.npm/] before_script: - - npm config set cache .npm --global + # version.test.ts shells out to git to resolve a build version, and the + # slim image ships without it. The clone is done by the runner's helper + # image, so nothing else here needs git and its absence is easy to miss. + - apt-get update -qq && apt-get install -y -qq --no-install-recommends git + # config.test.ts chmods a directory to 0555 and expects the write to be + # refused. Root ignores the permission bits, so as root that assertion can + # never hold. The tests run as the image's unprivileged `node` user for + # that reason; -p keeps the environment, including NODE_OPTIONS above. + - chown -R node:node "$CI_PROJECT_DIR" script: - - npm ci --ignore-scripts - - npm run typecheck - - npm test - - npm run build + - su node -p -c "npm ci --ignore-scripts" + - su node -p -c "npm run typecheck" + - su node -p -c "npm test" + - su node -p -c "npm run build" artifacts: paths: [dist/] expire_in: 1 week -- 2.54.0 From 2441e473904b8b31150007f99225e32ce4c0dc70 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 20 Sep 2026 20:00:31 -0700 Subject: [PATCH 3/3] Give CI jobs IPv6 rather than a Node flag that did not help The proxy test failed with ECONNREFUSED on 127.0.0.1 for a server bound to ::1. That is not resolution order, so --dns-result-order was treating the wrong cause and is removed: with no non-loopback IPv6 address on the container, getaddrinfo's AI_ADDRCONFIG drops ::1 from the results altogether and localhost can only ever come back IPv4. The runner now puts jobs on a docker network created with --ipv6, which is where the fix belongs. Verified by reproducing the failure on the old network and watching it pass on the new one. --- .gitlab-ci.yml | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index dcb0a54..3c87f2a 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -32,14 +32,6 @@ node: image: node:26-bookworm-slim@sha256:582460f614631b59b824ac6020533b9bf339c7fdf3a6d7db31abb6b4065f0212 # 26-bookworm-slim variables: NPM_CONFIG_CACHE: "$CI_PROJECT_DIR/.npm" - # imageproxy.test.ts binds its "reached by name" server to ::1 and then - # asks for localhost, on the assumption that localhost resolves to IPv6 - # first. That holds on a workstation and on GitHub's ubuntu-latest; inside - # this container /etc/hosts answers 127.0.0.1 first, the request lands on - # the pinned server instead and the control case fails. Node 17 onwards - # returns getaddrinfo order verbatim, so ask for the order the test - # expects rather than rewriting the test around the runner. - NODE_OPTIONS: "--dns-result-order=ipv6first" cache: key: files: [package-lock.json] @@ -52,7 +44,16 @@ node: # config.test.ts chmods a directory to 0555 and expects the write to be # refused. Root ignores the permission bits, so as root that assertion can # never hold. The tests run as the image's unprivileged `node` user for - # that reason; -p keeps the environment, including NODE_OPTIONS above. + # that reason; -p keeps the environment. + # + # imageproxy.test.ts needs IPv6 as well, which is not set here but on the + # runner: jobs run on the `ci-net` docker network, created with --ipv6. + # Without a non-loopback IPv6 address on the container, getaddrinfo's + # AI_ADDRCONFIG drops ::1 from the results entirely, localhost resolves to + # IPv4 only, and the test's control case connects to a port nothing is + # listening on. That is a runner property, so it cannot be fixed from this + # file -- if these tests ever fail again with ECONNREFUSED on 127.0.0.1, + # check that the runner still puts jobs on an IPv6-enabled network. - chown -R node:node "$CI_PROJECT_DIR" script: - su node -p -c "npm ci --ignore-scripts" -- 2.54.0