diff --git a/.github/workflows/chronus-fix.yml b/.github/workflows/chronus-fix.yml new file mode 100644 index 000000000000..d2af72438f0e --- /dev/null +++ b/.github/workflows/chronus-fix.yml @@ -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')); diff --git a/.github/workflows/chronus-verify.yml b/.github/workflows/chronus-verify.yml new file mode 100644 index 000000000000..7814f2c216dc --- /dev/null +++ b/.github/workflows/chronus-verify.yml @@ -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 = ''; + 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`  →  defaults to `internal`', + '- `/chronus add fix`  →  bug fix', + '- `/chronus add feature`  →  new feature', + '- `/chronus add breaking`  →  breaking change', + '- `/chronus add deprecation`  →  deprecation', + '- `/chronus add dependencies`  →  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." diff --git a/doc/dev/changelog_updates.md b/doc/dev/changelog_updates.md index c39d8d49e25e..f7daf8143ec5 100644 --- a/doc/dev/changelog_updates.md +++ b/doc/dev/changelog_updates.md @@ -10,21 +10,23 @@ The repository configuration lives in [`.chronus/config.yaml`](https://github.co ## Prerequisites -Chronus is distributed as an npm package. To use it, you need [Node.js](https://nodejs.org/) installed (LTS version recommended). You can then run Chronus without a global install using `npx`: +The recommended way to interact with Chronus is through the `azpysdk` CLI, which is already available in this repository's developer environment and handles installing Chronus automatically. + +If you prefer to invoke Chronus directly, it is distributed as an npm package and requires [Node.js](https://nodejs.org/) (LTS version recommended). You can run it without a global install using `npx`: ```bash npx chronus ``` -Alternatively, install it globally: +## Adding a Change Description + +When you make changes to a package that has a `pyproject.toml`, add a change description by running the following from the repository root or from within the package directory: ```bash -npm install -g @chronus/chronus +azpysdk changelog add ``` -## Adding a Change Description - -When you make changes to a package that has a `pyproject.toml`, run `chronus add` from the root of the repository: +Alternatively, using raw Chronus: ```bash npx chronus add @@ -55,7 +57,13 @@ The following change kinds are defined for this repository: ### Specifying a Package Directly -You can skip the interactive prompt by passing the package path(s) directly: +You can skip the interactive prompt by passing the package path and change details directly: + +```bash +azpysdk changelog add sdk/storage/azure-storage-blob --kind fix --message "Fixed upload failure on large files" +``` + +Or using raw Chronus: ```bash npx chronus add sdk/storage/azure-storage-blob @@ -65,9 +73,9 @@ npx chronus add sdk/storage/azure-storage-blob ```bash # After making changes to azure-storage-blob, add a change description -npx chronus add sdk/storage/azure-storage-blob +azpysdk changelog add sdk/storage/azure-storage-blob -# Chronus will prompt you: +# You will be prompted to select: # ? What kind of change is this? › fix # ? Describe the change: › Fixed an issue where upload would fail on large files ``` @@ -90,10 +98,20 @@ You commit this file along with your code changes. To check whether all modified packages have a corresponding change description (e.g., before opening a PR), run: +```bash +azpysdk changelog verify +``` + +Or using raw Chronus: + ```bash npx chronus verify ``` +> **Note:** The CI workflow (`Chronus Verify`) runs `chronus verify` automatically on every pull request that modifies source files under `sdk/` (specifically files matching `sdk/*/*/**`). If it fails, you have two options: +> +> - **One-click fix:** comment `/chronus add` on the PR (optionally followed by a kind, e.g. `/chronus add fix`). A bot will commit a Chronus entry derived from the PR title back to the branch. *Available for PRs from the main repository only — fork PRs should run the command locally.* +> - **Locally:** run `azpysdk changelog add` and push the resulting `.chronus/changes/*.md` file. If your changes don't need a changelog entry (e.g., pure documentation or test-only changes unrelated to package behavior), you can add an `internal` change kind entry to satisfy the requirement without bumping the version. @@ -101,6 +119,12 @@ If your changes don't need a changelog entry (e.g., pure documentation or test-o To see a summary of all pending changes and the resulting version bumps: +```bash +azpysdk changelog status +``` + +Or using raw Chronus: + ```bash npx chronus status ``` @@ -109,7 +133,7 @@ npx chronus status Packages in this repository that use `pyproject.toml` (instead of or alongside `setup.py`) are fully supported by Chronus. The `pyproject.toml` is used for package metadata, while the `CHANGELOG.md` in the package directory remains the canonical user-facing changelog. -Chronus reads the package version from the Python package metadata and writes changelog entries into the `CHANGELOG.md` file with `npx chronus changelog`. You do not need to manually edit `CHANGELOG.md` for your changes. +Chronus reads the package version from the Python package metadata and writes changelog entries into the `CHANGELOG.md` file with `azpysdk changelog create` (or `npx chronus changelog`). You do not need to manually edit `CHANGELOG.md` for your changes. ## Further Reading