diff --git a/.github/workflows/renovate.yml b/.github/workflows/renovate.yml deleted file mode 100644 index db31616..0000000 --- a/.github/workflows/renovate.yml +++ /dev/null @@ -1,65 +0,0 @@ -# Reusable Renovate runner — the consumer-side updater a repo can't get from -# Dependabot. Each consumer calls this on a schedule from its own thin wrapper, -# exactly like upgrade-check.yml: it runs against the CALLING repo -# (github.repository), never a hardcoded list. -# -# Renovate reads composite action.yml (Dependabot is blind to composites — -# dependabot-core#6704), so it keeps every whuppi/ci ref uniform (workflow AND -# composite) and bumps third-party actions hidden in composites. Update rules -# live in the consumer's own renovate.json5. -# -# TOKEN: RENOVATE_TOKEN — a fine-grained PAT (Contents + Workflows + -# Pull-requests + Issues: write) on all whuppi repos, held as a whuppi ORG -# secret and passed in by the caller. GITHUB_TOKEN can't write .github/workflows/, -# which is exactly what Renovate must do — hence the PAT. -name: Renovate - -on: - workflow_call: - inputs: - dryRun: - type: boolean - required: false - default: true # safe default; callers opt into live with dryRun: false - logLevel: - type: string - required: false - default: 'info' - secrets: - RENOVATE_TOKEN: - required: true - -permissions: {} - -concurrency: - group: renovate-${{ github.repository }} - cancel-in-progress: false - -jobs: - renovate: - name: Renovate - runs-on: ubuntu-24.04 - timeout-minutes: 30 - permissions: - contents: write # push bump branches - pull-requests: write # open bump PRs - issues: write # the dependency dashboard issue - steps: - # No checkout: the Renovate action clones the target repo itself via the - # token (which is why there are no persisted git credentials to leak). - - uses: renovatebot/github-action@3064367f740a1a91cca218698a63902689cce200 # v46.1.20 - with: - token: ${{ secrets.RENOVATE_TOKEN }} - renovate-version: 43.279.0 - env: - # The calling consumer repo — generic, never hardcoded. - RENOVATE_REPOSITORIES: ${{ github.repository }} - RENOVATE_AUTODISCOVER: 'false' - # Commit via the GitHub API, not local git push — avoids the - # "repository has changed during renovation" abort that a repo's CI - # reacting to the pushed branch can otherwise trigger. - RENOVATE_PLATFORM_COMMIT: 'enabled' - # dryRun true → 'full' (detect only); false → '' (live). 'full' is - # truthy so this ternary is sound. - RENOVATE_DRY_RUN: ${{ inputs.dryRun && 'full' || '' }} - LOG_LEVEL: ${{ inputs.logLevel }} diff --git a/.github/workflows/upgrade-check.yml b/.github/workflows/upgrade-check.yml index 0b61aed..66236f5 100644 --- a/.github/workflows/upgrade-check.yml +++ b/.github/workflows/upgrade-check.yml @@ -1,13 +1,23 @@ # Reusable consumer upgrade radar — things a consuming repo owns that its # updater doesn't cover here, each in its own reviewed PR: -# flutter-sdk — the Flutter SDK pin (.fvmrc + example/.fvmrc) → latest stable. -# lockfiles — pubspec.lock files refreshed to latest in-range. -# whuppi/ci refs (workflows AND composite action.yml) are bumped by Renovate -# (renovate.yml) — it reads composite action.yml, which Dependabot can't -# (dependabot/dependabot-core#6704). The shared tool/binary pins (fvm, Chrome, -# bore, zizmor, actionlint) reach consumers through a whuppi/ci release + -# Renovate bumping the whuppi/ci refs; whuppi/ci owns their versions via -# self-upgrade.yml. +# flutter-sdk — the Flutter SDK pin (.fvmrc + example/.fvmrc) → latest stable. +# lockfiles — pubspec.lock files refreshed to latest in-range. +# composite-refs — the dependabot-core#6704 blind spot (opt-in): every +# whuppi/ci ref (workflow AND composite, kept uniform so the +# pin never splits) → latest release, plus third-party +# actions pinned inside composite action.yml → latest SHA. +# +# Dependabot still owns pub deps and third-party actions in workflow FILES; the +# composite-refs job owns only what Dependabot can't see (dependabot never reads +# `uses:` inside composite action.yml, and a whuppi/ci group bump splits the pin +# across workflow-vs-composite). A consumer opts in with `sweepActions: true` +# and passes CI_ACTIONS_TOKEN — a Workflows-scope PAT, because GITHUB_TOKEN +# can't write .github/workflows/ — and drops `whuppi/ci*` from its Dependabot +# github-actions group. +# +# The shared tool/binary pins (fvm, Chrome, bore, zizmor, actionlint, pinact) +# reach consumers through a whuppi/ci release + the composite-refs whuppi/ci +# bump; whuppi/ci owns their versions via self-upgrade.yml. name: Upgrade Check on: @@ -21,6 +31,17 @@ on: type: string required: false default: 'dev' + sweepActions: + # Opt in to the composite-refs job. Off by default so a consumer that + # hasn't set up CI_ACTIONS_TOKEN keeps its old Dependabot-only shape. + type: boolean + required: false + default: false + secrets: + CI_ACTIONS_TOKEN: + # Workflows-scope PAT for the composite-refs job's push (GITHUB_TOKEN + # can't write .github/workflows/). Only needed when sweepActions: true. + required: false permissions: {} @@ -208,3 +229,180 @@ jobs: else gh pr edit "$EXISTING_PR" --title "chore: refresh lockfiles" --body "$PR_BODY" fi + + # ── Composite + workflow action refs (the dependabot-core#6704 gap) ── + # Opt-in (sweepActions: true). Two bumps Dependabot can't reach: + # 1. every whuppi/ci ref across .github — workflows AND composite + # action.yml — swept to the latest release, so the pin never splits + # (a Dependabot group bump moves only the workflow refs, and + # pin-availability then rejects the split); + # 2. third-party actions pinned inside composite action.yml (e.g. + # actions/cache frozen at an old SHA) → latest SHA, via pinact. + # Dependabot keeps pub + third-party actions in workflow FILES; this owns only + # the composite blind spot + the uniform whuppi/ci bump. The push needs + # CI_ACTIONS_TOKEN (Workflows-scope PAT) — GITHUB_TOKEN can't write + # .github/workflows/. github.token (contents:read) still authenticates + # pinact's SHA-resolving API reads. + composite-refs: + name: Composite action refs + if: ${{ inputs.sweepActions }} + runs-on: ${{ inputs.runner }} + timeout-minutes: 15 + permissions: + contents: read # pinact's GitHub API reads; the PAT does every write + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ inputs.branch }} + persist-credentials: false + # whuppi/ci itself — for tool/versions.env (PINACT_VERSION). Stamped to + # the release tag by self-release.yml, same as the pr-checks gate does. + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: whuppi/ci + ref: main + path: .whuppi-ci + persist-credentials: false + + - name: Prepare branch + id: branch + env: + # The PAT, not GITHUB_TOKEN: `gh auth setup-git` wires it into git's + # credential helper so the push can write .github/workflows/. + GH_TOKEN: ${{ secrets.CI_ACTIONS_TOKEN }} + run: | + if [ -z "${GH_TOKEN:-}" ]; then + echo "::error::sweepActions is on but CI_ACTIONS_TOKEN was not passed. Add it under the caller's secrets:." >&2 + exit 1 + fi + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + gh auth setup-git + gh label create upgrade-ci-refs --description "Auto: whuppi/ci + composite action ref bump" --color "1d76db" 2>/dev/null || true + EXISTING_PR=$(gh pr list --label upgrade-ci-refs --state open --json number --jq '.[0].number // empty') + echo "existing_pr=$EXISTING_PR" >> "$GITHUB_OUTPUT" + if [ -n "$EXISTING_PR" ]; then + git fetch origin chore/composite-refs 2>/dev/null || true + git checkout -B chore/composite-refs origin/chore/composite-refs 2>/dev/null || git checkout -b chore/composite-refs + else + git checkout -b chore/composite-refs + fi + + - name: Sweep every whuppi/ci ref to the latest release + id: sweep + env: + GH_TOKEN: ${{ secrets.CI_ACTIONS_TOKEN }} + run: | + set -euo pipefail + latest="$(gh api repos/whuppi/ci/releases/latest --jq '.tag_name' 2>/dev/null || true)" + # latest is interpolated into the sed below — reject anything but an + # exact vX.Y.Z tag, or a crafted release name becomes sed injection. + if ! printf '%s' "$latest" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::implausible whuppi/ci release tag: '$latest'" >&2 + exit 1 + fi + # Every .github file pinning a whuppi/ci ref to a version tag — + # workflows AND composite action.yml. `@main` refs (intentionally + # floating) carry no @v and are left untouched. .whuppi-ci is our own + # transient checkout of whuppi/ci — never sweep or stage it. + files=() + while IFS= read -r f; do files+=("$f"); done \ + < <(grep -rlE 'whuppi/ci/[^@[:space:]]*@v[0-9.]+' .github || true) + if [ ${#files[@]} -eq 0 ]; then + echo "No versioned whuppi/ci refs found — nothing to sweep." + else + for f in "${files[@]}"; do + sed -i.bak -E "s#(whuppi/ci/[^@[:space:]]*)@v[0-9.]+#\1@${latest}#g" "$f" + rm -f "$f.bak" + done + echo "swept ${#files[@]} whuppi/ci ref file(s) → $latest" + fi + # Hand the exact rewritten paths to the commit step so it stages only + # those (never a blanket add). + { + echo "files<> "$GITHUB_OUTPUT" + + - name: Pin third-party actions inside composites (pinact) + id: pinact + env: + # Read-only API for resolving tag → SHA. The write is the push below. + GITHUB_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + # shellcheck disable=SC1091 + source .whuppi-ci/tool/versions.env + go install "github.com/suzuki-shunsuke/pinact/v4/cmd/pinact@v$PINACT_VERSION" + PATH="$(go env GOPATH)/bin:$PATH" + # Composite action.yml ONLY — Dependabot owns third-party actions in + # workflow files, so pinact stays out of .github/workflows here to + # avoid double-bumping. whuppi/ci refs are excluded (the sweep above + # keeps them as version tags, not SHAs). + comp=() + while IFS= read -r f; do comp+=("$f"); done \ + < <(find .github/actions \( -name action.yml -o -name action.yaml \) 2>/dev/null || true) + if [ ${#comp[@]} -eq 0 ]; then + echo "No composite action.yml — nothing to pin." + else + pinact run --update --exclude '^whuppi/ci/' "${comp[@]}" + fi + # Stage any composite that pinact rewrote. + changed=() + while IFS= read -r f; do changed+=("$f"); done \ + < <(git diff --name-only -- '.github/actions/**/action.yml' '.github/actions/**/action.yaml' 2>/dev/null || true) + { + echo "files<> "$GITHUB_OUTPUT" + + - name: Commit + open or update the PR + env: + GH_TOKEN: ${{ secrets.CI_ACTIONS_TOKEN }} + EXISTING_PR: ${{ steps.branch.outputs.existing_pr }} + BASE: ${{ inputs.branch }} + SWEPT_FILES: ${{ steps.sweep.outputs.files }} + PINNED_FILES: ${{ steps.pinact.outputs.files }} + run: | + # Stage only the files the two steps rewrote — never a blanket add, + # and never .whuppi-ci (our transient whuppi/ci checkout). + printf '%s\n%s\n' "$SWEPT_FILES" "$PINNED_FILES" | while IFS= read -r f; do + [ -n "$f" ] && git add -- "$f" + done + git commit -m "ci: bump whuppi/ci + composite action refs" || { + echo "Refs already current — nothing to bump." + exit 0 + } + if [ -n "$EXISTING_PR" ]; then + git push origin chore/composite-refs + else + git push --force origin chore/composite-refs + fi + + PR_BODY="$(cat <<'EOF' + Bumps the refs Dependabot can't reach inside composite `action.yml` + (dependabot/dependabot-core#6704): + + - **whuppi/ci refs** across `.github` — workflows AND vendored composite + actions — swept uniform to the latest whuppi/ci release, so the pin + never splits (a Dependabot group bump moves only the workflow refs, + and pin-availability rejects the split). + - **Third-party actions inside composites** (e.g. `actions/cache`) + pinned to the latest SHA via pinact. + + Dependabot still owns pub deps and third-party actions in workflow files. + + Add `ready-to-test` for the full cross-target run, then merge once green. + + _Auto-generated by upgrade-check.yml_ + EOF + )" + + if [ -z "$EXISTING_PR" ]; then + gh pr create --base "$BASE" --head chore/composite-refs \ + --title "ci: bump whuppi/ci + composite action refs" --label upgrade-ci-refs --draft --body "$PR_BODY" + else + gh pr edit "$EXISTING_PR" --title "ci: bump whuppi/ci + composite action refs" --body "$PR_BODY" + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 5188b82..6c3a8a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ Releases are cut from the top heading here by `self-release.yml`; consumers pin an exact version and upgrade through grouped Dependabot PRs. Versioning rules live in the README. Newest first. +## 2.3.0 + +- Reverted Renovate (added in 2.2.0). Deleted the reusable `renovate.yml`. The + self-hosted Renovate machine — a dashboard issue, a per-consumer `renovate.json5`, + a status-check/token-scope surface — was far more than the one gap that + actually bit us: composite `action.yml` refs Dependabot can't see + ([dependabot-core#6704](https://github.com/dependabot/dependabot-core/issues/6704)). +- Closed that gap in the existing radar instead. New opt-in `composite-refs` job + in the reusable `upgrade-check.yml`: sweeps every whuppi/ci ref across `.github` + (workflows AND composites) to the latest release — uniform, so the pin never + splits — and pins third-party actions inside composites to the latest SHA via + `pinact`. Dependabot keeps pub deps + third-party actions in workflow files; + the two never overlap. A consumer opts in with `sweepActions: true` + + `CI_ACTIONS_TOKEN` and adds `whuppi/ci*` to its Dependabot `ignore`. +- Added `pinact` to the pinned tool supply chain (`PINACT_VERSION`), owned by + `self-upgrade.yml` like actionlint/zizmor. +- Renamed the org secret `RENOVATE_TOKEN` → `CI_ACTIONS_TOKEN` (same + Workflows-scope PAT; `GITHUB_TOKEN` still can't write `.github/workflows/`). + `secrets.sh`'s `org` scope stays — it's generic. + ## 2.2.0 - Added a reusable `renovate.yml` — self-hosted Renovate that each consumer calls diff --git a/deploy/.deploy/secrets.json.example b/deploy/.deploy/secrets.json.example index e06b212..0e97b70 100644 --- a/deploy/.deploy/secrets.json.example +++ b/deploy/.deploy/secrets.json.example @@ -1,6 +1,6 @@ { "github": { "release_token": "FINE_GRAINED_PAT_contents+workflows_RW_on_whuppi/ci", - "renovate_token": "FINE_GRAINED_PAT_contents+workflows+PRs+issues_RW_on_ALL_whuppi_repos" + "ci_actions_token": "FINE_GRAINED_PAT_contents+workflows+PRs+issues_RW_on_ALL_whuppi_repos (composite-refs sweep; org secret CI_ACTIONS_TOKEN)" } } diff --git a/deploy/.deploy/secrets.sh b/deploy/.deploy/secrets.sh index e0bb651..a05c6f1 100755 --- a/deploy/.deploy/secrets.sh +++ b/deploy/.deploy/secrets.sh @@ -112,7 +112,8 @@ cmd_set() { ghn="$key" if [ "$env" = "org" ]; then # Org-wide secret, visible to every whuppi repo — used by reusable - # workflows running in a consumer's context (e.g. Renovate). + # workflows running in a consumer's context (e.g. the composite-refs + # sweep's CI_ACTIONS_TOKEN). gh secret set "$ghn" --org "$ORG" --visibility all --body "$value" echo "✓ GitHub: org $ORG → $ghn (all repos)" else diff --git a/docs/UPDATING.md b/docs/UPDATING.md index 4098773..010518e 100644 --- a/docs/UPDATING.md +++ b/docs/UPDATING.md @@ -11,21 +11,25 @@ consumer onboarding. | Thing | Bumped by | You do | |---|---|---| | Tool/binary pins (`tool/versions.env`) | `self-upgrade.yml` daily PR | Review the hashes, merge, cut a release | -| Consumer deps + every action `uses:` ref — pub, **and workflows AND composite `action.yml`** (third-party + whuppi/ci) | **Renovate** (`renovate.yml` reusable, one thin wrapper per consumer) | Review + merge the bump PRs it opens | -| Consumers' Flutter SDK + lockfiles, pre-Renovate | reusable `upgrade-check.yml` — for consumers not yet migrated to Renovate | Nothing here | +| Consumer pub deps + third-party actions in **workflow files** | consumer's **Dependabot** (ignoring `whuppi/ci*`) | Review + merge its grouped PRs | +| Every **whuppi/ci ref** (workflow + composite, uniform) + third-party actions **inside composites** | the `composite-refs` job in the reusable `upgrade-check.yml` (opt-in) | Review + merge the one bump PR it opens | +| Consumers' Flutter SDK + lockfiles | reusable `upgrade-check.yml` | Nothing here | -**Why Renovate, not Dependabot.** Dependabot's github-actions updater never reads -`uses:` refs inside composite `action.yml` +**The composite blind spot (`composite-refs`).** Dependabot's github-actions +updater never reads `uses:` refs inside composite `action.yml` ([dependabot/dependabot-core#6704](https://github.com/dependabot/dependabot-core/issues/6704), open) — a consumer's vendored `make-target/action.yml`, and any third-party action -pinned inside a composite, is invisible to it. A grouped bump moves only the -workflow-level refs and splits the pin, which the `pin-availability` gate then -rejects. Renovate reads composite `action.yml`, so it keeps every whuppi/ci ref -uniform (workflow and composite alike) and bumps the third-party actions hidden in -composites. Each consumer runs `renovate.yml` (a thin wrapper calling the reusable, -same shape as this one) and drops its Dependabot `github-actions` + `pub` config -once migrated. The token is a whuppi org secret (`RENOVATE_TOKEN`); Renovate must -write `.github/workflows/`, which `GITHUB_TOKEN` can't. +pinned inside a composite, is invisible to it. A grouped whuppi/ci bump moves only +the workflow-level refs and splits the pin, which the `pin-availability` gate then +rejects. The opt-in `composite-refs` job owns exactly that gap: it sweeps every +whuppi/ci ref across `.github` (workflows AND composites) to the latest release — +uniform by construction — and pins third-party actions inside composites to the +latest SHA with `pinact`. Dependabot keeps pub deps + third-party actions in +workflow FILES; the two never overlap. A consumer opts in by calling the reusable +with `sweepActions: true`, passing `CI_ACTIONS_TOKEN`, and adding `whuppi/ci*` to +its Dependabot `ignore`. The token is a whuppi org secret (`CI_ACTIONS_TOKEN`, a +Workflows-scope PAT) — the sweep writes `.github/workflows/`, which `GITHUB_TOKEN` +can't. A pin bump merged to `main` reaches nobody until a release is cut — merging and releasing are separate, deliberate steps. diff --git a/tool/ci/upgrade.sh b/tool/ci/upgrade.sh index 84ebb8d..2ebf06f 100755 --- a/tool/ci/upgrade.sh +++ b/tool/ci/upgrade.sh @@ -12,6 +12,7 @@ set -euo pipefail # bore BORE_VERSION + 3 sha256 ekzhang/bore # zizmor gate ZIZMOR_VERSION PyPI # actionlint ACTIONLINT_VERSION rhysd/actionlint +# pinact PINACT_VERSION suzuki-shunsuke/pinact # # Owned elsewhere by design (NOT here): # GitHub-action SHAs Dependabot (.github/dependabot.yml, this repo) @@ -206,6 +207,14 @@ if [ -n "$al_latest" ] && [ "$al_latest" != "$ACTIONLINT_VERSION" ]; then [ "$MODE" = apply ] && set_kv ACTIONLINT_VERSION "$al_latest" "$VERSIONS" fi +# ── pinact (composite-refs sweep in upgrade-check.yml; version in versions.env) ─ +pinact_latest="$(gh_latest_tag suzuki-shunsuke/pinact | sed 's/^v//')" +if [ -n "$pinact_latest" ] && [ "$pinact_latest" != "$PINACT_VERSION" ]; then + drift=1 + echo "pinact: $PINACT_VERSION -> $pinact_latest" + [ "$MODE" = apply ] && set_kv PINACT_VERSION "$pinact_latest" "$VERSIONS" +fi + # ── bore (version + 3 verified sha256, all in versions.env) ────────── bore_latest="$(gh_latest_tag ekzhang/bore)" if [ -n "$bore_latest" ] && [ "$bore_latest" != "$BORE_VERSION" ]; then diff --git a/tool/versions.env b/tool/versions.env index 4d97724..d043dd8 100644 --- a/tool/versions.env +++ b/tool/versions.env @@ -11,8 +11,8 @@ # Who bumps what: # tool/ci/upgrade.sh (via self-upgrade.yml, daily PR in THIS repo) — # everything in this file: FVM_*, CHROME_*, ACTIONLINT_VERSION, -# ZIZMOR_VERSION, BORE_*. One bump reaches every consumer through the -# next whuppi/ci release. +# ZIZMOR_VERSION, PINACT_VERSION, BORE_*. One bump reaches every consumer +# through the next whuppi/ci release. # Dependabot (in THIS repo) — GitHub-action SHAs pinned in the workflows. # Consumer repos — their own Flutter SDK (.fvmrc) + lockfiles, # via the reusable upgrade-check.yml. @@ -35,6 +35,12 @@ ACTIONLINT_VERSION="1.7.12" # resolves the version from PyPI, so no separate sha is tracked. ZIZMOR_VERSION="1.28.0" +# pinact — pins third-party actions inside composite action.yml to a SHA in the +# composite-refs sweep (upgrade-check.yml), closing the dependabot-core#6704 +# blind spot. Go-installed by this tag; Go verifies the module checksum, so no +# separate sha is tracked. +PINACT_VERSION="4.1.0" + # ── Pinned binary downloads (verified via tool/fetch_verified.sh) ── # sha256 values are the upstream-published digest where one exists, else # self-computed from the release asset. Bump alongside the version.