diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31ee4a1..8c4c48d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -141,6 +141,9 @@ jobs: if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' permissions: contents: read + # Kept as a statement of intent, but it does not grant what this job + # needs: on Gitea the registry does not accept the Actions token at all, + # so `packages: write` on it authorises nothing. See the login step. packages: write steps: - uses: https://github.com/actions/checkout@v4 @@ -166,11 +169,40 @@ jobs: echo "host=${host}" >> "$GITHUB_OUTPUT" echo "name=${host}/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" + # Why not `secrets.GITHUB_TOKEN`, which is what this was and what the + # GHCR version used: a Gitea package belongs to a **user or org owner, + # not to a repository**, so the ephemeral repo-scoped Actions token + # carries no authority over it and the registry refuses it. GitLab hands + # out working per-job registry credentials ($CI_REGISTRY_PASSWORD, which + # .gitlab-ci.yml uses) and Gitea deliberately does not, so this needs a + # real access token with package write scope, stored as REGISTRY_TOKEN. + # + # The username must be the token's owner, hence repository_owner rather + # than github.actor: the image is pushed into the owner's namespace, and + # actor is whoever happened to trigger the run — a different person on a + # dispatch, and the wrong account entirely on a fork. + - name: Check the registry credential exists + # Without this the failure is a bare `unauthorized` from the daemon, + # which says nothing about what to do. It also tells us something we do + # not currently know: whether the old token was rejected or was simply + # never set. + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + if [ -z "${REGISTRY_TOKEN:-}" ]; then + echo "::error::REGISTRY_TOKEN is not set. Create an access token at" \ + "${{ github.server_url }}/user/settings/applications with the" \ + "package read+write scope, then add it to this repository under" \ + "Settings → Actions → Secrets as REGISTRY_TOKEN." + exit 1 + fi + echo "REGISTRY_TOKEN is set (${#REGISTRY_TOKEN} chars)" + - uses: https://github.com/docker/login-action@v3 with: registry: ${{ steps.img.outputs.host }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + username: ${{ github.repository_owner }} + password: ${{ secrets.REGISTRY_TOKEN }} - uses: https://github.com/docker/build-push-action@v6 with: diff --git a/README.md b/README.md index e692780..cb93743 100644 --- a/README.md +++ b/README.md @@ -358,6 +358,22 @@ The image lands at `gitea.lucaswinther.info/lucasw89/gacha-event-tracker`, tagge the commit SHA. The registry host is derived from `github.server_url` rather than hardcoded, so a fork on another instance pushes to its own registry. +**Pushing the image needs one one-time step CI cannot do for itself.** A Gitea package belongs to a +*user or org owner*, not to a repository, so the ephemeral Actions token has no authority over it and +the registry refuses it — `secrets.GITHUB_TOKEN` does not work here, unlike on GHCR and unlike +GitLab's `$CI_REGISTRY_PASSWORD`. So: + +1. Create an access token at **Settings → Applications** with the **package** read + write scope. +2. Add it to the repository under **Settings → Actions → Secrets** as **`REGISTRY_TOKEN`**. + +The `image` job checks for that secret before it tries to log in, and fails with those instructions +rather than a bare `unauthorized` from the Docker daemon. The username it logs in with is +`github.repository_owner`, not `github.actor` — the image goes to the owner's namespace, and the +actor is merely whoever triggered the run. + +One consequence of packages being owner-scoped: the pushed image is **not** automatically associated +with this repository. Linking it is a manual step on the package's own settings page. + The feed job fails if the event count collapses, or if any single source parses to nothing — nine healthy sources hide a tenth that has gone quiet, and the total stays comfortably over the floor while one game shows an empty calendar. That is the failure mode a parser-only pipeline is most