diff --git a/.github/DEPENDABOT_AUTOMERGE.md b/.github/DEPENDABOT_AUTOMERGE.md new file mode 100644 index 0000000..450f372 --- /dev/null +++ b/.github/DEPENDABOT_AUTOMERGE.md @@ -0,0 +1,32 @@ +# Dependabot auto-merge + +Verified Dependabot version updates may squash-merge after the configured build +and test jobs pass on the exact current PR head and base. Runtime, development, +and major dependency updates use the same checks. Course material, exercises, +source changes, new/renamed/deleted files, human commits, and unknown dependency +inputs require review. + +`.github/dependabot-automerge.json` maps existing dependency files to the CI +workflows and jobs that exercise them. Existing workflow changes must consist +only of paired version updates to the same actions; SHA pins cannot become tags. +Add coverage before adding a dependency input. A repository without application +CI stays in manual review; policy self-tests alone cannot authorize a merge. + +The trusted default-branch workflow checks every current PR workflow, check and +commit status, rejects missing/skipped required jobs and outstanding change +requests, approves the tested revision, and merges with an exact head SHA. +Pending, failed, cancelled, stale or incomplete results never authorize a merge. +Checks run only in ordinary read-only PR jobs. The privileged workflow does not +execute PR code or consume its dependencies, caches or artifacts. + +Merging uses only the repository GITHUB_TOKEN, so it does not start push-driven +publishing, Pages, or deployment workflows. No release is dispatched here. +The platform's separate workflow handles dependency-labelled staging images and +its 21:00–08:00 Europe/Helsinki dependency-only deployment window. + +The workflow runs after CI completion. Its manual default-branch dispatch can +recheck existing Dependabot PRs; it applies the same policy and checks. Existing +Dependabot schedules, branch rules, releases and archived repository states are +not altered. + +Validate policy changes with `node --test .github/scripts/dependabot.test.mjs`. diff --git a/.github/dependabot-automerge.json b/.github/dependabot-automerge.json new file mode 100644 index 0000000..ab8092d --- /dev/null +++ b/.github/dependabot-automerge.json @@ -0,0 +1,57 @@ +{ + "baseBranch": "main", + "coursePaths": [ + "materials", + "assignment-repository", + "opencs-materials", + "opencs-assignment-repository", + "test-opencs-materials", + "test-opencs-assignment-repository" + ], + "dependencyFiles": { + "Cargo.toml": [ + ".github/workflows/test.yml" + ], + "Cargo.lock": [ + ".github/workflows/test.yml" + ], + "playground/package.json": [ + ".github/workflows/test.yml", + ".github/workflows/playground-dependencies.yml" + ], + "playground/package-lock.json": [ + ".github/workflows/test.yml", + ".github/workflows/playground-dependencies.yml" + ] + }, + "actionWorkflows": { + ".github/workflows/playground-dependencies.yml": [ + ".github/workflows/test.yml", + ".github/workflows/playground-dependencies.yml" + ], + ".github/workflows/publish.yml": [ + ".github/workflows/test.yml" + ], + ".github/workflows/pages.yml": [ + ".github/workflows/test.yml" + ], + ".github/workflows/test.yml": [ + ".github/workflows/test.yml" + ], + ".github/workflows/dependabot-policy.yml": [ + ".github/workflows/test.yml" + ], + ".github/workflows/dependabot-automerge.yml": [ + ".github/workflows/test.yml" + ] + }, + "workflows": { + ".github/workflows/test.yml": [ + "^run-tests$" + ], + ".github/workflows/playground-dependencies.yml": [ + "^contracts$", + "^production-build$" + ] + } +} diff --git a/.github/scripts/dependabot.mjs b/.github/scripts/dependabot.mjs new file mode 100644 index 0000000..f7399ec --- /dev/null +++ b/.github/scripts/dependabot.mjs @@ -0,0 +1,195 @@ +import { appendFileSync, readFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; + +const bot = (user) => user?.login === 'dependabot[bot]' && user.id === 49699333; +const reject = (reason) => ({ eligible: false, reason }); +const policyWorkflow = '.github/workflows/dependabot-policy.yml'; + +export function actionVersionsOnly(file) { + if (!file.patch) return false; + const before = [], after = []; + for (const line of file.patch.split('\n')) { + if (!line.startsWith('+') && !line.startsWith('-')) continue; + const match = line.slice(1).match(/^(\s*(?:-\s+)?uses:\s+[\w.-]+\/[\w./-]+)@([a-f0-9]{40}|v?\d[\w.+-]*)(?:\s+#.*)?$/i); + if (!match) return false; + (line[0] === '+' ? after : before).push(match.slice(1)); + } + return after.length > 0 && after.length === file.additions && + before.length === file.deletions && before.length === after.length && + before.every(([action, ref], i) => action === after[i][0] && + (!/^[a-f0-9]{40}$/i.test(ref) || /^[a-f0-9]{40}$/i.test(after[i][1]))); +} + +export function evaluatePullRequest(pr, files, commits, repository, config) { + if (pr.state !== 'open' || pr.draft || !bot(pr.user)) + return reject('Only open, non-draft Dependabot PRs qualify.'); + if (pr.base?.ref !== config.baseBranch || pr.base.repo?.full_name !== repository || + pr.head?.repo?.full_name !== repository || !pr.head.ref.startsWith('dependabot/')) + return reject('The PR must originate here and target the default branch.'); + if (config.disabledReason) return reject(config.disabledReason); + if (!files.length || files.length !== pr.changed_files || files.length > 100) + return reject('The complete, bounded changed-file list is required.'); + const required = new Set(); + for (const file of files) { + const course = [file.filename, file.previous_filename].filter(Boolean).some(path => + config.coursePaths.some(prefix => path === prefix || path.startsWith(prefix + '/'))); + if (course) return reject(`Course material or exercise content changed: ${file.filename}`); + if (file.status !== 'modified') return reject('Added, deleted and renamed files need review.'); + let coverage; + if (/^\.github\/workflows\/[^/]+\.ya?ml$/.test(file.filename)) { + if (!Object.hasOwn(config.actionWorkflows, file.filename) || !actionVersionsOnly(file)) + return reject('Only version updates to existing actions in covered workflows qualify.'); + coverage = config.actionWorkflows[file.filename]; + } else { + coverage = Object.hasOwn(config.dependencyFiles, file.filename) && config.dependencyFiles[file.filename]; + } + if (!Array.isArray(coverage) || !coverage.length) + return reject(`No reviewed dependency/build coverage for ${file.filename}.`); + for (const workflow of coverage) required.add(workflow); + } + if (!commits.length || commits.length !== pr.commits || commits.at(-1).sha !== pr.head.sha || + commits.some(commit => !bot(commit.author) || !commit.commit?.verification?.verified)) + return reject('Every commit must be a verified Dependabot commit on the current head.'); + if ([...required].some(path => !config.workflows[path]?.length) || !required.size) + return reject('Application build/test coverage must be configured.'); + required.add(policyWorkflow); + return { eligible: true, required: [...required], reason: 'Verified non-course dependency version update.' }; +} + +export function evaluateChecks(pr, runs, checks, statuses, required, config) { + const latest = new Map(); + for (const run of runs) { + if (run.event !== 'pull_request' || run.head_sha !== pr.head.sha) continue; + const previous = latest.get(run.workflow_id); + if (!previous || run.id > previous.id || (run.id === previous.id && run.run_attempt > previous.run_attempt)) + latest.set(run.workflow_id, run); + } + if ([...latest.values()].some(run => run.status !== 'completed' || run.conclusion !== 'success')) + return reject('A current PR workflow is pending or unsuccessful.'); + const verifiedRuns = []; + for (const path of required) { + const run = [...latest.values()].find(item => item.path === path); + if (!run || run.status !== 'completed' || run.conclusion !== 'success' || + !run.pull_requests?.some(item => item.number === pr.number && + item.head.sha === pr.head.sha && item.base.sha === pr.base.sha)) + return reject(`Build/tests must pass on the current head and base: ${path}`); + const patterns = path === policyWorkflow ? ['^policy$'] : config.workflows[path]; + for (const pattern of patterns) { + const jobs = (run.jobs || []).filter(job => new RegExp(pattern).test(job.name)); + if (!jobs.length || jobs.some(job => job.status !== 'completed' || job.conclusion !== 'success')) + return reject(`Required build/test jobs did not run successfully: ${path} / ${pattern}`); + } + verifiedRuns.push(run.id); + } + if (!checks.length || checks.some(check => check.status !== 'completed' || + !(check.conclusion === 'success' || (check.conclusion === 'skipped' && check.app?.slug === 'github-actions')))) + return reject('All current checks must finish successfully.'); + const latestStatuses = new Map(); + for (const status of statuses) { + const previous = latestStatuses.get(status.context); + if (!previous || status.id > previous.id) latestStatuses.set(status.context, status); + } + if ([...latestStatuses.values()].some(status => status.state !== 'success')) + return reject('A commit status is pending or unsuccessful.'); + return { eligible: true, reason: 'All current build and test checks passed.', verifiedRuns }; +} + +export function githubClient(repository, token) { + if (!/^[\w.-]+\/[\w.-]+$/.test(repository) || !token) throw new Error('Repository and token required.'); + const request = async (path, method = 'GET', body) => { + const response = await fetch(`https://api.github.com/repos/${repository}/${path}`, { + method, + headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', 'Content-Type': 'application/json' }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + if (!response.ok) throw new Error(`GitHub ${method} ${path}: HTTP ${response.status}`); + return response.status === 204 ? undefined : response.json(); + }; + const list = async (path, key) => { + const result = []; + for (let page = 1; page <= 50; page++) { + const data = await request(`${path}${path.includes('?') ? '&' : '?'}per_page=100&page=${page}`); + const items = key ? data[key] : data; + if (!Array.isArray(items)) throw new Error('Expected a complete GitHub list.'); + result.push(...items); + if (items.length < 100) return result; + } + throw new Error('GitHub list exceeded the bounded review limit.'); + }; + return { request, list }; +} + +export async function processPullRequest({ client, repository, config, number, expectedHead, merge = false }) { + const { request, list } = client; + const pr = await request(`pulls/${number}`); + if (expectedHead && expectedHead !== pr.head.sha) return reject('Completion belongs to an old head.'); + const [files, commits] = await Promise.all([list(`pulls/${number}/files`), list(`pulls/${number}/commits`)]); + const eligibility = evaluatePullRequest(pr, files, commits, repository, config); + if (!eligibility.eligible) return eligibility; + const [runs, checks, statuses] = await Promise.all([ + list(`actions/runs?event=pull_request&head_sha=${pr.head.sha}`, 'workflow_runs'), + list(`commits/${pr.head.sha}/check-runs?filter=latest`, 'check_runs'), + list(`commits/${pr.head.sha}/statuses`), + ]); + // Job conclusions distinguish a real build/test pass from a skipped workflow. + for (const run of runs) { + if (eligibility.required.includes(run.path) && run.head_sha === pr.head.sha) + run.jobs = await list(`actions/runs/${run.id}/attempts/${run.run_attempt}/jobs`, 'jobs'); + } + const readiness = evaluateChecks(pr, runs, checks, statuses, eligibility.required, config); + if (!readiness.eligible || !merge) return readiness; + const fresh = await request(`pulls/${number}`); + if (fresh.state !== 'open' || fresh.draft || fresh.head.sha !== pr.head.sha || + fresh.base.sha !== pr.base.sha || fresh.base.ref !== pr.base.ref || fresh.mergeable !== true) + return reject('The PR moved, closed, or is not mergeable; fresh checks are required.'); + const reviews = await list(`pulls/${number}/reviews`); + const latestReviews = new Map(); + for (const review of reviews) { + if (review.state !== 'COMMENTED') latestReviews.set(review.user.login, review); + } + if ([...latestReviews.values()].some(review => review.state === 'CHANGES_REQUESTED')) + return reject('A reviewer has requested changes.'); + if (!reviews.some(review => review.user.login === 'github-actions[bot]' && + review.commit_id === pr.head.sha && review.state === 'APPROVED')) + await request(`pulls/${number}/reviews`, 'POST', { event: 'APPROVE', commit_id: pr.head.sha }); + // Use only GITHUB_TOKEN: this merge does not trigger push-driven publishing, + // Pages, or deployment workflows. Never substitute a PAT or App token. + const result = await request(`pulls/${number}/merge`, 'PUT', { + sha: pr.head.sha, merge_method: 'squash', + commit_title: `chore(deps): merge tested non-course dependency update (#${number})`, + }); + if (!result.merged) throw new Error('GitHub did not merge the PR.'); + return { eligible: true, merged: true, reason: `Merged #${number} after build/tests; no release dispatched.` }; +} + +async function main() { + const event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8')); + const config = JSON.parse(readFileSync(new URL('../dependabot-automerge.json', import.meta.url), 'utf8')); + const repository = process.env.GITHUB_REPOSITORY; + const client = githubClient(repository, process.env.GH_TOKEN); + const merge = process.argv[2] === 'merge'; + if (!merge && process.argv[2] !== 'check') throw new Error('Expected check or merge mode.'); + let candidates; + const run = event.workflow_run; + if (run) { + if (run.event !== 'pull_request' || run.head_repository?.full_name !== repository || + !run.head_branch.startsWith('dependabot/')) return; + candidates = run.pull_requests || []; + if (!candidates.length) candidates = await client.list( + `pulls?state=open&base=${encodeURIComponent(config.baseBranch)}&head=${encodeURIComponent(repository.split('/')[0] + ':' + run.head_branch)}`); + } else if (process.env.GITHUB_EVENT_NAME === 'workflow_dispatch') { + candidates = (await client.list(`pulls?state=open&base=${encodeURIComponent(config.baseBranch)}`)) + .filter(pr => bot(pr.user)); + } else throw new Error('Only workflow_run or manual default-branch dispatch is supported.'); + for (const candidate of candidates) { + const result = await processPullRequest({ client, repository, config, number: candidate.number, + expectedHead: run?.head_sha, merge }); + console.log(`#${candidate.number}: ${result.reason}`); + if (process.env.GITHUB_STEP_SUMMARY) + appendFileSync(process.env.GITHUB_STEP_SUMMARY, `- #${candidate.number}: ${result.reason}\n`); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) + main().catch(error => { console.error(error.message); process.exitCode = 1; }); diff --git a/.github/scripts/dependabot.test.mjs b/.github/scripts/dependabot.test.mjs new file mode 100644 index 0000000..2be4727 --- /dev/null +++ b/.github/scripts/dependabot.test.mjs @@ -0,0 +1,157 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import { actionVersionsOnly, evaluatePullRequest, evaluateChecks, processPullRequest } from './dependabot.mjs'; + +const repository = 'aalto-opencs/example'; +const workflow = '.github/workflows/test.yml'; +const policy = '.github/workflows/dependabot-policy.yml'; +const bot = { login: 'dependabot[bot]', id: 49699333 }; +const config = { baseBranch: 'main', coursePaths: ['materials', 'assignment-repository'], + dependencyFiles: { 'package.json': [workflow], 'package-lock.json': [workflow], 'Dockerfile': [workflow] }, + actionWorkflows: { [workflow]: [workflow] }, workflows: { [workflow]: ['^tests$'] } }; +function fixture() { + const pr = { number: 1, state: 'open', draft: false, user: bot, changed_files: 1, commits: 1, + mergeable: true, base: { ref: 'main', sha: 'base', repo: { full_name: repository } }, + head: { ref: 'dependabot/npm/pkg-2', sha: 'head', repo: { full_name: repository } } }; + const files = [{ filename: 'package-lock.json', status: 'modified' }]; + const commits = [{ sha: 'head', author: bot, commit: { verification: { verified: true } } }]; + const runs = [workflow, policy].map((path, i) => ({ id: i + 1, workflow_id: i + 1, run_attempt: 1, + path, event: 'pull_request', head_sha: 'head', status: 'completed', conclusion: 'success', + pull_requests: [{ number: 1, head: { sha: 'head' }, base: { sha: 'base' } }], + jobs: [{ name: i === 0 ? 'tests' : 'policy', status: 'completed', conclusion: 'success' }] })); + const checks = [{ name: 'tests', app: { slug: 'github-actions' }, status: 'completed', conclusion: 'success' }]; + return structuredClone({ pr, files, commits, runs, checks, statuses: [], config }); +} +const eligibility = f => evaluatePullRequest(f.pr, f.files, f.commits, repository, f.config); +const readiness = f => evaluateChecks(f.pr, f.runs, f.checks, f.statuses, [workflow, policy], f.config); + +test('runtime, dev, major and Docker dependencies use the same test gate', () => { + for (const filename of Object.keys(config.dependencyFiles)) { + const f = fixture(); f.files[0].filename = filename; + assert.equal(eligibility(f).eligible, true); + assert.equal(readiness(f).eligible, true); + } +}); + +for (const [name, mutate] of [ + ['human PR', f => { f.pr.user = { login: 'human', id: 10 }; }], + ['forged bot login', f => { f.pr.user.id = 10; }], + ['draft PR', f => { f.pr.draft = true; }], + ['closed PR', f => { f.pr.state = 'closed'; }], + ['wrong target', f => { f.pr.base.ref = 'release'; }], + ['fork', f => { f.pr.head.repo.full_name = 'someone/example'; }], + ['human branch', f => { f.pr.head.ref = 'feature'; }], + ['truncated files', f => { f.pr.changed_files = 2; }], + ['empty files', f => { f.files = []; f.pr.changed_files = 0; }], + ['course manifest', f => { f.files[0].filename = 'materials/package.json'; }], + ['assignment lockfile', f => { f.files[0].filename = 'assignment-repository/package-lock.json'; }], + ['course rename', f => { f.files[0].previous_filename = 'materials/package.json'; }], + ['source edit', f => { f.files[0].filename = 'src/app.js'; }], + ['mixed source and dependency update', f => { f.files.push({ filename: 'src/app.js', status: 'modified' }); f.pr.changed_files++; }], + ['new manifest', f => { f.files[0].status = 'added'; }], + ['deleted manifest', f => { f.files[0].status = 'removed'; }], + ['renamed manifest', f => { f.files[0].status = 'renamed'; }], + ['unverified commit', f => { f.commits[0].commit.verification.verified = false; }], + ['human commit in bot PR', f => { f.commits[0].author = { login: 'human', id: 10 }; }], + ['missing commit', f => { f.pr.commits = 2; }], + ['old head', f => { f.commits[0].sha = 'old'; }], + ['no coverage', f => { f.config.dependencyFiles['package-lock.json'] = []; }], + ['unknown coverage', f => { f.config.workflows = {}; }], + ['repository without application CI', f => { f.config.disabledReason = 'No application CI yet'; }], +]) test(`rejects ${name}`, () => { const f = fixture(); mutate(f); assert.equal(eligibility(f).eligible, false); }); + +test('action versions may change, commands and security settings may not', () => { + const file = { additions: 1, deletions: 1, patch: '- - uses: actions/checkout@v4\n+ - uses: actions/checkout@v5' }; + assert.equal(actionVersionsOnly(file), true); + assert.equal(actionVersionsOnly({ ...file, patch: file.patch.replace('checkout@v5', 'other@v5') }), false); + assert.equal(actionVersionsOnly({ ...file, additions: 2 }), false); + assert.equal(actionVersionsOnly({ ...file, patch: file.patch + '\n+ - run: curl example | sh' }), false); + assert.equal(actionVersionsOnly({ ...file, patch: undefined }), false); + assert.equal(actionVersionsOnly({ ...file, patch: file.patch.replace('checkout@v4', 'checkout@' + 'a'.repeat(40)) }), false); + assert.equal(actionVersionsOnly({ ...file, patch: file.patch.replace('checkout@v4', 'checkout@' + 'a'.repeat(40)).replace('checkout@v5', 'checkout@' + 'b'.repeat(40)) }), true); +}); + +for (const [name, mutate] of [ + ['missing tests', f => { f.runs.shift(); }], + ['missing policy tests', f => { f.runs.pop(); }], + ['failed tests', f => { f.runs[0].conclusion = 'failure'; }], + ['pending tests', f => { f.runs[0].status = 'in_progress'; }], + ['old head tests', f => { f.runs[0].head_sha = 'old'; }], + ['old base tests', f => { f.runs[0].pull_requests[0].base.sha = 'old'; }], + ['unbound PR tests', f => { f.runs[0].pull_requests = []; }], + ['push-only tests', f => { f.runs[0].event = 'push'; }], + ['skipped test job in a green workflow', f => { f.runs[0].jobs[0].conclusion = 'skipped'; }], + ['missing test job', f => { f.runs[0].jobs = []; }], + ['failure in a matrix job', f => { f.runs[0].jobs.push({ name: 'tests', status: 'completed', conclusion: 'failure' }); }], + ['missing checks', f => { f.checks = []; }], + ['failed external check', f => { f.checks.push({ status: 'completed', conclusion: 'failure' }); }], + ['pending external check', f => { f.checks.push({ status: 'queued' }); }], + ['neutral external check', f => { f.checks.push({ status: 'completed', conclusion: 'neutral' }); }], + ['new failed rerun', f => { f.runs.push({ ...f.runs[0], run_attempt: 2, conclusion: 'failure' }); }], + ['new cancelled run', f => { f.runs.push({ ...f.runs[0], id: 10, conclusion: 'cancelled' }); }], + ['failed status', f => { f.statuses = [{ id: 1, context: 'external', state: 'failure' }]; }], +]) test(`blocks merge with ${name}`, () => { const f = fixture(); mutate(f); assert.equal(readiness(f).eligible, false); }); + +test('latest successful rerun and statuses supersede failures', () => { + const f = fixture(); + f.runs.push({ ...f.runs[0], run_attempt: 0, conclusion: 'failure' }); + f.statuses = [{ id: 2, context: 'external', state: 'success' }, { id: 1, context: 'external', state: 'failure' }]; + f.checks.push({ app: { slug: 'github-actions' }, status: 'completed', conclusion: 'skipped' }); + assert.equal(readiness(f).eligible, true); +}); + +function clientFor(f, { moved = false, reviews = [] } = {}) { + const writes = []; let reads = 0; + return { writes, client: { + request: async (path, method = 'GET', body) => { + if (method !== 'GET') { writes.push({ path, method, body }); return { merged: true }; } + if (path === 'pulls/1') { reads++; return structuredClone(moved && reads > 1 ? { ...f.pr, head: { ...f.pr.head, sha: 'changed' } } : f.pr); } + throw new Error(`Unexpected GET ${path}`); + }, + list: async path => { + if (path === 'pulls/1/files') return f.files; + if (path === 'pulls/1/commits') return f.commits; + if (path === 'pulls/1/reviews') return reviews; + if (path.startsWith('actions/runs?')) return f.runs; + if (path.includes('/check-runs?')) return f.checks; + if (path.endsWith('/statuses')) return f.statuses; + const match = path.match(/^actions\/runs\/(\d+)\/attempts\/\d+\/jobs$/); + if (match) return f.runs.find(run => run.id === Number(match[1])).jobs; + throw new Error(`Unexpected list ${path}`); + }, + } }; +} +for (const mode of ['check', 'merge', 'moved', 'changes-requested', 'pending']) { + test(`end-to-end API policy: ${mode}`, async () => { + const f = fixture(); + if (mode === 'pending') f.runs[0].status = 'in_progress'; + const { client, writes } = clientFor(f, { moved: mode === 'moved', reviews: mode === 'changes-requested' ? + [{ user: { login: 'reviewer' }, state: 'CHANGES_REQUESTED' }] : [] }); + await processPullRequest({ client, repository, config: f.config, number: 1, expectedHead: 'head', merge: mode !== 'check' }); + if (mode === 'merge') { + assert.deepEqual(writes.map(write => write.method), ['POST', 'PUT']); + assert.equal(writes[0].body.commit_id, 'head'); + assert.equal(writes[1].body.sha, 'head'); + assert.equal(writes[1].body.merge_method, 'squash'); + assert.ok(writes.every(write => !write.path.includes('dispatch'))); + } else assert.deepEqual(writes, []); + }); +} + +test('repository policy references existing dependency files and real CI workflows', () => { + const url = new URL('../dependabot-automerge.json', import.meta.url); + if (!existsSync(url)) return; // Template tests have no repository configuration. + const actual = JSON.parse(readFileSync(url, 'utf8')); + const root = new URL('../../', import.meta.url); + assert.ok(actual.baseBranch); + assert.ok(Array.isArray(actual.coursePaths)); + for (const [path, requirements] of Object.entries({ ...actual.dependencyFiles, ...actual.actionWorkflows })) { + assert.ok(existsSync(new URL(path, root)), path); + assert.ok(requirements.length, path); + for (const required of requirements) { + assert.ok(existsSync(new URL(required, root)), required); + assert.ok(actual.workflows[required]?.length, required); + } + } +}); diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml new file mode 100644 index 0000000..af86c8a --- /dev/null +++ b/.github/workflows/dependabot-automerge.yml @@ -0,0 +1,37 @@ +name: Merge tested Dependabot updates +on: + workflow_run: + workflows: ["Dependabot policy tests", "CodeQL", "Copilot code review", "Security Risk Assessment", "Deploy Playground to Pages", "Playground dependency contracts", "Build and Push stlcpp Docker Image", "Run tests"] + types: [completed] + workflow_dispatch: +permissions: + contents: read +concurrency: + group: dependabot-automerge-${{ github.event.workflow_run.head_branch || github.ref }} + cancel-in-progress: false +jobs: + merge: + if: >- + (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') || + (github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.head_repository.full_name == github.repository && + startsWith(github.event.workflow_run.head_branch, 'dependabot/')) + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + actions: read + checks: read + statuses: read + steps: + # Execute only immutable default-branch code. Never use PR code, + # dependencies, caches, or artifacts in this privileged workflow. + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Recheck dependency scope, build/tests and exact revisions, then merge + env: + GH_TOKEN: ${{ github.token }} + run: node .github/scripts/dependabot.mjs merge diff --git a/.github/workflows/dependabot-policy.yml b/.github/workflows/dependabot-policy.yml new file mode 100644 index 0000000..bd7a3ea --- /dev/null +++ b/.github/workflows/dependabot-policy.yml @@ -0,0 +1,18 @@ +name: Dependabot policy tests +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + policy: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '24' + - run: node --test .github/scripts/dependabot.test.mjs