diff --git a/.github/actions/update-flake-input/action.yaml b/.github/actions/update-flake-input/action.yaml new file mode 100644 index 0000000..552be60 --- /dev/null +++ b/.github/actions/update-flake-input/action.yaml @@ -0,0 +1,260 @@ +name: Update Flake Input +description: Update one GitHub-backed Nix flake input and create a pull request +inputs: + input-name: + description: Name of the flake input to update + required: true + github-token: + description: Token used to query GitHub, push the update branch, and manage the pull request + required: true + base-branch: + description: Branch targeted by the pull request + required: false + default: main + minimum-release-age-days: + description: Minimum age of the target commit in days + required: false + default: "3" + skip-delay: + description: Update to the latest revision without applying the minimum age + required: false + default: "false" + auto-merge: + description: Enable squash auto-merge on the pull request + required: false + default: "true" + pr-labels: + description: Comma-separated labels to add when they already exist in the repository + required: false + default: dependencies,automated +outputs: + updated: + description: Whether flake.lock changed + value: ${{ steps.update.outputs.updated }} + current-version: + description: Previous locked revision + value: ${{ steps.update.outputs.current_version }} + new-version: + description: New locked revision + value: ${{ steps.update.outputs.new_version }} + pr-url: + description: URL of the created or updated pull request + value: ${{ steps.pull-request.outputs.pr_url }} +runs: + using: composite + steps: + - name: Update flake input + id: update + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + INPUT_NAME: ${{ inputs.input-name }} + MINIMUM_RELEASE_AGE_DAYS: ${{ inputs.minimum-release-age-days }} + SKIP_DELAY: ${{ inputs.skip-delay }} + run: | + set -euo pipefail + + if [[ ! "$MINIMUM_RELEASE_AGE_DAYS" =~ ^[0-9]+$ ]]; then + echo "::error::minimum-release-age-days must be a non-negative integer" + exit 1 + fi + + node_key="$( + jq -er --arg input "$INPUT_NAME" ' + .nodes.root.inputs[$input] + | if type == "array" then .[0] else . end + ' flake.lock + )" + input_type="$(jq -r --arg node "$node_key" '.nodes[$node].locked.type // ""' flake.lock)" + input_owner="$(jq -r --arg node "$node_key" '.nodes[$node].locked.owner // ""' flake.lock)" + input_repo="$(jq -r --arg node "$node_key" '.nodes[$node].locked.repo // ""' flake.lock)" + input_ref="$(jq -r --arg node "$node_key" '.nodes[$node].original.ref // ""' flake.lock)" + current_rev="$(jq -er --arg node "$node_key" '.nodes[$node].locked.rev' flake.lock)" + + if [ "$input_type" != "github" ] || [ -z "$input_owner" ] || [ -z "$input_repo" ]; then + echo "::error::${INPUT_NAME} is not a GitHub-backed flake input" + exit 1 + fi + + echo "Input: $INPUT_NAME" + echo "Repository: ${input_owner}/${input_repo}" + echo "Current revision: $current_rev" + + if [ "$SKIP_DELAY" = "true" ]; then + nix flake update "$INPUT_NAME" + else + cutoff="$(date --utc --date="${MINIMUM_RELEASE_AGE_DAYS} days ago" +%Y-%m-%dT%H:%M:%SZ)" + api_args=( + --method GET + "repos/${input_owner}/${input_repo}/commits" + -f "until=$cutoff" + -f per_page=1 + ) + if [ -n "$input_ref" ]; then + api_args+=(-f "sha=$input_ref") + fi + + echo "Selecting the newest commit no later than $cutoff" + target_data="$(gh api "${api_args[@]}" --jq '.[0] | {sha: .sha, date: .commit.committer.date}')" + target_rev="$(jq -er '.sha' <<< "$target_data")" + target_date="$(jq -er '.date' <<< "$target_data")" + + if [ "$target_rev" = "$current_rev" ]; then + echo "The input is already at the newest eligible revision" + { + echo "updated=false" + echo "current_version=$current_rev" + echo "new_version=$current_rev" + } >> "$GITHUB_OUTPUT" + exit 0 + fi + + current_date="$( + gh api "repos/${input_owner}/${input_repo}/commits/${current_rev}" \ + --jq '.commit.committer.date' + )" + current_timestamp="$(date --date="$current_date" +%s)" + target_timestamp="$(date --date="$target_date" +%s)" + + if [ "$target_timestamp" -lt "$current_timestamp" ]; then + echo "The newest eligible revision is older than the current revision; skipping" + { + echo "updated=false" + echo "current_version=$current_rev" + echo "new_version=$current_rev" + } >> "$GITHUB_OUTPUT" + exit 0 + fi + + nix flake update "$INPUT_NAME" \ + --override-input "$INPUT_NAME" "github:${input_owner}/${input_repo}/${target_rev}" + fi + + if git diff --quiet -- flake.lock; then + echo "No lock file changes were produced" + { + echo "updated=false" + echo "current_version=$current_rev" + echo "new_version=$current_rev" + } >> "$GITHUB_OUTPUT" + exit 0 + fi + + new_node_key="$( + jq -er --arg input "$INPUT_NAME" ' + .nodes.root.inputs[$input] + | if type == "array" then .[0] else . end + ' flake.lock + )" + new_rev="$(jq -er --arg node "$new_node_key" '.nodes[$node].locked.rev' flake.lock)" + + echo "New revision: $new_rev" + { + echo "updated=true" + echo "current_version=$current_rev" + echo "new_version=$new_rev" + echo "input_owner=$input_owner" + echo "input_repo=$input_repo" + } >> "$GITHUB_OUTPUT" + - name: Create or update pull request + id: pull-request + if: steps.update.outputs.updated == 'true' + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + INPUT_NAME: ${{ inputs.input-name }} + BASE_BRANCH: ${{ inputs.base-branch }} + CURRENT_REV: ${{ steps.update.outputs.current_version }} + NEW_REV: ${{ steps.update.outputs.new_version }} + INPUT_OWNER: ${{ steps.update.outputs.input_owner }} + INPUT_REPO: ${{ steps.update.outputs.input_repo }} + MINIMUM_RELEASE_AGE_DAYS: ${{ inputs.minimum-release-age-days }} + SKIP_DELAY: ${{ inputs.skip-delay }} + AUTO_MERGE: ${{ inputs.auto-merge }} + PR_LABELS: ${{ inputs.pr-labels }} + run: | + set -euo pipefail + + branch_suffix="$(tr -c 'A-Za-z0-9._-' '-' <<< "$INPUT_NAME" | sed 's/-$//')" + branch="update-flake-${branch_suffix}" + current_short="${CURRENT_REV:0:8}" + new_short="${NEW_REV:0:8}" + title="chore(nix): update ${INPUT_NAME} to ${new_short}" + + if [ "$SKIP_DELAY" = "true" ]; then + age_note="The minimum release age check was skipped for this manually requested update." + else + age_note="The target commit is at least ${MINIMUM_RELEASE_AGE_DAYS} days old." + fi + + body="$( + printf '%s\n' \ + "Automated update of the \`${INPUT_NAME}\` flake input." \ + "" \ + "- Previous revision: [\`${current_short}\`](https://github.com/${INPUT_OWNER}/${INPUT_REPO}/commit/${CURRENT_REV})" \ + "- New revision: [\`${new_short}\`](https://github.com/${INPUT_OWNER}/${INPUT_REPO}/commit/${NEW_REV})" \ + "- Changes: [compare](https://github.com/${INPUT_OWNER}/${INPUT_REPO}/compare/${CURRENT_REV}...${NEW_REV})" \ + "" \ + "$age_note" + )" + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add flake.lock + git switch -C "$branch" + git commit -m "$title" + + git fetch origin "refs/heads/${branch}:refs/remotes/origin/${branch}" || true + git push --force-with-lease origin "HEAD:refs/heads/${branch}" + + label_args=() + available_labels="$(gh label list --limit 100 --json name --jq '.[].name')" + IFS=',' read -ra requested_labels <<< "$PR_LABELS" + for label in "${requested_labels[@]}"; do + label="$(xargs <<< "$label")" + if [ -n "$label" ] && grep -Fxq "$label" <<< "$available_labels"; then + label_args+=(--add-label "$label") + elif [ -n "$label" ]; then + echo "::warning::Skipping missing pull request label: $label" + fi + done + + pr_number="$( + gh pr list \ + --state open \ + --head "$branch" \ + --json number \ + --jq '.[0].number // empty' + )" + + if [ -n "$pr_number" ]; then + gh pr edit "$pr_number" \ + --title "$title" \ + --body "$body" \ + "${label_args[@]}" + else + gh pr create \ + --base "$BASE_BRANCH" \ + --head "$branch" \ + --title "$title" \ + --body "$body" + pr_number="$( + gh pr list \ + --state open \ + --head "$branch" \ + --json number \ + --jq '.[0].number' + )" + if [ "${#label_args[@]}" -gt 0 ]; then + gh pr edit "$pr_number" "${label_args[@]}" + fi + fi + + if [ "$AUTO_MERGE" = "true" ]; then + gh pr merge "$pr_number" --auto --squash || + echo "::warning::Auto-merge could not be enabled; check the repository merge settings" + fi + + pr_url="$(gh pr view "$pr_number" --json url --jq '.url')" + echo "pr_url=$pr_url" >> "$GITHUB_OUTPUT" + echo "Pull request: $pr_url" diff --git a/.github/workflows/renovate.yml b/.github/workflows/renovate.yml index dd77e81..d141841 100644 --- a/.github/workflows/renovate.yml +++ b/.github/workflows/renovate.yml @@ -11,7 +11,7 @@ concurrency: cancel-in-progress: false jobs: renovate: - name: Update Nix flake inputs + name: Update GitHub Actions dependencies runs-on: ubuntu-latest timeout-minutes: 60 steps: diff --git a/.github/workflows/update-flake-inputs.yml b/.github/workflows/update-flake-inputs.yml new file mode 100644 index 0000000..2ab924a --- /dev/null +++ b/.github/workflows/update-flake-inputs.yml @@ -0,0 +1,106 @@ +name: Update Flake Inputs +on: + schedule: + # Every day at 03:30 JST (18:30 UTC on the previous day). + - cron: "30 18 * * *" + workflow_dispatch: + inputs: + input: + description: Update only this flake input (empty updates all inputs) + required: false + type: string + skip-delay: + description: Update to the latest revision without the three-day delay + required: false + default: false + type: boolean + auto-merge: + description: Enable auto-merge after required checks pass + required: false + default: true + type: boolean +permissions: + contents: write + pull-requests: write +concurrency: + group: update-flake-inputs + cancel-in-progress: false +jobs: + discover: + name: Discover flake inputs + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + matrix: ${{ steps.inputs.outputs.matrix }} + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Build update matrix + id: inputs + env: + REQUESTED_INPUT: ${{ inputs.input }} + run: | + set -euo pipefail + + github_inputs="$( + jq -c ' + . as $lock + | [ + $lock.nodes.root.inputs + | to_entries[] + | .key as $name + | ( + .value + | if type == "array" then .[0] else . end + ) as $node + | select($lock.nodes[$node].locked.type == "github") + | $name + ] + | sort + ' flake.lock + )" + + if [ -n "$REQUESTED_INPUT" ]; then + if ! jq -e --arg input "$REQUESTED_INPUT" 'index($input) != null' <<< "$github_inputs" >/dev/null; then + echo "::error::Unknown or unsupported flake input: $REQUESTED_INPUT" + exit 1 + fi + matrix="$(jq -cn --arg input "$REQUESTED_INPUT" '{input: [$input]}')" + else + matrix="$(jq -cn --argjson inputs "$github_inputs" '{input: $inputs}')" + fi + + echo "matrix=$matrix" >> "$GITHUB_OUTPUT" + echo "Update matrix: $matrix" + update: + name: Update ${{ matrix.input }} + needs: discover + if: ${{ needs.discover.outputs.matrix != '{"input":[]}' }} + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + max-parallel: 4 + matrix: ${{ fromJSON(needs.discover.outputs.matrix) }} + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + token: ${{ secrets.RENOVATE_TOKEN }} + - name: Install Nix + uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + with: + extra_nix_config: | + experimental-features = nix-command flakes + accept-flake-config = true + access-tokens = github.com=${{ secrets.RENOVATE_TOKEN }} + - name: Update input + uses: ./.github/actions/update-flake-input + with: + input-name: ${{ matrix.input }} + github-token: ${{ secrets.RENOVATE_TOKEN }} + skip-delay: ${{ github.event_name == 'workflow_dispatch' && inputs.skip-delay }} + auto-merge: ${{ github.event_name != 'workflow_dispatch' || inputs.auto-merge }} diff --git a/renovate.json b/renovate.json index cd3f252..613d308 100644 --- a/renovate.json +++ b/renovate.json @@ -1,31 +1,13 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": ["config:recommended", "helpers:pinGitHubActionDigests"], - "enabledManagers": ["github-actions", "nix"], + "enabledManagers": ["github-actions"], "prHourlyLimit": 0, - "nix": { - "enabled": true - }, - - "lockFileMaintenance": { - "enabled": true, - "schedule": ["at any time"] - }, - "prCreation": "immediate", "semanticCommits": "enabled", "semanticCommitType": "chore", - "semanticCommitScope": "deps", - - "packageRules": [ - { - "description": "Group Nix flake input updates", - "matchManagers": ["nix"], - "groupName": "Nix flake inputs", - "groupSlug": "nix-flake-inputs" - } - ] + "semanticCommitScope": "deps" }