Recipe: keep dependencies current

Recipe: know when your dependencies fall behind

A build that pins its tools inline — jsr:@zuke/git@^1 in zuke.ts and its helper modules, rather than in a deno.json imports map — has no manifest for deno outdated to read. The lock keeps resolving the versions recorded when the build was written, and --frozen is perfectly content with that, because a stale-but-valid lock is exactly what --frozen is for.

Nothing fails. The build stays green for months while a wrapper it depends on grows the typed command the build is still hand-rolling. zuke outdated is the signal nothing else gives you; this recipe wires it up so it arrives on its own schedule instead of when someone remembers to look.

Do not put it in the gate

The obvious move is a line in the ci target. Resist it, for two reasons. outdated needs the network, and the gate is deliberately hermetic — that is what makes it reproducible and fast. And a dependency publishing a release would turn a red X on a pull request that has nothing to do with it, blocking an author who cannot fix the cause and did not create it. Freshness is a property of the repository over time, not of a change.

So it gets its own pipeline, on a schedule.

The wiring

import { Build, cicd, run, target } from "jsr:@zuke/core";
import { DenoTasks } from "jsr:@zuke/deno";

class MyBuild extends Build {
  // The ordinary gate: hermetic, on every push and pull request.
  ci = cicd({
    pipeline: {
      name: "CI",
      triggers: { push: ["main"], pullRequest: ["main"] },
    },
  });

  // Freshness, on its own file and its own cadence.
  freshness = cicd({
    path: ".github/workflows/freshness.yml",
    pipeline: {
      name: "Dependency freshness",
      triggers: {
        // 06:00 Monday, in a real timezone (see below).
        schedule: [{ cron: "0 6 * * 1", tz: "Europe/Sofia" }],
        // So anyone can ask the question on demand.
        manual: true,
      },
      jobs: [{
        id: "outdated",
        name: "Are we current?",
        // `outdated` is a reserved command rather than a target, so the job
        // names it directly instead of invoking a target.
        steps: [{ name: "Check", run: "./zuke outdated --exit-code" }],
      }],
    },
  });

  test = target().executes(() => DenoTasks.test((s) => s.allowAll()));
}

await run(MyBuild);
./zuke generate-ci           # writes both workflow files
./zuke generate-ci --check   # the gate: fails if a committed file drifted
./zuke outdated              # ask right now, as a report

What gets generated

The freshness workflow, as written by generate-ci:

name: Dependency freshness
"on":
  workflow_dispatch: {}
  schedule:
    - cron: "0 4 * * 1"
    - cron: "0 3 * * 1"
permissions:
  contents: read
jobs:
  outdated:
    name: "Are we current?"
    runs-on: ubuntu-latest
    needs:
      - zuke-schedule-guard
    if: "${{ needs.zuke-schedule-guard.outputs.run == 'true' }}"
    steps:
      - name: Harden and check out with Zuke
        uses: zuke-build/zuke@… # pinned
        with:
          egress-policy: audit
          persist-credentials: "false"
      - name: Check
        run: ./zuke outdated --exit-code
  zuke-schedule-guard:
    # …resolves the local time and sets run=true only in the intended hour

Two UTC crons for one schedule, and a guard job. GitHub only understands UTC cron, so a schedule in a daylight-saving zone contributes one UTC cron per distinct offset — 0 4 for winter, 0 3 for summer here. Both fire on every occurrence, so a bare pair of crons would run this job twice a week, one of them at the wrong local hour. Zuke generates the zuke-schedule-guard job to compare the real local time against the schedule and let the run proceed only on the matching one. A fixed-offset zone, or plain UTC, needs no guard.

The guard is only generated for GitHub. On Azure, a daylight-saving zone is a hard error at generation time — write the cron in UTC, or use a fixed-offset zone, for that provider. GitLab and Bitbucket configure schedules in the provider UI rather than in-file, so the field is ignored there. See Scheduled pipelines for the full matrix.

Egress. The default hardening policy is audit, which reports egress rather than blocking it. If you tighten a job to egress-policy: block, this one needs jsr.io:443 on its allowed endpoints — it is the one job in the repository whose whole purpose is to talk to the registry.

What --exit-code fails on

Without the flag, outdated is a report: being behind exits 0. With it, the command exits 1 when a package is behind or when a package could not be checked — a private scope, a rename, a runner behind a proxy.

One case fails either way: with no lock file there are no resolved versions to compare, and outdated reports that as an error and exits 1 with or without the flag. If the scheduled job runs before anything has written a lock, it goes red for that reason rather than for staleness.

That second half is the point of the gate. A run that reached nothing at all would otherwise print that every package is at its latest release, which is the confident wrong answer this command exists to prevent. A gate asking "are we current?" has not been told yes by a run that never got an answer.

Refreshing the lock

The output names each package and the version it is behind:

@zuke/git     1.5.0  →  1.11.0
@zuke/gcloud  1.1.0  →  1.3.0

Refreshing the lock is the part that catches people out, because the obvious commands do nothing. Measured against deno 2.9.5, on a stale entry for an inline jsr: specifier:

CommandEffect on the locked version
deno cache --reload=jsr:unchanged — re-resolves from cached registry metadata
deno cache --reloadunchanged — re-downloads sources, keeps the locked resolution
deno outdated --updateunchanged — it reads manifests, and an inline specifier is in no manifest
delete the lock, then re-cachere-resolved

That last row is the one that works. deno outdated --update is the right tool when the dependency is declared in a deno.json imports map — but then you would not have needed zuke outdated to find it, which is the whole point of the command. And in a repository that also has a package.json, deno cache resolves the whole npm tree and writes an npm section a jsr-only lock never had.

Review the lock diff and commit it in the same change, exactly as you would for a deliberate dependency bump — the lock is part of the gate.