diff --git a/.github/dependabot-for-sync.yml b/.github/dependabot-for-sync.yml index f090c50..82b959a 100644 --- a/.github/dependabot-for-sync.yml +++ b/.github/dependabot-for-sync.yml @@ -12,9 +12,14 @@ updates: ignore: - dependency-name: "the-pr-agent/pr-agent" groups: + tagpr: + patterns: + - "Songmu/tagpr" dependencies: patterns: - "*" + exclude-patterns: + - "Songmu/tagpr" open-pull-requests-limit: 20 - package-ecosystem: gomod diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6773f7b..eac731e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,9 +10,14 @@ updates: cooldown: default-days: 7 groups: + tagpr: + patterns: + - "Songmu/tagpr" dependencies: patterns: - "*" + exclude-patterns: + - "Songmu/tagpr" open-pull-requests-limit: 20 - package-ecosystem: gomod diff --git a/.github/files-sync-config.yaml b/.github/files-sync-config.yaml index 887c6bf..c016cc8 100644 --- a/.github/files-sync-config.yaml +++ b/.github/files-sync-config.yaml @@ -6,6 +6,7 @@ settings: patterns: - files: - .github/workflows/pr-agent.yml + - .github/workflows/dependabot-auto-merge.yml - from: .github/dependabot-for-sync.yml to: .github/dependabot.yml - .golangci.yml diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000..13c2356 --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,134 @@ +name: Auto-merge tagpr updates + +on: + workflow_run: + workflows: [test, Tests, CI] + types: [completed] + +permissions: {} + +concurrency: + group: tagpr-auto-merge-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: false + +jobs: + merge: + if: >- + github.repository_owner == 'monitoring-forge' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_repository.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + actions: read + contents: read + pull-requests: read + steps: + - name: Verify tests and tagpr-only changes + id: verify + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const {owner, repo} = context.repo; + const fullName = `${owner}/${repo}`; + const eventRun = context.payload.workflow_run; + const {data: run} = await github.rest.actions.getWorkflowRun({owner, repo, run_id: eventRun.id}); + if (run.status !== 'completed' || run.conclusion !== 'success' || + run.run_attempt !== eventRun.run_attempt || + !['push', 'pull_request'].includes(run.event) || + run.head_repository?.full_name !== fullName || + !['.github/workflows/test.yml', '.github/workflows/ci.yml'].includes(run.path?.split('@')[0])) { + core.info('Skip: no current successful test run in this repository.'); + return; + } + const prs = await github.paginate(github.rest.pulls.list, { + owner, repo, state: 'open', head: `${owner}:${run.head_branch}`, per_page: 100, + }); + for (const candidate of prs) { + const {data: pr} = await github.rest.pulls.get({owner, repo, pull_number: candidate.number}); + if (pr.user.login !== 'dependabot[bot]' || pr.draft || + pr.head.repo?.full_name !== fullName || pr.head.sha !== run.head_sha || + pr.base.ref !== context.payload.repository.default_branch) continue; + + // Inspect data only: never check out or execute code from the PR. + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, repo, pull_number: pr.number, per_page: 100, + }); + if (!files.length || files.length !== pr.changed_files) continue; + let tagprOnly = true; + const normalize = text => text.replace( + /^(\s*(?:-\s+)?uses:\s*Songmu\/tagpr@)[a-zA-Z0-9._/-]+(?:[ \t]+#[^\r\n]*)?[ \t]*$/gm, + '$1', + ); + for (const file of files) { + if (file.status !== 'modified' || !/^\.github\/workflows\/[^/]+\.ya?ml$/.test(file.filename)) { + tagprOnly = false; + break; + } + const read = async ref => { + const {data} = await github.rest.repos.getContent({owner, repo, path: file.filename, ref}); + if (data.type !== 'file' || data.encoding !== 'base64') throw new Error('Unexpected file response'); + return Buffer.from(data.content, 'base64').toString('utf8'); + }; + const before = await read(pr.base.sha); + const after = await read(pr.head.sha); + if (before === after || normalize(before) !== normalize(after)) { + tagprOnly = false; + break; + } + } + if (!tagprOnly) { + core.info(`Skip #${pr.number}: changes are not limited to Songmu/tagpr references.`); + continue; + } + + // Reject a superseded success or a pending/failed rerun for this exact head. + const runs = await github.paginate(github.rest.actions.listWorkflowRuns, { + owner, repo, workflow_id: run.workflow_id, head_sha: pr.head.sha, + branch: pr.head.ref, event: run.event, per_page: 100, + }); + const latest = runs.sort((a, b) => b.id - a.id)[0]; + if (!latest || latest.id !== run.id || latest.status !== 'completed' || + latest.conclusion !== 'success' || latest.run_attempt !== run.run_attempt) continue; + + const {data: repository} = await github.rest.repos.get({owner, repo}); + const method = repository.allow_merge_commit ? 'merge' : + repository.allow_squash_merge ? 'squash' : repository.allow_rebase_merge ? 'rebase' : null; + if (!method) throw new Error('No merge method is enabled'); + core.setOutput('pull-number', pr.number); + core.setOutput('head-sha', pr.head.sha); + core.setOutput('merge-method', method); + return; + } + + - name: Generate repository-scoped merge token + if: steps.verify.outputs.pull-number != '' + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + client-id: ${{ secrets.CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + permission-pull-requests: write + + - name: Merge with GitHub App to trigger push workflows + if: steps.verify.outputs.pull-number != '' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + PR_NUMBER: ${{ steps.verify.outputs.pull-number }} + HEAD_SHA: ${{ steps.verify.outputs.head-sha }} + MERGE_METHOD: ${{ steps.verify.outputs.merge-method }} + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const {owner, repo} = context.repo; + const pull_number = Number(process.env.PR_NUMBER); + // The App token triggers push workflows; the SHA rejects a newer push. + const {data: result} = await github.rest.pulls.merge({ + owner, repo, pull_number, sha: process.env.HEAD_SHA, + merge_method: process.env.MERGE_METHOD, + }); + if (!result.merged) throw new Error(result.message); + core.info(`Merged #${pull_number}: ${result.sha}`); diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..f2ab39c --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,15 @@ +name: test +on: + push: + branches: + - '**' + pull_request: +permissions: + contents: read +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Test auto-merge policy + run: node --test tests/*.test.cjs diff --git a/README.md b/README.md index fb3b762..71d7918 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,53 @@ ## 対象ファイル - .github/workflows/pr-agent.yml +- .github/workflows/dependabot-auto-merge.yml - .github/dependabot.yml - .golangci.yml +## Songmu/tagpr 更新の自動マージ + +`.github/workflows/dependabot-auto-merge.yml` を各リポジトリに同期します。 +Dependabot の GitHub Actions 更新では `Songmu/tagpr` を専用の `tagpr` グループに分離し、 +他の依存関係を含む PR は自動マージしません。既存の `dependencies` グループの PR でも、 +実際の差分が tagpr の参照更新だけなら対象になります。メジャー更新も対象です。 + +次の条件をすべて満たす場合、テスト完了後にマージします。 + +- PR の作成者が `dependabot[bot]` で、同じリポジトリのブランチからデフォルトブランチへの PR である。 +- `.github/workflows/test.yml` または `.github/workflows/ci.yml` の `test` / `Tests` / `CI` が成功した。 +- 成功した実行が PR の最新コミットに対応し、再実行や新しい実行で置き換えられていない。 +- 変更ファイルは既存の `.github/workflows/*.yml` / `*.yaml` のみで、変更内容は `uses: Songmu/tagpr@...` の参照と同行のコメントのみである。 + +`workflow_run` でテスト終了後に起動し、PR のコード・成果物・キャッシュは実行しません。 +GitHub API にマージ対象のコミット SHA を渡し、判定後に追加されたコミットのマージを防ぎます。 +App をブランチ保護・ruleset の bypass 対象に追加する必要はありません。既存の保護を維持してください。保護設定などでマージが拒否された場合は +Actions の実行が失敗します。条件を解消した後、対象 PR のテストを再実行してください。 +GitHub の「Allow auto-merge」設定には依存しません。 + +### 導入 + +事前に、既存 GitHub App を全対象リポジトリへインストールし、Actions Secrets の +`CLIENT_ID` と `APP_PRIVATE_KEY` を各リポジトリから利用可能にしてください。 +Organization Secrets の場合は対象リポジトリへの公開範囲も確認してください。 +App は Contents / Pull requests の読み書き権限を使用します。Issues の権限は不要です。 +Actions の読み取りは標準の `GITHUB_TOKEN` で行うため、App への追加権限は不要です。 + +1. この変更を `main` にマージし、通常の tagpr リリースを行います。 +2. リリース時の既存のファイル同期により、36リポジトリに同期 PR が作成されます。 +3. 各同期 PR をマージすると有効になります。`github-common` 自身はこの変更のマージで有効になります。 +4. すでにテストが終了している Dependabot PR は、導入後にテストを再実行すると判定されます。 + +テストが存在しない、または成功しない場合は自動マージされません。 +テストと差分の確認には読み取り専用の `GITHUB_TOKEN` を使い、対象が見つかった場合のみ、 +現在のリポジトリに限定した GitHub App トークンを発行してマージします。 +そのため、マージ後の `push` を起点とするテスト・tagpr も起動します。 +tagpr 側のトークン設定は変更しません。Secrets が未設定の場合はマージせず失敗します。 +他の必須チェックがテストより遅れて終了する場合も、完了後にテストを再実行してください。 + +### 検証 + +`github-common` で `node --test tests/*.test.cjs` を実行します。 +実際に同期するワークフロー内のスクリプトを、GitHub API の応答を模擬して検証します。 +この検証用の `test.yml` と `tests/` は他のリポジトリには同期しません。 diff --git a/tests/dependabot-auto-merge.test.cjs b/tests/dependabot-auto-merge.test.cjs new file mode 100644 index 0000000..f0e7dac --- /dev/null +++ b/tests/dependabot-auto-merge.test.cjs @@ -0,0 +1,79 @@ +const {test} = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const workflow = fs.readFileSync('.github/workflows/dependabot-auto-merge.yml', 'utf8'); +const scripts = [...workflow.matchAll(/ script: \|\n((?: [^\n]*\n|\n)*)/g)] + .map(match => match[1].split('\n').map(line => line.slice(12)).join('\n')); +assert.equal(scripts.length, 2); +const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor; +const execute = new AsyncFunction('github', 'context', 'core', scripts[0]); +const merge = new AsyncFunction('github', 'context', 'core', 'process', scripts[1]); + +async function scenario(change = () => {}) { + const fullName = 'monitoring-forge/flagrun'; + const run = {id: 1, workflow_id: 2, run_attempt: 1, status: 'completed', conclusion: 'success', + event: 'push', head_sha: 'tested', head_branch: 'dependabot/github_actions/dependencies-123', + head_repository: {full_name: fullName}, path: '.github/workflows/test.yml'}; + const pr = {number: 34, user: {login: 'dependabot[bot]'}, draft: false, changed_files: 1, + head: {sha: 'tested', ref: run.head_branch, repo: {full_name: fullName}}, base: {ref: 'main', sha: 'base'}}; + const state = {run, eventRun: {...run}, pr, latest: {...run}, + files: [{filename: '.github/workflows/tagpr.yml', status: 'modified'}], + before: 'steps:\n - uses: Songmu/tagpr@old # v1\n env:\n TOKEN: example\n', + after: 'steps:\n - uses: Songmu/tagpr@new # v2\n env:\n TOKEN: example\n', + repository: {allow_merge_commit: true}, merged: []}; + change(state); + const github = {rest: { + actions: {getWorkflowRun: async () => ({data: state.run}), listWorkflowRuns: 'runs'}, + pulls: {list: 'prs', listFiles: 'files', get: async () => ({data: state.pr}), + merge: async args => {state.merged.push(args); return {data: {merged: true, sha: 'merged'}};}}, + repos: {get: async () => ({data: state.repository}), getContent: async args => ({data: { + type: 'file', encoding: 'base64', content: Buffer.from(args.ref === 'base' ? state.before : state.after).toString('base64'), + }})}, + }, paginate: async (method) => ({runs: [state.latest], prs: [state.pr], files: state.files})[method]}; + const context = {repo: {owner: 'monitoring-forge', repo: 'flagrun'}, + payload: {workflow_run: state.eventRun, repository: {default_branch: 'main'}}}; + const outputs = {}; + const core = {info() {}, setOutput(key, value) {outputs[key] = String(value);}}; + const appGithub = {rest: {pulls: {merge: github.rest.pulls.merge}}}; + github.rest.pulls.merge = async () => {throw new Error('Default token must not merge');}; + await execute(github, context, core); + if (outputs['pull-number']) { + await merge(appGithub, context, core, {env: { + PR_NUMBER: outputs['pull-number'], HEAD_SHA: outputs['head-sha'], MERGE_METHOD: outputs['merge-method'], + }}); + } + return state.merged; +} +test('tagpr-only grouped update merges exactly the tested SHA', async () => { + const [merge] = await scenario(); + assert.equal(merge.sha, 'tested'); assert.equal(merge.pull_number, 34); assert.equal(merge.merge_method, 'merge'); +}); +test('pull_request CI and squash-only repositories are supported', async () => { + const [merge] = await scenario(s => {s.run.event = 'pull_request'; s.run.path = '.github/workflows/ci.yml'; + s.repository = {allow_squash_merge: true};}); + assert.equal(merge.merge_method, 'squash'); +}); +for (const [name, change] of Object.entries({ + 'failed tests': s => {s.run.conclusion = 'failure';}, + 'pending rerun': s => {s.run.status = 'in_progress';}, + 'stale attempt': s => {s.run.run_attempt = 2;}, + 'unrelated workflow': s => {s.run.path = '.github/workflows/tagpr.yml';}, + 'untrusted event': s => {s.run.event = 'workflow_dispatch';}, + 'fork run': s => {s.run.head_repository = {full_name: 'other/flagrun'};}, + 'human author': s => {s.pr.user.login = 'human';}, + 'draft': s => {s.pr.draft = true;}, + 'fork PR': s => {s.pr.head.repo.full_name = 'other/flagrun';}, + 'new head after testing': s => {s.pr.head.sha = 'untested';}, + 'non-default base': s => {s.pr.base.ref = 'other';}, + 'additional dependency': s => {s.before += ' - uses: actions/checkout@old\n'; s.after += ' - uses: actions/checkout@new\n';}, + 'changed executable content': s => {s.after += ' - run: echo unsafe\n';}, + 'indentation change': s => {s.after = s.after.replace(' - uses:', ' - uses:');}, + 'non-workflow file': s => {s.files[0].filename = 'go.mod';}, + 'renamed file': s => {s.files[0].status = 'renamed';}, + 'truncated file list': s => {s.pr.changed_files = 2;}, + 'empty diff': s => {s.files = []; s.pr.changed_files = 0;}, + 'newer test run': s => {s.latest.id = 2;}, + 'latest failed': s => {s.latest.conclusion = 'failure';}, + 'latest pending': s => {s.latest.status = 'in_progress';}, + 'latest attempt changed': s => {s.latest.run_attempt = 2;}, +})) test(`does not merge: ${name}`, async () => assert.deepEqual(await scenario(change), []));