Skip to content
Draft
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
252 changes: 252 additions & 0 deletions .github/workflows/chronus-fix.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
name: Chronus Fix (slash command)

# Listens for `/chronus add [kind]` PR comments and commits a Chronus change
# entry derived from the PR title back to the PR branch.
#
# Security model:
# - Restricted to same-repository PRs (fork PRs get a polite reply with
# local-run instructions).
# - Commenter must have write permission.
# - The Chronus tooling under `.github/chronus` is restored from the BASE
# branch before install/run, so PR-head changes to the tooling cannot
# execute under this workflow's write-scoped token.

on:
issue_comment:
types: [created]

permissions:
contents: write
issues: write
pull-requests: read

concurrency:
group: chronus-fix-${{ github.event.issue.number }}
cancel-in-progress: false

jobs:
chronus-fix:
name: Apply chronus add via slash command
runs-on: ubuntu-latest
if: >-
github.event.issue.pull_request != null &&
startsWith(github.event.comment.body, '/chronus add')
steps:
- name: Authorize command and load PR metadata
id: pr
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const comment = context.payload.comment;
const issue = context.payload.issue;

const match = comment.body.trim().match(/^\/chronus\s+add(?:\s+([A-Za-z]+))?\s*$/i);
if (!match) {
core.setFailed('Invalid command. Use `/chronus add [kind]`.');
return;
}
const aliases = {
internal: 'internal',
fix: 'fix', bug: 'fix',
feature: 'feature', features: 'feature',
breaking: 'breaking',
deprecation: 'deprecation', deprecated: 'deprecation',
dependencies: 'dependencies', deps: 'dependencies',
};
const kind = aliases[(match[1] || 'internal').toLowerCase()];
if (!kind) {
core.setFailed('Unknown kind. Allowed: internal, fix, feature, breaking, deprecation, dependencies.');
return;
}

const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: comment.user.login,
});
if (!['admin', 'maintain', 'write'].includes(perm.permission)) {
core.setFailed(`User ${comment.user.login} lacks write permission (has: ${perm.permission}).`);
return;
}

await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: comment.id,
content: 'eyes',
});

const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: issue.number,
});

if (pr.head.repo.full_name !== pr.base.repo.full_name) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: [
'⚠️ `/chronus add` cannot push to fork PR branches.',
'',
'Please run the following locally and push the resulting file:',
'',
'```bash',
'azpysdk changelog add',
'```',
].join('\n'),
});
core.setFailed('Fork PRs are not supported by /chronus add.');
return;
}

const refOk = /^[A-Za-z0-9._/\-]+$/;
if (!refOk.test(pr.head.ref) || !refOk.test(pr.base.ref)) {
core.setFailed(`Refusing unusual ref name. head=${pr.head.ref} base=${pr.base.ref}`);
return;
}

core.setOutput('head_ref', pr.head.ref);
core.setOutput('head_sha', pr.head.sha);
core.setOutput('base_ref', pr.base.ref);
core.setOutput('title', pr.title);
core.setOutput('kind', kind);

- name: Checkout PR head
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ steps.pr.outputs.head_sha }}
fetch-depth: 0
persist-credentials: false

- name: Fetch base branch and restore trusted tooling
env:
BASE_REF: ${{ steps.pr.outputs.base_ref }}
run: |
set -euo pipefail
git fetch --no-tags origin "$BASE_REF:$BASE_REF"
# Replace .github/chronus with the base-branch version so the PR
# cannot influence what we install or execute below.
git restore --source="$BASE_REF" -- .github/chronus

- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: lts/*
cache: npm
cache-dependency-path: .github/chronus/package-lock.json

- name: Install pinned chronus
run: npm ci
working-directory: .github/chronus

- name: Run chronus add for missing packages
id: add
env:
KIND: ${{ steps.pr.outputs.kind }}
MESSAGE: ${{ steps.pr.outputs.title }}
run: |
set -uo pipefail
out="$(.github/chronus/node_modules/.bin/chronus verify 2>&1)" || true
printf '%s\n' "$out"

pkgs="$(printf '%s\n' "$out" | grep -oE 'sdk/[A-Za-z0-9._-]+/[A-Za-z0-9._-]+' | sort -u || true)"
if [ -z "$pkgs" ]; then
echo "found=0" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "found=1" >> "$GITHUB_OUTPUT"

set -e
while IFS= read -r pkg_path; do
[ -z "$pkg_path" ] && continue
pkg_name="$(basename "$pkg_path")"
echo "::group::chronus add $pkg_name ($KIND)"
.github/chronus/node_modules/.bin/chronus add "$pkg_name" --kind "$KIND" --message "$MESSAGE"
echo "::endgroup::"
done <<< "$pkgs"

- name: Commit and push
id: push
if: steps.add.outputs.found == '1'
env:
HEAD_REF: ${{ steps.pr.outputs.head_ref }}
ACTOR: ${{ github.event.comment.user.login }}
ACTOR_ID: ${{ github.event.comment.user.id }}
PR_NUMBER: ${{ github.event.issue.number }}
GITHUB_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
git check-ref-format --branch "$HEAD_REF"

git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git add .chronus/changes/
if git diff --cached --quiet; then
echo "pushed=0" >> "$GITHUB_OUTPUT"
exit 0
fi

msg="$(printf 'Add chronus changelog entry [skip ci]\n\nTriggered by /chronus add comment on PR #%s.\n\nCo-authored-by: %s <%s+%s@users.noreply.github.com>' \
"$PR_NUMBER" "$ACTOR" "$ACTOR_ID" "$ACTOR")"
git commit -m "$msg"

if git push "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:refs/heads/$HEAD_REF"; then
echo "pushed=1" >> "$GITHUB_OUTPUT"
else
echo "pushed=0" >> "$GITHUB_OUTPUT"
echo "::warning::Push failed. The PR branch may have moved; ask the user to retry."
fi

- name: Reply with final result
if: always()
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
env:
KIND: ${{ steps.pr.outputs.kind }}
TITLE: ${{ steps.pr.outputs.title }}
FOUND: ${{ steps.add.outputs.found }}
PUSHED: ${{ steps.push.outputs.pushed }}
JOB_STATUS: ${{ job.status }}
with:
script: |
const issue_number = context.payload.issue.number;
const comment_id = context.payload.comment.id;
const react = (content) => github.rest.reactions.createForIssueComment({
owner: context.repo.owner, repo: context.repo.repo, comment_id, content,
});
const reply = (body) => github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo, issue_number, body,
});

if (process.env.JOB_STATUS !== 'success') {
await react('-1');
return;
}
if (process.env.FOUND !== '1') {
await reply([
'ℹ️ Could not detect any packages missing a Chronus change entry.',
'',
'Either every changed package already has an entry, or `chronus verify`',
'failed for an unrelated reason — check the workflow run for details.',
].join('\n'));
return;
}
if (process.env.PUSHED === '1') {
await react('rocket');
await reply([
'✅ Pushed a Chronus change entry to this PR.',
'',
'- **Kind:** `' + process.env.KIND + '`',
'- **Description:** _' + process.env.TITLE + '_',
'',
'Edit the file under `.chronus/changes/` if you\'d like to refine the description.',
].join('\n'));
return;
}
await react('confused');
await reply([
'⚠️ Could not push a Chronus entry to this PR branch.',
'',
'The branch may have moved while the bot was working. Please',
'comment `/chronus add` again, or run `azpysdk changelog add` locally.',
].join('\n'));
107 changes: 107 additions & 0 deletions .github/workflows/chronus-verify.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
name: Chronus Verify

on:
pull_request:
branches: [main]
paths:
- "sdk/*/*/**"

concurrency:
group: chronus-verify-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
chronus-verify:
name: Verify Chronus Change Descriptions
if: github.event.pull_request.user.login != 'azure-sdk'
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0 # needed so chronus can diff against base branch
persist-credentials: false

- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: lts/*
cache: npm
cache-dependency-path: .github/chronus/package-lock.json

- name: Install pinned dependencies
run: npm ci
working-directory: .github/chronus

- name: Run chronus verify
id: verify
run: .github/chronus/node_modules/.bin/chronus verify

# Sticky comment is only post-able when GITHUB_TOKEN has write scope —
# i.e. PRs from the main repo. Fork PRs see only the error annotation
# below, which is fine because /chronus add doesn't work for forks anyway.
- name: Post sticky one-click-fix PR comment on failure
if: failure() && steps.verify.conclusion == 'failure' && github.event.pull_request.head.repo.full_name == github.repository
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const HEADER = '<!-- chronus-verify-sticky -->';
const body = [
HEADER,
'### 📝 Missing changelog entry',
'',
'This PR touches package source under `sdk/*/*/**` but no Chronus',
'change description was found. CI requires every user-affecting',
'change to have one.',
'',
'#### ⚡ One-click fix',
'',
'**Comment `/chronus add` on this PR** and a bot will commit a',
'changelog entry for you, derived from your PR title.',
'',
'Customise the entry kind by appending it to the command:',
'',
'- `/chronus add` &nbsp;→&nbsp; defaults to `internal`',
'- `/chronus add fix` &nbsp;→&nbsp; bug fix',
'- `/chronus add feature` &nbsp;→&nbsp; new feature',
'- `/chronus add breaking` &nbsp;→&nbsp; breaking change',
'- `/chronus add deprecation` &nbsp;→&nbsp; deprecation',
'- `/chronus add dependencies` &nbsp;→&nbsp; dependency bump',
'',
'> ℹ️ For PRs from forks, run the command locally instead:',
'>',
'> ```bash',
'> azpysdk changelog add',
'> ```',
'',
'See [`doc/dev/changelog_updates.md`](https://github.com/Azure/azure-sdk-for-python/blob/main/doc/dev/changelog_updates.md) for full instructions.',
].join('\n');

const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
});
const existing = comments.find(c => c.body && c.body.startsWith(HEADER));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body,
});
}

- name: Emit annotation on failure
if: failure() && steps.verify.conclusion == 'failure'
run: |
echo "::error::Chronus verification failed. Comment '/chronus add' on this PR for an automated fix, or run 'azpysdk changelog add' locally."
echo "::error::See https://github.com/Azure/azure-sdk-for-python/blob/main/doc/dev/changelog_updates.md for instructions."
Loading
Loading