diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70592b9..2efdccd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,17 @@ on: push: branches: [main] pull_request: + # Load-bearing, not a convenience for re-running by hand. `pins.yml` opens its + # pull requests with `gh pr create` under GITHUB_TOKEN, so their author is + # `app/github-actions`, and this repository's approval policy is + # `first_time_contributors`: the `pull_request` run is created and then held in + # `action_required` until a person presses a button. GitHub's own exception is + # the way out and needs no credential -- "`workflow_dispatch` and + # `repository_dispatch` events always create workflow runs" -- so `pins.yml` + # dispatches this workflow on the pin branch after opening the pull request. + # Check runs bind to a commit rather than to an event, and the head of the pin + # branch IS the pull request's head commit, so the same contexts are reported + # against it. Removing this line makes every proposed pin arrive unchecked. workflow_dispatch: permissions: @@ -132,6 +143,18 @@ jobs: - name: The installer the actions share parses run: bash -n setup/install.sh + # The release number is copied out of images/owlab.yaml into everything + # that starts a router, and a stale copy fails nothing: an old release + # still builds, it just stops being the one anybody runs. This is the only + # way that ever goes red. Measured on 2026-09-04 -- `ARG BASE_IMAGE` and + # both release literals here still said 25.12.4 while the config had been + # on 25.12.5 the whole time, and every job was green. + # + # Scoped to the workflows and images/Dockerfile on purpose; the list and + # the reasons for what is left out are in tools/pins.sh. + - name: Every release literal names a release images/owlab.yaml pins + run: sh tools/pins.sh check + # Dogfooding, and the only test here that runs against OpenWrt rather than # against a description of how OpenWrt behaves. # @@ -152,7 +175,7 @@ jobs: fail-fast: false matrix: include: - - {distro: openwrt, release: "25.12.4"} + - {distro: openwrt, release: "25.12.5"} - {distro: openwrt, release: "24.10.8"} - {distro: immortalwrt, release: "25.12.1"} steps: @@ -204,7 +227,7 @@ jobs: - uses: ./action with: - releases: "25.12.4" + releases: "25.12.5" fixtures: "none" assert: | http 200 /cgi-bin/luci/admin/status/overview diff --git a/.github/workflows/pins.yml b/.github/workflows/pins.yml new file mode 100644 index 0000000..61f1ea2 --- /dev/null +++ b/.github/workflows/pins.yml @@ -0,0 +1,220 @@ +name: pins + +# Move the release pins when upstream tags a point release — by opening a pull +# request, never by pushing to main. +# +# The answer has been available since `owlab releases --json` existed; what was +# missing was a reader. `images.yml` calls `/tmp/owlab releases || true` and +# prints the table into a log nobody opens, which is how `ARG BASE_IMAGE` and +# both release literals in `ci.yml` sat on 25.12.4 while the config was on +# 25.12.5, with every job green. This job is the consumer of that JSON, and +# `tools/pins.sh check` in ci.yml is the gate that catches whatever it misses. +# +# It proposes; it does not apply. Moving a pin changes which OpenWrt every e2e +# job and every published image is built against, and the evidence that the new +# release works is the CI run on the pull request — not the fact that a +# directory listing had a larger number in it. `tools/check-updates.sh` in +# owfeed/owfeed-packages is the same shape for the same reason, and the two +# traps below are the ones it paid for. + +on: + schedule: + # Daily, so a point release is noticed within a day rather than within a + # week. Not on the hour, because GitHub's scheduler is heavily contended at + # :00 and delays runs by tens of minutes; not at 04:17 Monday, because that + # is when `images.yml` rebuilds, and two jobs that both build owlab and both + # read the same download server have no reason to do it in the same minute. + - cron: '43 5 * * *' + workflow_dispatch: + +permissions: + contents: read + +# Never two at once. Both would branch from the same main, write the same pins +# and open a second pull request for the same bump. Not cancel-in-progress: a +# run killed between `git push` and `gh pr create` leaves a branch with no pull +# request on it, and nothing would ever go back for it. +concurrency: + group: owlab-pins + cancel-in-progress: false + +jobs: + propose: + runs-on: ubuntu-24.04 + # Written out in full rather than added to: naming any scope in a job-level + # block sets every scope this job does not name to none, so `contents` and + # `pull-requests` have to be repeated here or pushing the branch and opening + # the pull request stop working. + # + # `actions: write` is the dispatch at the end of the step, and it is the one + # permission whose absence is invisible: without it the dispatch is a 403, + # the pull request is open, and its checks never start. See the comment on + # `workflow_dispatch` in ci.yml for why the dispatch is needed at all. + permissions: + contents: write + pull-requests: write + actions: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + + - name: Propose the pins upstream has moved past + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -eu + + go build -o "$RUNNER_TEMP/owlab" ./cmd/owlab + + # From images/, because `owlab releases` compares against the project + # it is standing in. Run anywhere without an owlab.yaml it answers a + # different question — what the download servers publish — and that + # document has no `stale` field at all, so the check below would read + # `null` and this job would do nothing forever. + ( cd images && "$RUNNER_TEMP/owlab" releases --json ) > "$RUNNER_TEMP/releases.json" + cat "$RUNNER_TEMP/releases.json" + + schema="$(jq -r '.schema' "$RUNNER_TEMP/releases.json")" + if [ "$schema" != "owlab.releases/v1" ]; then + echo "owlab releases --json now emits $schema; this job reads owlab.releases/v1" >&2 + echo " update the jq below rather than letting it read fields that moved" >&2 + exit 1 + fi + + stale="$(jq -r '.stale' "$RUNNER_TEMP/releases.json")" + if [ "$stale" = "0" ]; then + echo "every pin is the newest point release of its branch" + exit 0 + fi + + # One line per pin that moved, deduplicated: a branch is pinned by two + # routers (x86_64 and aarch64) carrying the same release, so the pair + # must be applied once rather than twice. + jq -r '.routers[] | select(.behind > 0) | "\(.pinned) \(.newest)"' \ + "$RUNNER_TEMP/releases.json" | sort -u > "$RUNNER_TEMP/pairs" + echo "pins to move:" + sed 's/^/ /' "$RUNNER_TEMP/pairs" + + # Refuse an ambiguous rewrite rather than guess. `tools/pins.sh bump` + # replaces a release literal wherever it appears, and two distributions + # can be pinned to the same release on the same branch — OpenWrt and + # ImmortalWrt have both published a 25.12.1 — and then move at + # different times. One old release with two answers would rewrite one + # distribution's pin to the other's, and the diff would look right. + # `tr -d` because BSD wc pads its count with spaces and GNU wc does + # not, and a string comparison of " 2" against "2" is an + # ambiguity that is not there — which would refuse every bump on a + # developer's Mac while passing on the runner. + olds="$(cut -d' ' -f1 "$RUNNER_TEMP/pairs" | sort -u | wc -l | tr -d ' ')" + rows="$(wc -l < "$RUNNER_TEMP/pairs" | tr -d ' ')" + if [ "$olds" != "$rows" ]; then + echo "the same pinned release maps to more than one newest release:" >&2 + sed 's/^/ /' "$RUNNER_TEMP/pairs" >&2 + echo "move these by hand — a literal rewrite cannot tell them apart" >&2 + exit 1 + fi + + # Redirected from a file rather than piped: a `while read` on the right + # of a pipe runs in a subshell, and everything it changed about this + # shell — including whether it ran at all — is gone by the next line. + while read -r old new; do + sh tools/pins.sh bump "$old" "$new" + done < "$RUNNER_TEMP/pairs" + + if git diff --quiet; then + echo "the report says stale but no pin file changed" + echo " the pins are somewhere tools/pins.sh does not look; nothing to propose" + exit 0 + fi + git --no-pager diff --stat + + # What the pull request has to survive anyway, run here so a rewrite + # that cannot be read never reaches a reviewer. `context --list` is the + # stronger of the two: it resolves the matrix against the download + # server, so a release that is tagged but has no artifacts for a target + # fails here rather than twenty minutes into a build. + sh tools/pins.sh check + ( cd images && "$RUNNER_TEMP/owlab" context --list >/dev/null ) + + # Named for what it proposes, so a re-run before the pull request is + # merged lands on the same branch instead of opening a second one. + branch="pins/$(cut -d' ' -f2 "$RUNNER_TEMP/pairs" | sort -u | tr '\n' '-' | sed 's/-$//')" + + # Captured, not piped into grep. A pipeline reports the status of its + # LAST command, so a `gh` that failed would reach grep as empty output + # and read as "no pull request is open" — the one answer that makes + # this push a branch and open a duplicate. Measured in + # owfeed-packages, where a job without a checkout failed exactly here. + # + # `-R "$GITHUB_REPOSITORY"` on every gh call for the same reason: gh + # falls back to `git remote` to decide which repository it means, and + # the fallback is one refactor away from not being there. + open="$(gh pr list -R "$GITHUB_REPOSITORY" --head "$branch" --state open \ + --json number -q '.[0].number')" + if [ -n "$open" ]; then + echo "#$open already proposes $branch" + exit 0 + fi + + subject="chore(pins): $(sed 's/^\(.*\) \(.*\)$/\1 -> \2/' "$RUNNER_TEMP/pairs" \ + | tr '\n' ',' | sed 's/,$//; s/,/, /g')" + + { + echo "Upstream publishes a newer point release on these branches:" + echo + sed 's/^\(.*\) \(.*\)$/- \`\1\` -> \`\2\`/' "$RUNNER_TEMP/pairs" + echo + echo '```' + jq -r '.routers[] | select(.behind > 0) + | "\(.router) \(.distro) \(.pinned) -> \(.newest) (\(.behind) behind)"' \ + "$RUNNER_TEMP/releases.json" + echo '```' + echo + echo "Rewritten by \`tools/pins.sh bump\`, which moves a release literal only where" + echo "this repository runs one: \`images/owlab.yaml\`, \`images/Dockerfile\` and the" + echo "workflows. Prose, \`examples/\` and the READMEs keep the release they name." + echo + echo "What this does not prove is that the new release still builds a router. That is" + echo "what the CI run on this branch is for: the e2e job installs on both package" + echo "managers against a real image, and \`tools/pins.sh check\` re-reads the rewrite." + } > "$RUNNER_TEMP/body.md" + + git config user.name "owlab-bot" + git config user.email "owlab-bot@users.noreply.github.com" + git switch -c "$branch" + # One file for both, so the commit a reviewer reads in `git log` says + # the same thing as the pull request they read it from. `-F` rather + # than `-m`: git refuses the two together. + { echo "$subject"; echo; cat "$RUNNER_TEMP/body.md"; } > "$RUNNER_TEMP/message" + git commit -a -F "$RUNNER_TEMP/message" + + # --force-with-lease, because a run that pushed the branch and then + # failed before opening the pull request leaves it behind, and the next + # run's push is a non-fast-forward against it — so one failure would + # wedge this bump until somebody deleted the branch by hand. The branch + # belongs to this job and its content is derived from the report, so + # replacing it loses nothing; the lease still refuses if someone else + # moved it. + git push -u --force-with-lease origin "$branch" + + url="$(gh pr create -R "$GITHUB_REPOSITORY" --base main --head "$branch" \ + --title "$subject" --body-file "$RUNNER_TEMP/body.md")" + echo "$url" + + # Start the checks by hand, because GitHub will not start them itself + # for a pull request authored by `app/github-actions` under this + # repository's approval policy. The reasoning is on `workflow_dispatch` + # in ci.yml. + # + # Not allowed to fail the run: the job's product is the pull request + # and by this line it exists. Reported rather than swallowed, because a + # silent `|| true` leaves a pull request whose checks nobody will start. + if gh workflow run ci.yml -R "$GITHUB_REPOSITORY" --ref "$branch"; then + echo "ci dispatched on $branch" + else + echo "CHECKS NOT DISPATCHED — the pull request is open and nothing is running against it" + echo " run 'gh workflow run ci.yml -R $GITHUB_REPOSITORY --ref $branch'," + echo " or approve the waiting run by hand" + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e446f1..d92d9a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,40 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). today keeps working across minor and patch releases; a change that would break one waits for a major. +## [Unreleased] + +### Added + +- **A release pin that goes stale now fails a build.** `sh tools/pins.sh check` + reads the pins out of `images/owlab.yaml` and refuses any release literal in + `images/Dockerfile` or a workflow that names a release the config does not + pin; `ci.yml` runs it on every push. Nothing caught this before, because an + old release still builds — on 2026-09-04 `ARG BASE_IMAGE` and both release + literals in `ci.yml` still said 25.12.4 while the config had been on 25.12.5 + since it was written, and every job was green the whole time. Comments, + `examples/`, the READMEs and `docs/` are outside the check on purpose: a + release number there is an illustration, and the README's sample `owlab + releases` output shows a pin one release behind deliberately. +- **`pins.yml` proposes the bump upstream has made available.** Daily, it asks + `owlab releases --json` — the report that has existed since 0.2.0 and that + nothing read — and when `stale` is not zero it rewrites the pins with + `tools/pins.sh bump`, validates the result with `tools/pins.sh check` and + `owlab context --list`, and opens a pull request. It never pushes to `main`: + what proves a new release still builds a router is the CI run on that pull + request. It then dispatches `ci.yml` on the pin branch, because a pull request + authored by `app/github-actions` has its `pull_request` run held in + `action_required` under this repository's approval policy, and + `workflow_dispatch` is GitHub's own documented exception to that. + +### Changed + +- The e2e matrix, the `action` job's `releases:` input and `ARG BASE_IMAGE` move + from 25.12.4 to 25.12.5, which is what `images/owlab.yaml` has pinned all + along. `openwrt/rootfs:x86_64-25.12.5` was not on Docker Hub when this landed + — upstream tags the download server first — and it does not matter: `owlab + context` asks the registry and falls back to the rootfs tarball, which is the + path the published 25.12.5 images were already built through. + ## [0.5.6] - 2026-09-04 ### Fixed diff --git a/docs/runbook.md b/docs/runbook.md index 5ca10d4..5e7f856 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -256,6 +256,44 @@ reports the release you pinned. Do not pin `snapshot`. Snapshot images and snapshot feeds are rebuilt daily and independently, so installs start failing within a day. +### Check the copies of the pin + +`images/owlab.yaml` is the pin a person edits. The same release number is also +copied into `images/Dockerfile` and the CI workflows, which start a router +without reading that config. + +```console +$ sh tools/pins.sh check +images/owlab.yaml pins: 24.10.6 24.10.8 25.12.1 25.12.5 +every release literal in the pin files is one images/owlab.yaml pins +``` + +It fails when a workflow or `images/Dockerfile` names a release +`images/owlab.yaml` does not pin, and `ci.yml` runs it on every push. Nothing +else catches that: an old release still builds, so a forgotten copy stays green +while CI tests a release nobody runs. + +To move every copy of one release at once: + +```console +$ sh tools/pins.sh bump 25.12.4 25.12.5 +``` + +Comments, `examples/`, the READMEs and `docs/` keep the release they name. +Both commands leave them alone, because a release number in prose is an +illustration — the sample output above this section is a pin deliberately one +release behind. + +### The job that proposes a bump + +`pins.yml` runs daily, asks `owlab releases --json` the same question, and opens +a pull request when the answer is not "up to date". It rewrites the pins, runs +`tools/pins.sh check` and `owlab context --list` over the result, and stops +there. + +It never pushes to `main`. What proves a new release still builds a router is +the CI run on that pull request, not a larger number in a directory listing. + --- ## Install a package to try it diff --git a/docs/runbook_ru.md b/docs/runbook_ru.md index c59460f..72d93c0 100644 --- a/docs/runbook_ru.md +++ b/docs/runbook_ru.md @@ -255,6 +255,45 @@ $ owlab exec owrt2512 -- 'ubus call system board | grep version' Не пиньте `snapshot`. Snapshot-образы и snapshot-фиды пересобираются ежедневно и независимо, поэтому установки начинают падать в течение суток. +### Проверить копии пина + +`images/owlab.yaml` — пин, который правит человек. Тот же номер релиза +скопирован ещё в `images/Dockerfile` и в CI-воркфлоу: они поднимают роутер, не +читая этот конфиг. + +```console +$ sh tools/pins.sh check +images/owlab.yaml pins: 24.10.6 24.10.8 25.12.1 25.12.5 +every release literal in the pin files is one images/owlab.yaml pins +``` + +Команда падает, когда воркфлоу или `images/Dockerfile` называют релиз, которого +нет среди пинов `images/owlab.yaml`; `ci.yml` запускает её на каждый push. +Больше это не ловит ничто: старый релиз всё ещё собирается, поэтому забытая +копия остаётся зелёной, а CI тестирует релиз, на котором никто не работает. + +Сдвинуть все копии одного релиза сразу: + +```console +$ sh tools/pins.sh bump 25.12.4 25.12.5 +``` + +Комментарии, `examples/`, README и `docs/` сохраняют тот релиз, который в них +написан. Обе команды их не трогают: номер релиза в тексте — иллюстрация, и +пример вывода выше в этом разделе намеренно показывает пин, отставший на один +релиз. + +### Джоба, которая предлагает сдвиг + +`pins.yml` запускается ежедневно, задаёт `owlab releases --json` тот же вопрос и +открывает пулл-реквест, когда ответ не «up to date». Она переписывает пины, +прогоняет по результату `tools/pins.sh check` и `owlab context --list` и на этом +останавливается. + +В `main` она не пишет никогда. То, что новый релиз всё ещё собирает роутер, +доказывает прогон CI на этом пулл-реквесте, а не бóльшая цифра в листинге +каталога. + --- ## Поставить пакет попробовать diff --git a/images/Dockerfile b/images/Dockerfile index 7ee405b..0d22ae1 100644 --- a/images/Dockerfile +++ b/images/Dockerfile @@ -24,7 +24,17 @@ ARG BASE_STAGE=upstream # Both of these must be declared here, before the first FROM: an ARG inside a # stage is scoped to that stage, and `FROM ${BASE_STAGE}` would then resolve # against an empty base name. -ARG BASE_IMAGE=openwrt/rootfs:x86_64-25.12.4 +# +# The default is never what builds an image -- owlab passes BASE_IMAGE on every +# invocation -- but it is read as documentation, so it is held to the pin in +# owlab.yaml by `tools/pins.sh check`. It is also the one place the tarball +# fallback does not reach: `owlab context` asks the registry first and switches +# to ROOTFS_URL when the release has no container image yet, and on 2026-09-04 +# `openwrt/rootfs:x86_64-25.12.5` was exactly that -- tagged on the download +# server, not yet mirrored to Docker Hub. A bare `docker build images/` with +# these defaults would have failed on the pull while every owlab build of the +# same release succeeded. +ARG BASE_IMAGE=openwrt/rootfs:x86_64-25.12.5 ARG ROOTFS_URL= ARG ALPINE_TAG=3.22 diff --git a/tools/pins.sh b/tools/pins.sh new file mode 100755 index 0000000..73f3b07 --- /dev/null +++ b/tools/pins.sh @@ -0,0 +1,170 @@ +#!/bin/sh +# The release pins that are not images/owlab.yaml, and the two things worth +# doing to them. +# +# sh tools/pins.sh check # do the copies still agree with the config? +# sh tools/pins.sh bump 25.12.4 25.12.5 # move every copy of one release +# +# owlab pins hard -- a rootfs and its feed have to name the same point release +# or every install fails -- so the number is copied into every place that +# starts a router. `images/owlab.yaml` is the one a person edits. Everything +# else is a copy, and a copy goes stale without failing: an old release still +# builds, it just stops being the release anyone runs. Measured on 2026-09-04, +# `ARG BASE_IMAGE` and both release literals in `ci.yml` still said 25.12.4 +# while the config had been on 25.12.5 since it was written, and every job was +# green the whole time. +# +# `check` is the gate in ci.yml. `bump` is what pins.yml runs before opening a +# pull request. They share this file so that "where the pins live" is one list +# with one meaning, rather than a workflow and a checker that can disagree +# about which files count. +set -eu + +CONFIG=images/owlab.yaml + +# The files this repository RUNS a release number out of. Not the ones that +# print one. +# +# Deliberately outside the list, and this is the whole reason it is narrow -- +# a check that fires on a healthy tree is worse than no check: +# +# README*.md the sample `owlab releases` output shows a pin one +# release behind ON PURPOSE; it is what the command looks +# like when it has something to say +# docs/, CHANGELOG prose about releases that happened, and history that +# cannot be rewritten +# examples/ configs a reader copies into their own repository, where +# the release is an illustration and not this repo's pin +# action/action.yml input documentation, inside a YAML block scalar +# internal/, cmd/ test fixtures and the examples in error messages +# +# $CONFIG is not here either: it is the answer, not a copy of it. +pin_files() { + for f in .github/workflows/*.yml images/Dockerfile; do + [ -f "$f" ] || continue + echo "$f" + done +} + +# Every release literal on a line that is not a comment, as `file:line:release`. +# +# Comments are skipped because in these files they are evidence. `images/Dockerfile` +# explains a real apk failure with "a 25.12.4 rootfs pointed at the 25.12.5 feed", +# and `images.yml` explains the resolver with "upstream tagging 25.12.5". Holding +# those to the current pin would either fire on a healthy tree or, worse, let +# `bump` rewrite a measurement into something that was never measured. +# +# Two digits, a dot, two digits, a dot: an OpenWrt release is YY.MM.N. The +# tokeniser matches any three-part number and then filters, so `v7.0.1`, +# `ubuntu-24.04-arm` and `go 1.24.3` are read and discarded rather than never +# looked at -- a regex tuned tightly enough to skip them silently would also +# skip a release nobody meant to leave behind. +literals() { + awk ' + /^[[:space:]]*#/ { next } + { + s = $0 + while (match(s, /[0-9]+\.[0-9]+\.[0-9]+/)) { + v = substr(s, RSTART, RLENGTH) + if (v ~ /^[0-9][0-9]\.[0-9][0-9]\.[0-9]+$/) { + print FILENAME ":" FNR ":" v + } + s = substr(s, RSTART + RLENGTH) + } + } + ' "$1" +} + +cmd_check() { + pins="$(sed -n 's/^[[:space:]]*release:[[:space:]]*"\([0-9][0-9.]*\)".*/\1/p' "$CONFIG" | sort -u)" + + # An extractor that found nothing is not a clean tree. Without this the + # check passes hardest exactly when it has stopped working -- somebody + # reformats the config, no pin is ever read again, and every literal below + # is compared against an empty list and reported as wrong. Which is the + # louder half of the failure; the quiet half is that `bump` then has + # nothing to move. + if [ -z "$pins" ]; then + echo "$CONFIG: no 'release:' pins found" >&2 + echo " the extractor in tools/pins.sh is broken, not the tree" >&2 + exit 1 + fi + echo "$CONFIG pins: $(echo "$pins" | tr '\n' ' ')" + + bad=0 + for f in $(pin_files); do + for hit in $(literals "$f"); do + v="${hit##*:}" + if printf '%s\n' "$pins" | grep -qxF "$v"; then + continue + fi + echo " ${hit%:*}: $v is not a release $CONFIG pins" + bad=$((bad + 1)) + done + done + + if [ "$bad" -gt 0 ]; then + echo + echo "$bad release literal(s) name a release $CONFIG does not pin." >&2 + echo "This is the copy that was forgotten when the pin moved: CI keeps" >&2 + echo "testing a release nobody runs, and nothing goes red to say so." >&2 + echo >&2 + echo "Fix, in whichever direction is true:" >&2 + echo " sh tools/pins.sh bump " >&2 + echo " or move the pin in $CONFIG and run it the other way round" >&2 + echo >&2 + echo "Re-check with: sh tools/pins.sh check" >&2 + exit 1 + fi + echo "every release literal in the pin files is one $CONFIG pins" +} + +cmd_bump() { + old="${1:-}" + new="${2:-}" + if [ -z "$old" ] || [ -z "$new" ]; then + echo "usage: sh tools/pins.sh bump " >&2 + exit 2 + fi + + # Token replacement rather than sed, for one reason: `24.10.1` is a prefix + # of `24.10.10`, and a branch reaching double digits is a matter of time. + # The same tokeniser as `literals` above, so what gets rewritten is exactly + # what gets checked. + for f in $CONFIG $(pin_files); do + awk -v old="$old" -v new="$new" ' + /^[[:space:]]*#/ { print; next } + { + s = $0 + out = "" + while (match(s, /[0-9]+\.[0-9]+\.[0-9]+/)) { + v = substr(s, RSTART, RLENGTH) + out = out substr(s, 1, RSTART - 1) + out = out (v == old ? new : v) + s = substr(s, RSTART + RLENGTH) + } + print out s + } + ' "$f" > "$f.pins.tmp" + if cmp -s "$f" "$f.pins.tmp"; then + rm -f "$f.pins.tmp" + continue + fi + mv "$f.pins.tmp" "$f" + echo "$f: $old -> $new" + done +} + +case "${1:-}" in +check) + cmd_check + ;; +bump) + shift + cmd_bump "$@" + ;; +*) + echo "usage: sh tools/pins.sh check | bump " >&2 + exit 2 + ;; +esac