name: Check for dependency updates # Runs every six hours and reports anything out of date in one place: the # crates in Cargo.lock, the Rust pin in rust-toolchain.toml and the Dockerfile, # and the Dockerfile's base images. # # Everything lands in ONE issue titled "Dependency updates", edited in place # rather than commented on, so there is a single current answer instead of a # pile of history. When nothing is out of date the issue is closed — an empty # issue left open is a thing people stop reading, which defeats the point. # # Nothing is upgraded automatically. `cargo update` on a schedule would land # unreviewed code in a service that holds the operator's second factor, and a # breaking bump needs a human regardless. on: schedule: # Every six hours, off the hour so this doesn't compete with an image # rebuild for a runner. - cron: '23 */6 * * *' workflow_dispatch: # One run at a time. A manual dispatch during a scheduled run would otherwise # race it and could open the issue twice. concurrency: group: dependency-check cancel-in-progress: false permissions: contents: read issues: write jobs: check: runs-on: ubuntu-latest timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v4 # The toolchain comes from the repo rather than being hard-coded here, so # the check runs on the same compiler the image ships with and this file # can be copied between repos unchanged. - name: Resolve the pinned Rust version id: rust run: | set -eu FILE=deploy/docker/Dockerfile [ -f "$FILE" ] || FILE=Dockerfile VERSION=$(grep -E '^ARG RUST_VERSION=' "$FILE" | head -1 | cut -d= -f2) [ -n "$VERSION" ] || { echo "::error::no ARG RUST_VERSION in $FILE"; exit 1; } echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "checking with rust ${VERSION}" # The workspace is streamed into the container over stdin rather than # bind-mounted, so this works whether the runner shares a filesystem with # the daemon or not. # # The image is rust + node, built from .gitea/scripts/ci.Dockerfile: the # report needs cargo to resolve the workspace graph and Node to read the # registries, and neither official image has both. # # RUSTUP_TOOLCHAIN overrides rust-toolchain.toml: the image's own # toolchain is enough to read Cargo.lock, and honouring the file would # make every run fetch a second compiler for no benefit. # # Cargo is deliberately *not* run offline: at least one project here # depends on a crate published to a private registry, and `--offline` # turns that into a failed resolution and therefore no report at all. - name: Generate report run: | set -eu VERSION="${{ steps.rust.outputs.version }}" docker build \ --build-arg "RUST_VERSION=${VERSION}" \ --tag "dep-check:${VERSION}" \ --file .gitea/scripts/ci.Dockerfile \ .gitea/scripts > /dev/null tar cf - --exclude=./.git --exclude=./target . | docker run --rm -i \ -e RUSTUP_TOOLCHAIN="${VERSION}" \ -e CARGO_TERM_COLOR=never \ "dep-check:${VERSION}" \ sh -ec 'mkdir -p /work && cd /work && tar xf - && exec node .gitea/scripts/dependency-report.mjs' \ > /tmp/report.md 2> /tmp/report.err || { echo "::error::the dependency report failed to generate" cat /tmp/report.err exit 1 } cat /tmp/report.md grep -E '::dep-check::' /tmp/report.err || true # An empty report is a broken check, not a clean bill of health. # Failing here keeps the tracking issue open rather than closing it on # the strength of a file that never got written. test -s /tmp/report.md grep -oE 'routine=[0-9]+ breaking=[0-9]+ images=[0-9]+ rust=[0-9]+' /tmp/report.err \ > /tmp/counts cat /tmp/counts - name: Open, update or close the tracking issue env: GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} REPO: ${{ github.repository }} run: | set -eu API="https://gitea.milesward.dev/api/v1" node -e ' const fs = require("fs"); const report = fs.readFileSync("/tmp/report.md", "utf8").trim(); const counts = fs.readFileSync("/tmp/counts", "utf8").trim(); const [routine, breaking, images, rust] = counts.match(/[0-9]+/g).map(Number); const actionable = routine + breaking + images + rust; const body = (actionable === 0 ? "Everything tracked is current. Closing this until something changes.\n\n" : "") + report + "\n\n---\n\n" + "Rewritten by `.gitea/workflows/dependency-check.yml` every 6 hours, so this is " + "always the latest report rather than a pile of comments."; fs.writeFileSync("/tmp/state.json", JSON.stringify({ actionable, body })); ' ACTIONABLE=$(node -e 'process.stdout.write(String(require("/tmp/state.json").actionable))') echo "actionable=$ACTIONABLE" # Existing issue by title, any state: a previously closed one is # exactly the one to update and reopen, and searching all states is # also what stops a duplicate being created after a close. # # The `|| true` matters: `set -e` plus `pipefail` would abort the step # on a transient API error, and creating a second issue is a far worse # outcome than missing one run. NUMBER=$(curl -sf -H "Authorization: token ${GITEA_TOKEN}" \ "${API}/repos/${REPO}/issues?state=all&type=issues&limit=100" \ | node -e ' let s = ""; process.stdin.on("data", d => s += d).on("end", () => { try { const issues = JSON.parse(s); const hit = issues.find(i => i.title === "Dependency updates"); process.stdout.write(hit ? String(hit.number) : ""); } catch { process.stdout.write(""); } }); ' || true) BODY=$(node -e 'process.stdout.write(JSON.stringify({ body: require("/tmp/state.json").body }))') if [ -z "$NUMBER" ]; then if [ "$ACTIONABLE" = "0" ]; then echo "everything current and no issue open — nothing to do" exit 0 fi curl -sf -X POST -H "Authorization: token ${GITEA_TOKEN}" \ -H "Content-Type: application/json" \ -d "$(node -e 'process.stdout.write(JSON.stringify({ title: "Dependency updates", body: require("/tmp/state.json").body }))')" \ "${API}/repos/${REPO}/issues" > /dev/null echo "opened the tracking issue" exit 0 fi curl -sf -X PATCH -H "Authorization: token ${GITEA_TOKEN}" \ -H "Content-Type: application/json" \ -d "$BODY" "${API}/repos/${REPO}/issues/${NUMBER}" > /dev/null echo "updated issue #${NUMBER}" # Reopen when there is work, close when there isn't. Done as a second # call because the state is not part of the body above. if [ "$ACTIONABLE" = "0" ]; then WANT=closed; else WANT=open; fi STATE=$(curl -sf -H "Authorization: token ${GITEA_TOKEN}" \ "${API}/repos/${REPO}/issues/${NUMBER}" \ | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.parse(s).state))') if [ "$STATE" != "$WANT" ]; then curl -sf -X PATCH -H "Authorization: token ${GITEA_TOKEN}" \ -H "Content-Type: application/json" \ -d "{\"state\":\"${WANT}\"}" "${API}/repos/${REPO}/issues/${NUMBER}" > /dev/null echo "issue #${NUMBER}: ${STATE} -> ${WANT}" fi