Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
247 changes: 247 additions & 0 deletions .github/workflows/apply_dependabot_license_update.yml
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) {
Comment on lines +140 to +143

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restrict validated artifacts to Dependabot PRs

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 point metadata.pull_request at a PR whose head is a protected branch such as main, 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 setting apply.

Useful? React with 👍 / 👎.

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}.`);
96 changes: 96 additions & 0 deletions .github/workflows/generate_dependabot_license_update.yml
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
Loading