diff --git a/.github/workflows/.update-deps.yml b/.github/workflows/.update-deps.yml new file mode 100644 index 0000000..04babac --- /dev/null +++ b/.github/workflows/.update-deps.yml @@ -0,0 +1,450 @@ +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}`); diff --git a/.github/workflows/bake.yml b/.github/workflows/bake.yml index 73139a0..733676e 100644 --- a/.github/workflows/bake.yml +++ b/.github/workflows/bake.yml @@ -156,7 +156,7 @@ on: env: BUILDX_VERSION: "v0.33.0" BUILDKIT_IMAGE: "moby/buildkit:v0.29.0" - SBOM_IMAGE: "docker/buildkit-syft-scanner:1.10.0" + SBOM_IMAGE: "docker/buildkit-syft-scanner:1.11.0" BINFMT_IMAGE: "tonistiigi/binfmt:qemu-v10.2.1-65" DOCKER_ACTIONS_TOOLKIT_MODULE: "@docker/actions-toolkit@0.88.0" HANDLEBARS_MODULE: "handlebars@4.7.9" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7434f9f..8179f9f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -159,7 +159,7 @@ on: env: BUILDX_VERSION: "v0.33.0" BUILDKIT_IMAGE: "moby/buildkit:v0.29.0" - SBOM_IMAGE: "docker/buildkit-syft-scanner:1.10.0" + SBOM_IMAGE: "docker/buildkit-syft-scanner:1.11.0" BINFMT_IMAGE: "tonistiigi/binfmt:qemu-v10.2.1-65" DOCKER_ACTIONS_TOOLKIT_MODULE: "@docker/actions-toolkit@0.88.0" HANDLEBARS_MODULE: "handlebars@4.7.9" diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index a595a01..22bb748 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -13,7 +13,7 @@ on: required: false env: - DOCKER_ACTIONS_TOOLKIT_MODULE: "@docker/actions-toolkit@0.76.0" + DOCKER_ACTIONS_TOOLKIT_MODULE: "@docker/actions-toolkit@0.88.0" jobs: verify: