-
Notifications
You must be signed in to change notification settings - Fork 128
chore(ci): automate Dependabot license updates #1893
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
pront
wants to merge
8
commits into
main
Choose a base branch
from
pront-dependabot-license-regeneration
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+343
−0
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
624a38b
ci: regenerate licenses on Dependabot PRs
pront 669ac7f
fix(ci): use configured bot private-key secret
pront 65f2595
fix(ci): use configured bot client ID
pront ffad6b8
fix(ci): validate before minting bot token
pront 182b62c
fix(ci): scope license update credentials
pront 086cb6e
revert(ci): keep shared bot credentials simple
pront 29027a2
ci: trim redundant defensive validation from dependabot license updater
thomasqueirozb 30c9bbf
ci: drop CSV content validation from dependabot license updater
thomasqueirozb File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,247 @@ | ||
| name: Apply Dependabot License Update | ||
|
|
||
| on: | ||
| workflow_run: | ||
| workflows: ["Generate Dependabot License Update"] | ||
| types: [completed] | ||
|
|
||
| # The built-in token validates the triggering run, artifact, and pull request. | ||
| # Only validated updates mint a current-repository-only vectordotdev-bot token. | ||
| # This requires the GH_APP_VECTORDOTDEV_BOT_CLIENT_ID and | ||
| # GH_APP_VECTORDOTDEV_BOT_APP_PRIVATE_KEY Actions secrets. | ||
| permissions: | ||
| actions: read | ||
| contents: read | ||
| pull-requests: read | ||
|
|
||
| concurrency: | ||
| group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.event.workflow_run.id }} | ||
| cancel-in-progress: true | ||
|
|
||
| jobs: | ||
| apply: | ||
| name: Apply license update | ||
| if: > | ||
| github.event.workflow_run.conclusion == 'success' | ||
| && github.event.workflow_run.event == 'pull_request' | ||
| && github.event.workflow_run.head_repository.full_name == github.repository | ||
| runs-on: ubuntu-24.04 | ||
| timeout-minutes: 5 | ||
| steps: | ||
| - name: Find update artifact | ||
| id: artifact | ||
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | ||
| with: | ||
| script: | | ||
| const artifacts = await github.paginate( | ||
| github.rest.actions.listWorkflowRunArtifacts, | ||
| { | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| run_id: context.payload.workflow_run.id, | ||
| per_page: 100, | ||
| }, | ||
| ); | ||
| const matches = artifacts.filter( | ||
| artifact => artifact.name === 'dependabot-license-update', | ||
| ); | ||
|
|
||
| if (matches.length === 0) { | ||
| core.notice('No license update was generated.'); | ||
| core.setOutput('present', 'false'); | ||
| return; | ||
| } | ||
| if (matches.length !== 1) { | ||
| core.setFailed(`Expected one update artifact, found ${matches.length}.`); | ||
| return; | ||
| } | ||
| if (matches[0].expired) { | ||
| core.setFailed('The license update artifact has expired.'); | ||
| return; | ||
| } | ||
|
|
||
| core.setOutput('present', 'true'); | ||
|
|
||
| - name: Download update artifact | ||
| if: steps.artifact.outputs.present == 'true' | ||
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | ||
| with: | ||
| name: dependabot-license-update | ||
| path: dependabot-license-update | ||
| run-id: ${{ github.event.workflow_run.id }} | ||
| github-token: ${{ github.token }} | ||
|
|
||
| # Do not check out or execute pull request code here. The downloaded files | ||
| # are treated only as untrusted data. | ||
| - name: Validate update | ||
| if: steps.artifact.outputs.present == 'true' | ||
| id: validation | ||
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | ||
| with: | ||
| script: | | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
|
|
||
| const fail = message => { | ||
| throw new Error(message); | ||
| }; | ||
| const artifactDir = path.resolve('dependabot-license-update'); | ||
| const expectedFiles = ['LICENSE-3rdparty.csv', 'metadata.json']; | ||
|
|
||
| const entries = fs.readdirSync(artifactDir, { withFileTypes: true }); | ||
| for (const entry of entries) { | ||
| if (!entry.isFile()) { | ||
| fail(`Artifact entry is not a regular file: ${entry.name}`); | ||
| } | ||
| } | ||
| const files = entries.map(entry => entry.name).sort(); | ||
| if (JSON.stringify(files) !== JSON.stringify(expectedFiles)) { | ||
| fail(`Unexpected artifact contents: ${files.join(', ')}`); | ||
| } | ||
|
|
||
| const metadataPath = path.join(artifactDir, 'metadata.json'); | ||
| const metadataStat = fs.statSync(metadataPath); | ||
| if (metadataStat.size === 0 || metadataStat.size > 4096) { | ||
| fail(`Invalid metadata size: ${metadataStat.size}`); | ||
| } | ||
|
|
||
| let metadata; | ||
| try { | ||
| metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); | ||
| } catch (error) { | ||
| fail(`Invalid metadata JSON: ${error.message}`); | ||
| } | ||
|
|
||
| const metadataKeys = Object.keys(metadata).sort(); | ||
| const expectedMetadataKeys = [ | ||
| 'head_ref', | ||
| 'head_sha', | ||
| 'pull_request', | ||
| 'version', | ||
| ]; | ||
| if (JSON.stringify(metadataKeys) !== JSON.stringify(expectedMetadataKeys)) { | ||
| fail(`Unexpected metadata fields: ${metadataKeys.join(', ')}`); | ||
| } | ||
|
|
||
| if (metadata.version !== 1) { | ||
| fail(`Unsupported metadata version: ${metadata.version}`); | ||
| } | ||
|
|
||
| const { data: pr } = await github.rest.pulls.get({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| pull_number: metadata.pull_request, | ||
| }); | ||
|
|
||
| if (pr.state !== 'open') { | ||
| core.notice(`Pull request #${metadata.pull_request} is no longer open.`); | ||
| return; | ||
| } | ||
| if (pr.head.ref !== metadata.head_ref) { | ||
| fail(`Pull request head ref does not match artifact: ${pr.head.ref}`); | ||
| } | ||
| if (pr.head.sha !== metadata.head_sha) { | ||
| core.notice('The pull request advanced after license generation; ignoring stale artifact.'); | ||
| return; | ||
| } | ||
|
|
||
| const csvPath = path.join(artifactDir, 'LICENSE-3rdparty.csv'); | ||
| const csv = fs.readFileSync(csvPath); | ||
|
|
||
| const { data: currentFile } = await github.rest.repos.getContent({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| path: 'LICENSE-3rdparty.csv', | ||
| ref: metadata.head_sha, | ||
| }); | ||
| if (Array.isArray(currentFile) || currentFile.type !== 'file' | ||
| || currentFile.encoding !== 'base64') { | ||
| fail('Unable to read the current license inventory as a file.'); | ||
| } | ||
| const current = Buffer.from(currentFile.content.replace(/\s/g, ''), 'base64'); | ||
| if (current.equals(csv)) { | ||
| core.notice('The pull request already contains the generated license inventory.'); | ||
| return; | ||
| } | ||
|
|
||
| core.setOutput('apply', 'true'); | ||
|
|
||
| - name: Create vectordotdev-bot token | ||
| if: steps.validation.outputs.apply == 'true' | ||
| id: app-token | ||
| uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 | ||
| with: | ||
| # With no owner/repositories input, the token is scoped to this repo. | ||
| client-id: ${{ secrets.GH_APP_VECTORDOTDEV_BOT_CLIENT_ID }} | ||
| private-key: ${{ secrets.GH_APP_VECTORDOTDEV_BOT_APP_PRIVATE_KEY }} | ||
| permission-contents: write | ||
| permission-pull-requests: read | ||
|
|
||
| # Re-check mutable PR state after minting the token, then change one fixed | ||
| # path with a non-forcing ref update. | ||
| - name: Apply validated update | ||
| if: steps.validation.outputs.apply == 'true' | ||
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | ||
| with: | ||
| github-token: ${{ steps.app-token.outputs.token }} | ||
| script: | | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
|
|
||
| const artifactDir = path.resolve('dependabot-license-update'); | ||
| const metadata = JSON.parse( | ||
| fs.readFileSync(path.join(artifactDir, 'metadata.json'), 'utf8'), | ||
| ); | ||
| const csv = fs.readFileSync(path.join(artifactDir, 'LICENSE-3rdparty.csv')); | ||
|
|
||
| // Re-check the live head immediately before constructing the commit. | ||
| const { data: freshPr } = await github.rest.pulls.get({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| pull_number: metadata.pull_request, | ||
| }); | ||
| if (freshPr.state !== 'open' || freshPr.head.sha !== metadata.head_sha) { | ||
| core.notice('The pull request changed during validation; no update was applied.'); | ||
| return; | ||
| } | ||
|
|
||
| const { data: parent } = await github.rest.git.getCommit({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| commit_sha: metadata.head_sha, | ||
| }); | ||
| const { data: blob } = await github.rest.git.createBlob({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| content: csv.toString('base64'), | ||
| encoding: 'base64', | ||
| }); | ||
| const { data: tree } = await github.rest.git.createTree({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| base_tree: parent.tree.sha, | ||
| tree: [{ | ||
| path: 'LICENSE-3rdparty.csv', | ||
| mode: '100644', | ||
| type: 'blob', | ||
| sha: blob.sha, | ||
| }], | ||
| }); | ||
| const { data: commit } = await github.rest.git.createCommit({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| message: 'chore(deps): update licenses', | ||
| tree: tree.sha, | ||
| parents: [metadata.head_sha], | ||
| }); | ||
|
|
||
| // force:false makes a concurrent Dependabot rebase or maintainer push | ||
| // fail rather than replacing the newer branch head. | ||
| await github.rest.git.updateRef({ | ||
| owner: context.repo.owner, | ||
| repo: context.repo.repo, | ||
| ref: `heads/${metadata.head_ref}`, | ||
| sha: commit.sha, | ||
| force: false, | ||
| }); | ||
| core.notice(`Updated licenses in commit ${commit.sha}.`); | ||
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| name: Generate Dependabot License Update | ||
|
|
||
| on: | ||
| pull_request: | ||
| types: [opened, reopened, synchronize] | ||
| paths: | ||
| - "Cargo.lock" | ||
| - "Cargo.toml" | ||
| - "**/Cargo.toml" | ||
|
|
||
| # This workflow executes pull-request-controlled code. Keep it read-only and do | ||
| # not add secrets. The generated artifact is untrusted input to the updater. | ||
| permissions: | ||
| contents: read | ||
|
|
||
| concurrency: | ||
| group: ${{ github.workflow }}-${{ github.event.pull_request.number }} | ||
| cancel-in-progress: true | ||
|
|
||
| jobs: | ||
| generate: | ||
| name: Generate license update | ||
| if: > | ||
| github.event.pull_request.user.login == 'dependabot[bot]' | ||
| && github.event.pull_request.head.repo.full_name == github.repository | ||
| && startsWith(github.event.pull_request.head.ref, 'dependabot/cargo/') | ||
| && github.event.pull_request.base.ref == 'main' | ||
| runs-on: ubuntu-24.04 | ||
| timeout-minutes: 10 | ||
| steps: | ||
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | ||
| with: | ||
| ref: ${{ github.event.pull_request.head.sha }} | ||
| persist-credentials: false | ||
|
|
||
| - name: Generate license inventory | ||
| run: make write-licenses | ||
|
|
||
| - name: Verify generated file scope | ||
| run: | | ||
| if ! git diff --quiet HEAD -- . ':(exclude)LICENSE-3rdparty.csv'; then | ||
| echo "License generation modified unexpected tracked files:" | ||
| git status --short | ||
| exit 1 | ||
| fi | ||
|
|
||
| if [[ -n "$(git ls-files --others --exclude-standard)" ]]; then | ||
| echo "License generation created unexpected files:" | ||
| git status --short | ||
| exit 1 | ||
| fi | ||
|
|
||
| - name: Check for a license update | ||
| id: changes | ||
| run: | | ||
| if git diff --quiet HEAD -- LICENSE-3rdparty.csv; then | ||
| echo "changed=false" >> "$GITHUB_OUTPUT" | ||
| else | ||
| echo "changed=true" >> "$GITHUB_OUTPUT" | ||
| fi | ||
|
|
||
| - name: Prepare update artifact | ||
| if: steps.changes.outputs.changed == 'true' | ||
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | ||
| with: | ||
| script: | | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
|
|
||
| const artifactDir = 'dependabot-license-update'; | ||
| fs.mkdirSync(artifactDir, { recursive: false }); | ||
| fs.copyFileSync( | ||
| 'LICENSE-3rdparty.csv', | ||
| path.join(artifactDir, 'LICENSE-3rdparty.csv'), | ||
| ); | ||
|
|
||
| const metadata = { | ||
| version: 1, | ||
| pull_request: context.payload.pull_request.number, | ||
| head_ref: context.payload.pull_request.head.ref, | ||
| head_sha: context.payload.pull_request.head.sha, | ||
| }; | ||
| fs.writeFileSync( | ||
| path.join(artifactDir, 'metadata.json'), | ||
| `${JSON.stringify(metadata, null, 2)}\n`, | ||
| { encoding: 'utf8', flag: 'wx' }, | ||
| ); | ||
|
|
||
| - name: Upload update artifact | ||
| if: steps.changes.outputs.changed == 'true' | ||
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | ||
| with: | ||
| name: dependabot-license-update | ||
| path: dependabot-license-update/ | ||
| if-no-files-found: error | ||
| retention-days: 1 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
At this validation point the privileged workflow accepts any artifact whose metadata names an open PR with a matching head ref/SHA; it never checks that the referenced PR is a same-repository Dependabot Cargo PR targeting
main. If a same-repository PR changes the generator workflow to upload this artifact, it can pointmetadata.pull_requestat a PR whose head is a protected branch such asmain, causing the app token path below to fast-forward that branch with the artifact contents. Mirror the generator’s author/base/head-repo/head-ref checks here before settingapply.Useful? React with 👍 / 👎.