Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .github/dependabot-for-sync.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .github/files-sync-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
134 changes: 134 additions & 0 deletions .github/workflows/dependabot-auto-merge.yml
Original file line number Diff line number Diff line change
@@ -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<ref>',
);
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}`);
15 changes: 15 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/` は他のリポジトリには同期しません。
79 changes: 79 additions & 0 deletions tests/dependabot-auto-merge.test.cjs
Original file line number Diff line number Diff line change
@@ -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), []));
Loading