diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 011e08c..3f49353 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,15 @@ on: # Weekly probe of the live endpoints, so external API changes surface. - cron: '0 3 * * 1' workflow_dispatch: + # Called by docker.yml as the publish gate. `gate` marks that call: its + # concurrency group is separate from the standalone run on the same ref, and + # it never cancels a superseded run, because each gate run carries its own + # commit's image (a docs-only follow-up must not drop the code commit's). + workflow_call: + inputs: + gate: + type: boolean + default: false permissions: contents: read @@ -41,8 +50,8 @@ permissions: # A newer push to the same ref supersedes the older run; without this every # intermediate commit of a PR branch keeps a runner busy to completion. concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true + group: ci-${{ inputs.gate && 'gate-' || '' }}${{ github.ref }} + cancel-in-progress: ${{ !inputs.gate }} env: # Panicking tests print their backtrace; free when nothing fails. @@ -134,7 +143,7 @@ jobs: live: needs: test - if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') + if: ${{ !inputs.gate && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')) }} runs-on: ubuntu-latest timeout-minutes: 30 continue-on-error: true diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index c98b290..184afac 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -111,14 +111,27 @@ jobs: fi echo "build=true" >> "$GITHUB_OUTPUT" + # The publish gate: the offline suite CI runs (fmt, clippy, tests, release + # build, audit), reused rather than copied, so no image is pushed for a + # commit whose tests are red. Pull requests skip it: their own CI run covers + # the code, and the docker job there only proves the image still builds. + ci: + needs: should-build + if: needs.should-build.outputs.build == 'true' && github.event_name != 'pull_request' + uses: ./.github/workflows/ci.yml + with: + gate: true + # No `actions/checkout` here on purpose: `docker/build-push-action` defaults # to the Git context (`https://github.com//.git#`), so # BuildKit clones the repo itself and authenticates with the automatic # github.token. Adding `context: .` below without a checkout step would hand # BuildKit an empty workspace. docker: - needs: should-build - if: needs.should-build.outputs.build == 'true' + needs: [should-build, ci] + # `always()`: a skipped `ci` (pull requests) must not skip this job; only a + # failed or cancelled gate does. + if: always() && needs.should-build.outputs.build == 'true' && (github.event_name == 'pull_request' || needs.ci.result == 'success') runs-on: ubuntu-latest timeout-minutes: 60 steps: diff --git a/AGENTS.md b/AGENTS.md index 915a1d8..5d68ecc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,7 +89,7 @@ Docker: `docker build -t tgxmb .` then `docker run --rm -d --name tgxmb --env-fi | `Dockerfile` | Multi-stage: cached dep layer via stub sources + `touch *.rs` mtime bump (cargo's freshness is mtime-based and `cargo clean -p` removes 0 files — the touch is what forces the real sources to rebuild while deps stay cached), static ffmpeg from ffmpeg.martin-riedl.de (`FFMPEG_URL` arg, optional `FFMPEG_SHA256` checksum, `unzip -t` integrity check), `debian:bookworm-slim` runtime, entrypoint. Runtime ships **no libssl/libcrypto/CA bundle** — rustls webpki-roots handles all TLS, and the static ffmpeg only processes local files (downloads go through reqwest) | |`docker-entrypoint.sh` | Privilege drop: `useradd` with `LOCAL_USER_ID` (default 9001) + `setpriv` (no gosu on bookworm-slim); rejects a non-numeric or `0` `LOCAL_USER_ID`, which would otherwise survive the drop and run the bot root | | `docker-compose.yml` | The deployment composition, committed as-is: every instance value (token, admins, site credentials, domain) is a `${VAR}` substitution read from the gitignored `.env` beside it, so the file needs no per-deployment edit — and a variable not listed in a service's `environment:` never reaches that container. Ships nginx-proxy + acme-companion: webhook mode needs TLS termination in front (teloxide's axum listener is HTTP-only; `WEBHOOK_CERT` only feeds `set_webhook`), bot exposes `VIRTUAL_HOST`/`VIRTUAL_PORT` on the shared `proxy` network, no host port; container names `nginx-proxy`/`acme-companion`/`tgxmb`, start order via `depends_on` (proxy → acme → bot) | -| `.github/workflows/docker.yml` | CI: build+push to Docker Hub on tag `v*`/master, plus a build-only check on PRs touching the build inputs; **no test step**; verifies a release tag matches both crate versions; buildx gha cache (`cache-from` always, `cache-to` except on PRs, scope `tgxmb-build`, `mode=max`) so cargo deps + ffmpeg layers are restored across runs; `FFMPEG_URL`/`FFMPEG_SHA256` come from repo variables when set | +| `.github/workflows/docker.yml` | CI: build+push to Docker Hub on tag `v*`/master, plus a build-only check on PRs touching the build inputs; the push is gated on `ci.yml`'s offline suite (reused via `workflow_call` with `gate: true`, so a red suite blocks the image); verifies a release tag matches both crate versions; buildx gha cache (`cache-from` always, `cache-to` except on PRs, scope `tgxmb-build`, `mode=max`) so cargo deps + ffmpeg layers are restored across runs; `FFMPEG_URL`/`FFMPEG_SHA256` come from repo variables when set | | `README.md` | Feature docs + command table (Chinese) | ## Runtime/Tooling Preferences