Freshness, port keyring pins #3
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Freshness, port keyring pins | |
| # Two ports sign with keys archlinux-keyring does not carry, and each has a pin | |
| # in bootstrap/keyrings/. A pinned keyring package name carries its version, and | |
| # a port removes a superseded package from its mirror, so a pin that falls | |
| # behind stops being fetchable and every build for that port fails. This job | |
| # notices before that happens, applies the bump on a branch, tests it, and opens | |
| # a pull request. | |
| # | |
| # ⛔ One job per pin. Two trust roots moving in one pull request would hide which | |
| # one moved, and the two are tested by building different architectures. | |
| # | |
| # A failing run here is a normal result. It means upstream moved. | |
| on: | |
| workflow_dispatch: | |
| schedule: | |
| - cron: "15 04 * * 1" # Mondays, 04:15 UTC | |
| defaults: | |
| run: | |
| shell: bash | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: freshness-keyring | |
| cancel-in-progress: false | |
| jobs: | |
| check: | |
| name: Check the ${{ matrix.keyring }} pin | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| strategy: | |
| # One run reports every stale pin. Neither blocks the other. | |
| fail-fast: false | |
| matrix: | |
| include: | |
| # docker_arch is the architecture built to test a bump. Both ARM ports | |
| # share the ARM keyring, so one of the two exercises it. | |
| - keyring: archlinuxarm | |
| pin: bootstrap/keyrings/archlinuxarm.pin | |
| docker_arch: arm64 | |
| platform: linux/arm64 | |
| - keyring: archlinux-lcpu | |
| pin: bootstrap/keyrings/archlinux-lcpu.pin | |
| docker_arch: loong64 | |
| platform: linux/loong64 | |
| # All three PowerPC ports share one trust root, so one of the three | |
| # exercises it. ppc64le is the one chosen because it is the only | |
| # PowerPC target the standard QEMU setup already covers, so this job | |
| # needs no emulator registration of its own. | |
| - keyring: archpower | |
| pin: bootstrap/keyrings/archpower.pin | |
| docker_arch: ppc64le | |
| platform: linux/ppc64le | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - name: Compare the pin against the mirror | |
| id: check | |
| env: | |
| PIN: ${{ matrix.pin }} | |
| run: | | |
| set -uo pipefail | |
| # 0 current, 3 behind, 4 gone, 5 the pinned bytes changed, 1 the check | |
| # could not run. | |
| scripts/check-keyring-pin "$PIN" | tee /tmp/check.txt | |
| rc="${PIPESTATUS[0]}" | |
| echo "rc=$rc" >> "$GITHUB_OUTPUT" | |
| case "$rc" in | |
| 0) echo "::notice::${PIN} is current" ;; | |
| 3) echo "::warning::a newer keyring is published for ${PIN}, preparing a pull request" ;; | |
| 4) echo "::error::the package ${PIN} pins is gone from the mirror, that port is broken now" ;; | |
| 5) echo "::error::the package ${PIN} pins still answers and its bytes changed" ;; | |
| *) echo "the freshness check could not run" >&2; exit 1 ;; | |
| esac | |
| # ⛔ Reported on every run, including a green one. A trusted key past its | |
| # expiry signs nothing pacman will take, so the count of usable keys falls | |
| # with no upstream change at all and nothing else would say so. | |
| - name: Report the expiry horizon | |
| env: | |
| EXPIRED: ${{ steps.check.outputs.expired_now }} | |
| SOON: ${{ steps.check.outputs.expiring_soon }} | |
| COUNT: ${{ steps.check.outputs.trusted_count }} | |
| KEYRING: ${{ matrix.keyring }} | |
| run: | | |
| set -euo pipefail | |
| echo "${KEYRING}: ${COUNT} trusted fingerprint(s) pinned" | |
| echo " expired today : ${EXPIRED:-none}" | |
| echo " expiring < 1y : ${SOON:-none}" | |
| if [ -n "${SOON:-}" ] && [ "${SOON:-none}" != "none" ]; then | |
| echo "::warning::${KEYRING} has trusted keys expiring within a year: ${SOON}" | |
| fi | |
| - name: Apply the bump on a branch | |
| if: steps.check.outputs.rc != '0' | |
| id: apply | |
| env: | |
| PIN: ${{ matrix.pin }} | |
| KEYRING: ${{ matrix.keyring }} | |
| run: | | |
| set -uo pipefail | |
| branch="freshness/${KEYRING}-$(date -u +%Y%m%d)" | |
| git switch -c "$branch" | |
| scripts/check-keyring-pin --apply "$PIN" | tee /tmp/apply.txt | |
| rc="${PIPESTATUS[0]}" | |
| case "$rc" in | |
| 3 | 4 | 5) ;; | |
| *) | |
| echo "applying the bump did not report a bump, refusing to open a pull request" >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| # check-keyring-pin writes the measured values straight to | |
| # GITHUB_OUTPUT when it runs under Actions, so nothing is re-parsed | |
| # out of its human readable output here. | |
| echo "branch=$branch" >> "$GITHUB_OUTPUT" | |
| git diff --stat | |
| - name: Set up QEMU | |
| if: steps.check.outputs.rc != '0' | |
| uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 | |
| - name: Test the bump with the tests that guard a normal build | |
| if: steps.check.outputs.rc != '0' | |
| id: test | |
| env: | |
| DOCKER_ARCH: ${{ matrix.docker_arch }} | |
| PLATFORM: ${{ matrix.platform }} | |
| run: | | |
| set -euo pipefail | |
| echo "::group::static suite" | |
| tests/run.sh static | |
| echo "::endgroup::" | |
| # One architecture is built and inspected, because a keyring that | |
| # installs but does not verify produces an empty root rather than a | |
| # build error. | |
| ver="$(date -u +%Y.%m.%d)" | |
| docker build --platform "$PLATFORM" \ | |
| --build-arg "IMAGE_VERSION=$ver" \ | |
| --build-arg "SOURCE_COMMIT=${{ github.sha }}" \ | |
| --build-arg "BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ | |
| -t "freshness-check:$DOCKER_ARCH" . | |
| # The databases the build resolved against, so the evidence is not | |
| # joined against a set that moved. HISTORY/evidence-race.md. | |
| docker build --platform "$PLATFORM" --target dbsnapshot \ | |
| --build-arg "IMAGE_VERSION=$ver" \ | |
| --output "type=local,dest=/tmp/dbsnapshot" . | |
| echo "::group::image suite" | |
| # ⛔ CONTAINER_RUNTIME is not optional. The runner image carries both | |
| # docker and podman, and gen-evidence prefers podman when it finds | |
| # one. The image was built with docker and lives in docker's store, so | |
| # podman tries to pull it from Docker Hub and quay.io and fails. | |
| SOURCE_COMMIT="${{ github.sha }}" BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ | |
| DB_SNAPSHOT=/tmp/dbsnapshot CONTAINER_RUNTIME=docker \ | |
| scripts/gen-evidence "$DOCKER_ARCH" "freshness-check:$DOCKER_ARCH" "$PLATFORM" \ | |
| "/tmp/evidence-$DOCKER_ARCH.json" | |
| IMAGE="freshness-check:$DOCKER_ARCH" PLATFORM="$PLATFORM" CONTAINER_RUNTIME=docker \ | |
| EVIDENCE="/tmp/evidence-$DOCKER_ARCH.json" tests/run.sh image | |
| echo "::endgroup::" | |
| pkgs="$(jq -r '.package_count' "/tmp/evidence-$DOCKER_ARCH.json")" | |
| echo "packages=$pkgs" >> "$GITHUB_OUTPUT" | |
| - name: Open a pull request carrying the measurement | |
| if: steps.check.outputs.rc != '0' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| BRANCH: ${{ steps.apply.outputs.branch }} | |
| PIN: ${{ matrix.pin }} | |
| KEYRING: ${{ matrix.keyring }} | |
| DOCKER_ARCH: ${{ matrix.docker_arch }} | |
| OLD_PACKAGE: ${{ steps.apply.outputs.old_package }} | |
| OLD_SHA: ${{ steps.apply.outputs.old_sha256 }} | |
| NEW_PACKAGE: ${{ steps.apply.outputs.new_package }} | |
| NEW_SHA: ${{ steps.apply.outputs.new_sha256 }} | |
| NEW_SIZE: ${{ steps.apply.outputs.new_size }} | |
| TRUSTED_COUNT: ${{ steps.apply.outputs.trusted_count }} | |
| TRUSTED_ADDED: ${{ steps.apply.outputs.trusted_added }} | |
| TRUSTED_REMOVED: ${{ steps.apply.outputs.trusted_removed }} | |
| EXPIRED: ${{ steps.apply.outputs.expired_now }} | |
| SOON: ${{ steps.apply.outputs.expiring_soon }} | |
| PINNED_HTTP: ${{ steps.apply.outputs.pinned_http }} | |
| PACKAGES: ${{ steps.test.outputs.packages }} | |
| RC: ${{ steps.check.outputs.rc }} | |
| run: | | |
| set -euo pipefail | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| git add "$PIN" | |
| git commit -m "keyring: pin ${NEW_PACKAGE}" | |
| git push --set-upstream origin "$BRANCH" | |
| urgency="a newer keyring is published and the pinned one still answers" | |
| case "$RC" in | |
| 4) urgency="the pinned keyring is GONE from the mirror, so ${DOCKER_ARCH} builds fail until this merges" ;; | |
| 5) urgency="⛔ the pinned package name still answers and its bytes are not the bytes that were pinned" ;; | |
| esac | |
| # The printf formats are markdown. Backticks inside them are literal | |
| # text for the pull request body, not shell expansions. | |
| # shellcheck disable=SC2016 | |
| { | |
| printf '## What moved\n\n' | |
| printf 'The `%s` trust root changed. %s\n\n' "$KEYRING" "$urgency" | |
| printf '| | pinned before | pinned now |\n' | |
| printf '| --- | --- | --- |\n' | |
| printf '| package | `%s` | `%s` |\n' "$OLD_PACKAGE" "$NEW_PACKAGE" | |
| printf '| sha256 | `%s` | `%s` |\n\n' "$OLD_SHA" "$NEW_SHA" | |
| printf 'The old package answered HTTP `%s` at check time. The new package is\n' "$PINNED_HTTP" | |
| printf '`%s` bytes and trusts `%s` fingerprint(s).\n\n' "$NEW_SIZE" "$TRUSTED_COUNT" | |
| printf '| trusted keys | fingerprints |\n' | |
| printf '| --- | --- |\n' | |
| printf '| added | `%s` |\n' "$TRUSTED_ADDED" | |
| printf '| removed | `%s` |\n' "$TRUSTED_REMOVED" | |
| printf '| expired as of today | `%s` |\n' "$EXPIRED" | |
| printf '| expiring within a year | `%s` |\n\n' "$SOON" | |
| printf 'Every value above was re-derived from the package itself by\n' | |
| printf '`scripts/check-keyring-pin`. None was copied from a report. The expiry\n' | |
| printf 'dates come from the key material, so a change to one of them is the\n' | |
| printf 'trust root moving and not a clock ticking.\n\n' | |
| printf '## What the tests did\n\n' | |
| printf -- '- `tests/run.sh static`, the same suite the build job runs first.\n' | |
| printf -- '- A full `%s` build from this branch.\n' "$DOCKER_ARCH" | |
| printf -- '- `scripts/gen-evidence` then `tests/run.sh image` against that build,\n' | |
| printf ' which installed `%s` packages.\n\n' "$PACKAGES" | |
| printf 'Signature verification stayed at `SigLevel = Required` throughout. A\n' | |
| printf 'keyring that installs but does not verify produces an empty root, which\n' | |
| printf 'the image suite fails on rather than passing.\n\n' | |
| printf '## ⚠ What this job did NOT verify\n\n' | |
| printf -- '- **Only `%s` was built.** The other architectures were not.\n' "$DOCKER_ARCH" | |
| printf -- '- **The new fingerprints were not checked against any source other than\n' | |
| printf ' the package that carries them.** That is the trust model these pins use,\n' | |
| printf ' and it is why the sha256 is pinned alongside them.\n' | |
| printf -- '- **No image was published.** The build stayed on the runner.\n' | |
| printf -- '- **The mirror was read once.** A mirror mid sync can serve an older\n' | |
| printf ' listing than another mirror.\n' | |
| printf -- '- **This pull request carries no status check.** A pull request opened\n' | |
| printf ' with the built-in token has its run held at `action_required` until a\n' | |
| printf ' human approves it. The tree was tested inside run `%s`, linked above.\n' "$GITHUB_RUN_ID" | |
| printf ' See `HISTORY/maintainer-actions.md` section 4.\n' | |
| } > /tmp/body.md | |
| title="keyring: pin ${NEW_PACKAGE}" | |
| case "$RC" in | |
| 4) title="keyring: pin ${NEW_PACKAGE} (${DOCKER_ARCH} builds are broken until this merges)" ;; | |
| 5) title="keyring: ${OLD_PACKAGE} changed under its own name" ;; | |
| esac | |
| gh pr create --base main --head "$BRANCH" --title "$title" --body-file /tmp/body.md |