The first run died on `exec: node: not found` — rust:1.95-bookworm has no Node, and the report needs cargo to resolve the workspace graph alongside Node to read the registries. .gitea/scripts/ci.Dockerfile layers the two official images together, so the parsing exists once rather than once per language.
185 lines
8.0 KiB
YAML
185 lines
8.0 KiB
YAML
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
|