diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c26c8f9..5221459 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,3 +92,35 @@ jobs: with: files: coverage/coverage.xml fail_ci_if_error: false + + # Merges a Dependabot pull request that changes only the CI image, after + # lint and test passed. Off unless the repository variable + # CI_IMAGE_AUTO_MERGE is true. + merge-image: + needs: [lint, test] + if: >- + vars.CI_IMAGE_AUTO_MERGE == 'true' && + github.event_name == 'pull_request' && + github.actor == 'dependabot[bot]' && + github.event.pull_request.user.login == 'dependabot[bot]' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + pull-requests: write + + steps: + - name: Merge image update + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + files=$(gh api "repos/$REPO/pulls/$PR/files" --paginate --jq '.[].filename') + if [[ "$files" != ".github/ci-image/Dockerfile" ]]; then + echo "::notice::#$PR changes more than .github/ci-image/Dockerfile and stays open" + exit 0 + fi + # Merges nothing when the branch moved after lint and test + gh pr merge "$PR" -R "$REPO" --squash --match-head-commit "$HEAD_SHA" diff --git a/docs/SETUP.md b/docs/SETUP.md index 32ba910..8d68954 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -35,6 +35,7 @@ sudo pacman -S --needed git openssh go-yq curl pacman-contrib namcap github-cli ### Recommended - **Enable Dependabot version updates**: Settings > Advanced Security, then Enable next to "Dependabot version updates". GitHub leaves them off on forks. Dependabot then opens pull requests for new action versions and for the digest of the Arch image the workflows run in. +- **Merge Arch image updates automatically** (optional): Settings > Secrets and variables > Actions > Variables > New repository variable, with the name `CI_IMAGE_AUTO_MERGE` and the value `true`. A new image then reaches the jobs with the secrets after the tests and without a review, see [WORKFLOW.md](WORKFLOW.md#63-ci-image-updates). - Set repository topics if your fork is public. ### Badges diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md index 8379fd6..86c02ab 100644 --- a/docs/WORKFLOW.md +++ b/docs/WORKFLOW.md @@ -649,7 +649,7 @@ The workflow runs every hour (`cron: "0 * * * *"`) and by hand through `workflow ### 5.2 Permissions and container -The workflow has three jobs after a short `image` job. `image` reads the image from `.github/ci-image/Dockerfile`: `archlinux:base-devel`, pinned by digest, so a new image on Docker Hub reaches the jobs with the secrets only through a reviewed commit. Dependabot updates the digest once a month with a pull request, and `ci.yml` runs the tests in the new image before the merge. The three jobs run in that image on `ubuntu-latest` with a 30-minute timeout, and `vercmp` from pacman, which `check-all.sh` requires, is the reason `verify` and `publish` need Arch too. Every install step upgrades `archlinux-keyring` before the system, because a pinned image ages and an old keyring rejects packages signed with a newer packager key. `makepkg` refuses to run as root, so the steps that touch packages in `prepare` and `publish` run as a `builder` user. `builder` has no `sudo` rights: the steps run as root and switch to `builder` with `sudo -u builder`. `verify` executes no PKGBUILD and runs as root. +The workflow has three jobs after a short `image` job. `image` reads the image from `.github/ci-image/Dockerfile`: `archlinux:base-devel`, pinned by digest, so a new image on Docker Hub reaches the jobs with the secrets only through a reviewed commit. Dependabot updates the digest once a month with a pull request, and `ci.yml` runs the tests in the new image before the merge, see [6.3](#63-ci-image-updates). The three jobs run in that image on `ubuntu-latest` with a 30-minute timeout, and `vercmp` from pacman, which `check-all.sh` requires, is the reason `verify` and `publish` need Arch too. Every install step upgrades `archlinux-keyring` before the system, because a pinned image ages and an old keyring rejects packages signed with a newer packager key. `makepkg` refuses to run as root, so the steps that touch packages in `prepare` and `publish` run as a `builder` user. `builder` has no `sudo` rights: the steps run as root and switch to `builder` with `sudo -u builder`. `verify` executes no PKGBUILD and runs as root. | Job | Permissions | Secrets | |-----|-------------|---------| @@ -785,6 +785,14 @@ bashcov -- bats tests/ --recursive The coverage run uploads `coverage/coverage.xml` to Codecov and never fails the job. +### 6.3 CI image updates + +Once a month, when Docker Hub has a new `archlinux:base-devel`, Dependabot opens a pull request that changes the digest in `.github/ci-image/Dockerfile`. The `image` job reads the digest from the pull request, so `test` runs in the new image before the merge. + +The `merge-image` job merges such a pull request without a review. It is off until the repository variable `CI_IMAGE_AUTO_MERGE` is `true`, see [SETUP.md](SETUP.md#recommended). It runs after `lint` and `test` passed, only for a pull request that `dependabot[bot]` opened, on an event Dependabot caused, and gets `contents: write` and `pull-requests: write`. A pull request that changes any other file, a Dependabot update of an action for example, stays open with a notice. Otherwise the job squash-merges it with `--match-head-commit`, so a commit pushed after the tests stops the merge. + +With the variable set, a new image reaches the jobs with the secrets after the tests and without a review. A branch rule that requires an approval, or an up-to-date branch after `main` has moved, makes the merge fail, and the pull request waits for a manual merge. + ## 7. Local Testing ### 7.1 Prerequisites diff --git a/tests/workflows.bats b/tests/workflows.bats index d9e90d6..007327d 100644 --- a/tests/workflows.bats +++ b/tests/workflows.bats @@ -94,3 +94,63 @@ run_blocks() { [ "$status" -eq 0 ] done } + +@test "merge-image: runs only after lint and test" { + run yq '.jobs["merge-image"].needs | sort | join(" ")' "$GITHUB_DIR/workflows/ci.yml" + [ "$status" -eq 0 ] + [ "$output" = "lint test" ] +} + +@test "merge-image: off unless the variable is true and Dependabot opened the pull request" { + run yq '.jobs["merge-image"].if' "$GITHUB_DIR/workflows/ci.yml" + [ "$status" -eq 0 ] + [[ "$output" == *"vars.CI_IMAGE_AUTO_MERGE == 'true'"* ]] + [[ "$output" == *"github.event_name == 'pull_request'"* ]] + [[ "$output" == *"github.actor == 'dependabot[bot]'"* ]] + [[ "$output" == *"github.event.pull_request.user.login == 'dependabot[bot]'"* ]] + [[ "$output" != *"||"* ]] +} + +# Runs the merge step of ci.yml with a gh that prints the files in +# $GH_FILES and logs every call to $GH_LOG +run_merge_step() { + local bin="$BATS_TEST_TMPDIR/bin" + mkdir -p "$bin" + cat >"$bin/gh" <<'EOF' +#!/usr/bin/env bash +echo "$*" >>"$GH_LOG" +if [[ "$1" == "api" ]]; then + [[ -z "${GH_FAIL:-}" ]] || exit 1 + printf '%s\n' "$GH_FILES" +fi +EOF + chmod +x "$bin/gh" + yq '.jobs["merge-image"].steps[] | select(.name == "Merge image update") | .run' \ + "$GITHUB_DIR/workflows/ci.yml" >"$BATS_TEST_TMPDIR/step.sh" + GH_LOG="$BATS_TEST_TMPDIR/gh.log" + : >"$GH_LOG" + run env PATH="$bin:$PATH" GH_LOG="$GH_LOG" REPO=owner/repo PR=12 HEAD_SHA=abc123 \ + bash -e "$BATS_TEST_TMPDIR/step.sh" +} + +@test "merge-image: merges the tested head when only the Dockerfile changed" { + GH_FILES=".github/ci-image/Dockerfile" run_merge_step + [ "$status" -eq 0 ] + grep -qx 'api repos/owner/repo/pulls/12/files --paginate --jq .\[\].filename' "$GH_LOG" + grep -qx 'pr merge 12 -R owner/repo --squash --match-head-commit abc123' "$GH_LOG" +} + +@test "merge-image: leaves a pull request that changes other files" { + GH_FILES=$'.github/ci-image/Dockerfile\n.github/workflows/ci.yml' run_merge_step + [ "$status" -eq 0 ] + [[ "$output" == *"::notice::"* ]] + run grep -c '^pr merge' "$GH_LOG" + [ "$output" = "0" ] +} + +@test "merge-image: fails when the file list cannot be read" { + GH_FAIL=1 GH_FILES=".github/ci-image/Dockerfile" run_merge_step + [ "$status" -ne 0 ] + run grep -c '^pr merge' "$GH_LOG" + [ "$output" = "0" ] +}