Skip to content

.update-deps

.update-deps #1

Workflow file for this run

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}`);