.update-deps #1
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: .update-deps | |
| on: | |
| workflow_dispatch: | |
| schedule: | |
| - cron: "0 9 * * *" | |
| permissions: | |
| contents: read | |
| jobs: | |
| update: | |
| runs-on: ubuntu-24.04 | |
| environment: update-deps # secrets are gated by this environment | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| dep: | |
| - buildx | |
| - buildkit | |
| - sbom | |
| - binfmt | |
| - cosign | |
| - toolkit | |
| steps: | |
| - | |
| name: GitHub auth token from GitHub App | |
| id: write-app | |
| uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 | |
| with: | |
| client-id: ${{ vars.DOCKER_GITHUB_BUILDER_WRITE_CLIENT_ID }} | |
| private-key: ${{ secrets.DOCKER_GITHUB_BUILDER_WRITE_PRIVATE_KEY }} | |
| owner: docker | |
| repositories: github-builder | |
| - | |
| name: Update dependency | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| env: | |
| INPUT_DEP: ${{ matrix.dep }} | |
| with: | |
| github-token: ${{ steps.write-app.outputs.token }} | |
| script: | | |
| const dep = core.getInput('dep'); | |
| const dependencyConfigs = { | |
| buildx: { | |
| key: 'BUILDX_VERSION', | |
| name: 'Buildx version', | |
| branch: 'deps/buildx-version', | |
| files: [ | |
| '.github/workflows/build.yml', | |
| '.github/workflows/bake.yml' | |
| ], | |
| sourceUrl: 'https://github.com/docker/actions-toolkit/blob/main/.github/buildx-releases.json', | |
| async resolve({github}) { | |
| const response = await github.rest.repos.getContent({ | |
| owner: 'docker', | |
| repo: 'actions-toolkit', | |
| path: '.github/buildx-releases.json', | |
| ref: 'main' | |
| }); | |
| const content = decodeContent(response.data); | |
| const payload = JSON.parse(content); | |
| const tag = payload?.latest?.tag_name; | |
| if (!tag) { | |
| throw new Error('Unable to resolve latest buildx tag from docker/actions-toolkit/.github/buildx-releases.json'); | |
| } | |
| return { | |
| value: tag, | |
| from: tag, | |
| to: tag | |
| }; | |
| } | |
| }, | |
| buildkit: { | |
| key: 'BUILDKIT_IMAGE', | |
| name: 'BuildKit image', | |
| branch: 'deps/buildkit-image', | |
| files: [ | |
| '.github/workflows/build.yml', | |
| '.github/workflows/bake.yml' | |
| ], | |
| sourceUrl: 'https://github.com/moby/buildkit/releases/latest', | |
| async resolve({github}) { | |
| const release = await github.rest.repos.getLatestRelease({ | |
| owner: 'moby', | |
| repo: 'buildkit' | |
| }); | |
| return { | |
| value: `moby/buildkit:${release.data.tag_name}`, | |
| from: release.data.tag_name, | |
| to: release.data.tag_name | |
| }; | |
| } | |
| }, | |
| sbom: { | |
| key: 'SBOM_IMAGE', | |
| name: 'SBOM image', | |
| branch: 'deps/sbom-image', | |
| files: [ | |
| '.github/workflows/build.yml', | |
| '.github/workflows/bake.yml' | |
| ], | |
| sourceUrl: 'https://github.com/docker/buildkit-syft-scanner/releases/latest', | |
| async resolve({github}) { | |
| const release = await github.rest.repos.getLatestRelease({ | |
| owner: 'docker', | |
| repo: 'buildkit-syft-scanner' | |
| }); | |
| const tag = release.data.tag_name; | |
| return { | |
| value: `docker/buildkit-syft-scanner:${stripLeadingV(tag)}`, | |
| from: tag, | |
| to: stripLeadingV(tag) | |
| }; | |
| } | |
| }, | |
| binfmt: { | |
| key: 'BINFMT_IMAGE', | |
| name: 'Binfmt image', | |
| branch: 'deps/binfmt-image', | |
| files: [ | |
| '.github/workflows/build.yml', | |
| '.github/workflows/bake.yml' | |
| ], | |
| sourceUrl: 'https://github.com/tonistiigi/binfmt/releases/latest', | |
| async resolve({github}) { | |
| const release = await github.rest.repos.getLatestRelease({ | |
| owner: 'tonistiigi', | |
| repo: 'binfmt' | |
| }); | |
| const tag = release.data.tag_name; | |
| if (!tag.startsWith('deploy/')) { | |
| throw new Error(`Expected deploy/ release tag for tonistiigi/binfmt, got ${tag}`); | |
| } | |
| const imageTag = `qemu-${tag.slice('deploy/'.length)}`; | |
| return { | |
| value: `tonistiigi/binfmt:${imageTag}`, | |
| from: tag, | |
| to: imageTag | |
| }; | |
| } | |
| }, | |
| toolkit: { | |
| key: 'DOCKER_ACTIONS_TOOLKIT_MODULE', | |
| name: 'docker/actions-toolkit module', | |
| branch: 'deps/docker-actions-toolkit-module', | |
| files: [ | |
| '.github/workflows/build.yml', | |
| '.github/workflows/bake.yml', | |
| '.github/workflows/verify.yml' | |
| ], | |
| sourceUrl: 'https://github.com/docker/actions-toolkit/releases/latest', | |
| async resolve({github}) { | |
| const release = await github.rest.repos.getLatestRelease({ | |
| owner: 'docker', | |
| repo: 'actions-toolkit' | |
| }); | |
| const tag = release.data.tag_name; | |
| const version = stripLeadingV(tag); | |
| return { | |
| value: `@docker/actions-toolkit@${version}`, | |
| from: tag, | |
| to: version | |
| }; | |
| } | |
| }, | |
| cosign: { | |
| key: 'COSIGN_VERSION', | |
| name: 'Cosign version', | |
| branch: 'deps/cosign-version', | |
| files: [ | |
| '.github/workflows/build.yml', | |
| '.github/workflows/bake.yml' | |
| ], | |
| sourceUrl: 'https://github.com/docker/actions-toolkit/blob/main/.github/cosign-releases.json', | |
| async resolve({github}) { | |
| const response = await github.rest.repos.getContent({ | |
| owner: 'docker', | |
| repo: 'actions-toolkit', | |
| path: '.github/cosign-releases.json', | |
| ref: 'main' | |
| }); | |
| const content = decodeContent(response.data); | |
| const payload = JSON.parse(content); | |
| const tag = payload?.latest?.tag_name; | |
| if (!tag) { | |
| throw new Error('Unable to resolve latest cosign tag from docker/actions-toolkit/.github/cosign-releases.json'); | |
| } | |
| return { | |
| value: tag, | |
| from: tag, | |
| to: tag | |
| }; | |
| } | |
| } | |
| }; | |
| function stripLeadingV(value) { | |
| return value.startsWith('v') ? value.slice(1) : value; | |
| } | |
| function escapeRegExp(value) { | |
| return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | |
| } | |
| function decodeContent(data) { | |
| if (Array.isArray(data) || data.type !== 'file' || !data.content) { | |
| throw new Error('Expected a file content response from the GitHub API'); | |
| } | |
| return Buffer.from(data.content, data.encoding).toString('utf8'); | |
| } | |
| async function getTextFile(github, owner, repo, path, ref) { | |
| const response = await github.rest.repos.getContent({ | |
| owner, | |
| repo, | |
| path, | |
| ref | |
| }); | |
| return { | |
| path, | |
| sha: response.data.sha, | |
| content: decodeContent(response.data) | |
| }; | |
| } | |
| function readEnvValue(content, key) { | |
| const pattern = new RegExp(`^ ${escapeRegExp(key)}: "([^"]*)"$`, 'm'); | |
| const match = content.match(pattern); | |
| if (!match) { | |
| throw new Error(`Missing ${key}`); | |
| } | |
| return match[1]; | |
| } | |
| function replaceEnvValue(content, key, value) { | |
| const pattern = new RegExp(`^( ${escapeRegExp(key)}: ")([^"]*)(")$`, 'm'); | |
| const match = content.match(pattern); | |
| if (!match) { | |
| throw new Error(`Missing ${key}`); | |
| } | |
| return { | |
| changed: match[2] !== value, | |
| before: match[2], | |
| content: content.replace(pattern, `$1${value}$3`) | |
| }; | |
| } | |
| function unique(values) { | |
| return [...new Set(values)]; | |
| } | |
| function formatList(values) { | |
| if (values.length === 1) { | |
| return `\`${values[0]}\``; | |
| } | |
| if (values.length === 2) { | |
| return `\`${values[0]}\` and \`${values[1]}\``; | |
| } | |
| const quoted = values.map((value) => `\`${value}\``); | |
| return `${quoted.slice(0, -1).join(', ')}, and ${quoted.at(-1)}`; | |
| } | |
| async function findOpenPullRequest(github, context, branch, base) { | |
| const pulls = await github.rest.pulls.list({ | |
| ...context.repo, | |
| state: 'open', | |
| head: `${context.repo.owner}:${branch}`, | |
| base, | |
| per_page: 100 | |
| }); | |
| return pulls.data[0] ?? null; | |
| } | |
| const config = dependencyConfigs[dep]; | |
| if (!config) { | |
| core.setFailed(`Unknown dependency ${dep}`); | |
| return; | |
| } | |
| const repo = await github.rest.repos.get(context.repo); | |
| const defaultBranch = repo.data.default_branch; | |
| const branchRefName = `heads/${config.branch}`; | |
| const openPullRequest = await findOpenPullRequest(github, context, config.branch, defaultBranch); | |
| const target = await config.resolve({github}); | |
| core.info(`Resolved ${config.key} to ${target.value} from ${config.sourceUrl}`); | |
| const baseFiles = await Promise.all(config.files.map((path) => getTextFile(github, context.repo.owner, context.repo.repo, path, defaultBranch))); | |
| const baseValues = unique(baseFiles.map((file) => readEnvValue(file.content, config.key))); | |
| const baseIsUpToDate = baseValues.every((value) => value === target.value); | |
| if (baseIsUpToDate) { | |
| core.info(`${config.key} is already up to date on ${defaultBranch}`); | |
| if (openPullRequest) { | |
| await github.rest.pulls.update({ | |
| ...context.repo, | |
| pull_number: openPullRequest.number, | |
| state: 'closed' | |
| }); | |
| core.notice(`Closed stale pull request #${openPullRequest.number}`); | |
| } | |
| return; | |
| } | |
| let branchExists = false; | |
| try { | |
| await github.rest.git.getRef({ | |
| ...context.repo, | |
| ref: branchRefName | |
| }); | |
| branchExists = true; | |
| } catch (error) { | |
| if (error.status !== 404) { | |
| throw error; | |
| } | |
| } | |
| const defaultRef = await github.rest.git.getRef({ | |
| ...context.repo, | |
| ref: `heads/${defaultBranch}` | |
| }); | |
| const parentCommitSha = defaultRef.data.object.sha; | |
| // Always rebuild updater branches from the latest default branch head | |
| // so stale dependency PRs do not accumulate merge conflicts. | |
| const workingRef = defaultBranch; | |
| const workingFiles = await Promise.all( | |
| config.files.map((path) => getTextFile(github, context.repo.owner, context.repo.repo, path, workingRef)) | |
| ); | |
| const changes = []; | |
| for (const file of workingFiles) { | |
| const replacement = replaceEnvValue(file.content, config.key, target.value); | |
| if (!replacement.changed) { | |
| continue; | |
| } | |
| changes.push({ | |
| path: file.path, | |
| before: replacement.before, | |
| after: target.value, | |
| content: replacement.content | |
| }); | |
| } | |
| if (changes.length > 0) { | |
| const parentCommit = await github.rest.git.getCommit({ | |
| ...context.repo, | |
| commit_sha: parentCommitSha | |
| }); | |
| const tree = []; | |
| for (const change of changes) { | |
| const blob = await github.rest.git.createBlob({ | |
| ...context.repo, | |
| content: change.content, | |
| encoding: 'utf-8' | |
| }); | |
| tree.push({ | |
| path: change.path, | |
| mode: '100644', | |
| type: 'blob', | |
| sha: blob.data.sha | |
| }); | |
| } | |
| const newTree = await github.rest.git.createTree({ | |
| ...context.repo, | |
| base_tree: parentCommit.data.tree.sha, | |
| tree | |
| }); | |
| const commit = await github.rest.git.createCommit({ | |
| ...context.repo, | |
| message: `chore(deps): bump ${config.key} to ${target.to}`, | |
| tree: newTree.data.sha, | |
| parents: [parentCommitSha] | |
| }); | |
| if (branchExists) { | |
| await github.rest.git.updateRef({ | |
| ...context.repo, | |
| ref: branchRefName, | |
| sha: commit.data.sha, | |
| force: true | |
| }); | |
| } else { | |
| await github.rest.git.createRef({ | |
| ...context.repo, | |
| ref: `refs/${branchRefName}`, | |
| sha: commit.data.sha | |
| }); | |
| branchExists = true; | |
| } | |
| } else { | |
| core.info(`No file changes needed on branch ${config.branch}`); | |
| } | |
| const comparison = await github.rest.repos.compareCommits({ | |
| ...context.repo, | |
| base: defaultBranch, | |
| head: config.branch | |
| }); | |
| if (comparison.data.ahead_by === 0) { | |
| core.info(`Branch ${config.branch} does not differ from ${defaultBranch}`); | |
| if (openPullRequest) { | |
| await github.rest.pulls.update({ | |
| ...context.repo, | |
| pull_number: openPullRequest.number, | |
| state: 'closed' | |
| }); | |
| core.notice(`Closed stale pull request #${openPullRequest.number}`); | |
| } | |
| return; | |
| } | |
| const title = `chore(deps): bump ${config.name} to ${target.to}`; | |
| const beforeValue = formatList(baseValues); | |
| const body = [ | |
| `This updates ${config.key} from ${beforeValue} to \`${target.value}\`.`, | |
| '', | |
| `The source of truth for this update is ${config.sourceUrl}.` | |
| ].join('\n'); | |
| if (openPullRequest) { | |
| await github.rest.pulls.update({ | |
| ...context.repo, | |
| pull_number: openPullRequest.number, | |
| title, | |
| body | |
| }); | |
| core.notice(`Updated pull request #${openPullRequest.number}`); | |
| return; | |
| } | |
| const pullRequest = await github.rest.pulls.create({ | |
| ...context.repo, | |
| title, | |
| body, | |
| head: config.branch, | |
| base: defaultBranch | |
| }); | |
| core.notice(`Created pull request #${pullRequest.data.number}`); |