diff --git a/.craft.yml b/.craft.yml new file mode 100644 index 00000000..6a25ffd3 --- /dev/null +++ b/.craft.yml @@ -0,0 +1,10 @@ +minVersion: 2.20.1 +changelogPolicy: auto +preReleaseCommand: pwsh -cwa '' +artifactProvider: + name: none +targets: + - name: github + floatingTags: + - 'v{major}' + - 'latest' diff --git a/.github/test-dangerfile.js b/.github/test-dangerfile.js new file mode 100644 index 00000000..b125452a --- /dev/null +++ b/.github/test-dangerfile.js @@ -0,0 +1,37 @@ +// Test dangerfile for exercising extra-dangerfile feature +// This demonstrates how repositories can add custom Danger checks + +module.exports = async function ({ fail, warn, message, markdown, danger }) { + console.log('::notice::Running custom dangerfile checks...'); + + // Test that we have access to the danger API + if (!danger || !danger.github || !danger.github.pr) { + fail('Custom dangerfile cannot access danger API'); + return; + } + + // Example check: Verify PR has a description + const prBody = danger.github.pr.body; + if (!prBody || prBody.trim().length === 0) { + warn('PR description is empty. Consider adding a description to help reviewers.'); + } else { + message('✅ Custom dangerfile check: PR has a description'); + } + + // Example check: Verify PR title is not too short + const prTitle = danger.github.pr.title; + if (prTitle && prTitle.length < 10) { + warn('PR title is quite short. Consider making it more descriptive.'); + } else { + message('✅ Custom dangerfile check: PR title length is reasonable'); + } + + // Show that we can access git information + const modifiedFiles = danger.git.modified_files || []; + const createdFiles = danger.git.created_files || []; + const totalChangedFiles = modifiedFiles.length + createdFiles.length; + + message(`📊 Custom check: This PR changes ${totalChangedFiles} file(s)`); + + console.log('::notice::Custom dangerfile checks completed successfully'); +}; diff --git a/.github/workflows/danger-workflow-tests.yml b/.github/workflows/danger-workflow-tests.yml index 0b27a3a1..1ed2def1 100644 --- a/.github/workflows/danger-workflow-tests.yml +++ b/.github/workflows/danger-workflow-tests.yml @@ -5,14 +5,99 @@ on: pull_request: types: [opened, synchronize, reopened, edited, ready_for_review] +permissions: + contents: read + pull-requests: write + statuses: write + jobs: - danger: - uses: ./.github/workflows/danger.yml - with: - _workflow_version: ${{ github.sha }} + # Test Danger action on pull requests - should analyze PR and report findings + pr-analysis: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Run danger action + id: danger + uses: ./danger + + - name: Validate danger outputs + env: + DANGER_OUTCOME: ${{ steps.danger.outputs.outcome }} + shell: pwsh + run: | + Write-Host "🔍 Validating Danger action outputs..." + Write-Host "Danger Outcome: '$env:DANGER_OUTCOME'" - test-outputs: + # Validate that Danger ran successfully + $env:DANGER_OUTCOME | Should -Be "success" + + Write-Host "✅ Danger PR analysis completed successfully!" + Write-Host "â„šī¸ Check the PR comments for any Danger findings" + + # Test extra-dangerfile feature + extra-dangerfile-test: runs-on: ubuntu-latest - needs: danger steps: - - run: "[[ '${{ needs.danger.outputs.outcome }}' == 'success' ]]" + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Run danger with extra dangerfile + id: danger-extra + uses: ./danger + with: + extra-dangerfile: '.github/test-dangerfile.js' + + - name: Validate danger with extra-dangerfile outputs + env: + DANGER_OUTCOME: ${{ steps.danger-extra.outputs.outcome }} + shell: pwsh + run: | + Write-Host "🔍 Validating Danger action with extra-dangerfile..." + Write-Host "Danger Outcome: '$env:DANGER_OUTCOME'" + + # Validate that Danger ran successfully + $env:DANGER_OUTCOME | Should -Be "success" + + Write-Host "✅ Danger with extra-dangerfile completed successfully!" + + # Test extra-install-packages feature + extra-packages-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + # Create a test dangerfile that requires curl + - name: Create test dangerfile requiring curl + shell: bash + run: | + cat > .github/test-dangerfile-curl.js << 'EOF' + module.exports = async function ({ message, danger }) { + const { execSync } = require('child_process'); + try { + const curlVersion = execSync('curl --version', { encoding: 'utf-8' }); + message('✅ curl is available: ' + curlVersion.split('\n')[0]); + } catch (err) { + throw new Error('curl command not found - extra-install-packages failed'); + } + }; + EOF + + - name: Run danger with extra packages + id: danger-packages + uses: ./danger + with: + extra-dangerfile: '.github/test-dangerfile-curl.js' + extra-install-packages: 'curl' + + - name: Validate danger with extra-install-packages outputs + env: + DANGER_OUTCOME: ${{ steps.danger-packages.outputs.outcome }} + shell: pwsh + run: | + Write-Host "🔍 Validating Danger action with extra-install-packages..." + Write-Host "Danger Outcome: '$env:DANGER_OUTCOME'" + + # Validate that Danger ran successfully + $env:DANGER_OUTCOME | Should -Be "success" + + Write-Host "✅ Danger with extra-install-packages completed successfully!" diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml deleted file mode 100644 index 0d3abb59..00000000 --- a/.github/workflows/danger.yml +++ /dev/null @@ -1,41 +0,0 @@ -# Runs DangerJS with a pre-configured set of rules on a Pull Request. -on: - workflow_call: - inputs: - _workflow_version: - description: 'Internal: specify github-workflows (this repo) revision to use when checking out scripts.' - type: string - required: false - default: v2 # Note: update when publishing a new version - outputs: - outcome: - description: Whether the Danger run finished successfully. Possible values are success, failure, cancelled, or skipped. - value: ${{ jobs.danger.outputs.outcome }} - -jobs: - danger: - runs-on: ubuntu-latest - outputs: - outcome: ${{ steps.danger.outcome }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Download dangerfile.js - run: wget https://raw.githubusercontent.com/getsentry/github-workflows/${{ inputs._workflow_version }}/danger/dangerfile.js -P ${{ runner.temp }} - - # Using a pre-built docker image in GitHub container registry instaed of NPM to reduce possible attack vectors. - - name: Run DangerJS - id: danger - run: | - docker run \ - --volume ${{ github.workspace }}:/github/workspace \ - --volume ${{ runner.temp }}:${{ runner.temp }} \ - --workdir /github/workspace \ - --user $UID \ - -e "INPUT_ARGS" -e "GITHUB_JOB" -e "GITHUB_REF" -e "GITHUB_SHA" -e "GITHUB_REPOSITORY" -e "GITHUB_REPOSITORY_OWNER" -e "GITHUB_RUN_ID" -e "GITHUB_RUN_NUMBER" -e "GITHUB_RETENTION_DAYS" -e "GITHUB_RUN_ATTEMPT" -e "GITHUB_ACTOR" -e "GITHUB_TRIGGERING_ACTOR" -e "GITHUB_WORKFLOW" -e "GITHUB_HEAD_REF" -e "GITHUB_BASE_REF" -e "GITHUB_EVENT_NAME" -e "GITHUB_SERVER_URL" -e "GITHUB_API_URL" -e "GITHUB_GRAPHQL_URL" -e "GITHUB_REF_NAME" -e "GITHUB_REF_PROTECTED" -e "GITHUB_REF_TYPE" -e "GITHUB_WORKSPACE" -e "GITHUB_ACTION" -e "GITHUB_EVENT_PATH" -e "GITHUB_ACTION_REPOSITORY" -e "GITHUB_ACTION_REF" -e "GITHUB_PATH" -e "GITHUB_ENV" -e "GITHUB_STEP_SUMMARY" -e "RUNNER_OS" -e "RUNNER_ARCH" -e "RUNNER_NAME" -e "RUNNER_TOOL_CACHE" -e "RUNNER_TEMP" -e "RUNNER_WORKSPACE" -e "ACTIONS_RUNTIME_URL" -e "ACTIONS_RUNTIME_TOKEN" -e "ACTIONS_CACHE_URL" -e GITHUB_ACTIONS=true -e CI=true \ - -e GITHUB_TOKEN="${{ github.token }}" \ - -e DANGER_DISABLE_TRANSPILATION="true" \ - ghcr.io/danger/danger-js:11.3.1 \ - --failOnErrors --dangerfile ${{ runner.temp }}/dangerfile.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..5239ad3f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,39 @@ +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to release (automatically inferred from commits if not provided)' + required: false + force: + description: Force a release even when there are release-blockers (optional) + required: false + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + name: 'Release a new version' + steps: + - name: Get auth token + id: token + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 + with: + app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} + private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} + + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + token: ${{ steps.token.outputs.token }} + fetch-depth: 0 + + - name: Prepare release + uses: getsentry/craft@bae212ca7aec50bb716eafd387c80bcfb28da937 # 2.26.3 + env: + GITHUB_TOKEN: ${{ steps.token.outputs.token }} + with: + version: ${{ inputs.version || 'auto' }} + force: ${{ inputs.force || 'false' }} diff --git a/.github/workflows/script-tests.yml b/.github/workflows/script-tests.yml index 3a6f16ef..14207001 100644 --- a/.github/workflows/script-tests.yml +++ b/.github/workflows/script-tests.yml @@ -1,5 +1,7 @@ # This isn't a reusable workflow but a CI action for this repo itself - testing the contained workflows & scripts. name: Script Tests +permissions: + contents: read on: push: @@ -18,8 +20,28 @@ jobs: steps: - run: git config --global core.autocrlf false - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - run: Invoke-Pester working-directory: updater shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + + danger: + name: Danger JS Tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: danger + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '18' + + - run: node --test + + - name: Check syntax + run: node -c dangerfile.js diff --git a/.github/workflows/update-deps.yml b/.github/workflows/update-deps.yml new file mode 100644 index 00000000..72df7690 --- /dev/null +++ b/.github/workflows/update-deps.yml @@ -0,0 +1,21 @@ +name: Update dependencies + +on: + workflow_dispatch: + schedule: + # Run weekly on Mondays at 8:00 UTC + - cron: '0 8 * * 1' + +permissions: + contents: write + pull-requests: write + +jobs: + danger: + runs-on: ubuntu-latest + steps: + - uses: getsentry/github-workflows/updater@669decb15baad1ee9928c4704da7ea5fcc772a57 # main + with: + path: danger/danger.properties + name: Danger JS + api-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/updater.yml b/.github/workflows/updater.yml deleted file mode 100644 index 7b70200c..00000000 --- a/.github/workflows/updater.yml +++ /dev/null @@ -1,258 +0,0 @@ -# Allows updating dependencies to the latest published tag -on: - workflow_call: - inputs: - path: - description: Dependency path in the source repository, this can be either a submodule, a .properties file or a shell script. - type: string - required: true - name: - description: Name used in the PR title and the changelog entry. - type: string - required: true - pattern: - description: RegEx pattern that will be matched against available versions when picking the latest one. - type: string - required: false - default: '' - changelog-entry: - description: Whether to add a changelog entry for the update. - type: boolean - required: false - default: true - changelog-section: - description: Section header to attach the changelog entry to. - type: string - required: false - default: Dependencies - runs-on: - description: GitHub Actions virtual environment name to run the udpater job on. - type: string - required: false - default: 'ubuntu-latest' - pr-strategy: - description: | - How to handle PRs - can be either of the following: - * create - create a new PR for new dependency versions as they are released - maintainers may merge or close older PRs manually - * update - keep a single PR that gets updated with new dependency versions until merged - only the latest version update is available at any time - type: string - required: false - default: create - _workflow_version: - description: 'Internal: specify github-workflows (this repo) revision to use when checking out scripts.' - type: string - required: false - default: v2 # Note: update when publishing a new version - secrets: - api-token: - required: true - outputs: - prUrl: - description: 'The created/updated PRs url.' - value: ${{ jobs.update.outputs.prUrl }} - baseBranch: - description: 'The base branch name.' - value: ${{ jobs.update.outputs.baseBranch }} - prBranch: - description: 'The created/updated pr branch name.' - value: ${{ jobs.update.outputs.prBranch }} - originalTag: - description: 'The original tag from which the dependency was updated from.' - value: ${{ jobs.update.outputs.originalTag }} - latestTag: - description: 'The latest tag to which the dependency was updated to.' - value: ${{ jobs.update.outputs.latestTag }} - -jobs: - cancel-previous-run: - runs-on: ubuntu-latest - steps: - - name: Cancel Previous Runs - uses: styfle/cancel-workflow-action@85880fa0301c86cca9da44039ee3bb12d3bedbfa # Tag: 0.12.1 - with: - access_token: ${{ github.token }} - - # What we need to accomplish: - # * update to the latest tag - # * create a PR - # * update changelog (including the link to the just created PR) - # - # What we actually do is based on whether a PR exists already: - # * YES it does: - # * make the update - # * update changelog (with the ID of an existing PR) - # * push to the PR - # * NO it doesn't: - # * make the update - # * push to a new PR - # * update changelog (with the ID of the just created PR) - # * push to the PR - # We do different approach on subsequent runs because otherwise we would spam users' mailboxes - # with notifications about pushes to existing PRs. This way there is actually no push if not needed. - update: - runs-on: ${{ inputs.runs-on }} - # Map the job outputs to step outputs - outputs: - prUrl: ${{ steps.pr.outputs.url }} - baseBranch: ${{ steps.root.outputs.baseBranch }} - prBranch: ${{ steps.root.outputs.prBranch }} - originalTag: ${{ steps.target.outputs.originalTag }} - latestTag: ${{ steps.target.outputs.latestTag }} - timeout-minutes: 30 - defaults: - run: - shell: pwsh - steps: - - uses: actions/checkout@v4 - with: - ssh-key: ${{ secrets.api-token }} - - # In order to run scripts from this repo, we need to check it out manually, doesn't seem available locally. - - name: Check out workflow scripts - # Note: cannot use `actions/checkout` at the moment because you can't clone outside of the repo root. - # Follow https://github.com/actions/checkout/issues/197 - run: | - mkdir -p ${{ runner.temp }}/ghwf - cd ${{ runner.temp }}/ghwf - git init - git remote add origin https://github.com/getsentry/github-workflows.git - git fetch --depth 1 origin ${{ inputs._workflow_version }} - git checkout FETCH_HEAD - - - name: Update to the latest version - id: target - run: ${{ runner.temp }}/ghwf/updater/scripts/update-dependency.ps1 -Path '${{ inputs.path }}' -Pattern '${{ inputs.pattern }}' - - - name: Get the base repo info - if: steps.target.outputs.latestTag != steps.target.outputs.originalTag - id: root - run: | - $mainBranch = $(git remote show origin | Select-String "HEAD branch: (.*)").Matches[0].Groups[1].Value - $prBranch = switch ('${{ inputs.pr-strategy }}') - { - 'create' { 'deps/${{ inputs.path }}/${{ steps.target.outputs.latestTag }}' } - 'update' { 'deps/${{ inputs.path }}' } - default { throw "Unkown PR strategy '${{ inputs.pr-strategy }}'." } - } - "baseBranch=$mainBranch" | Tee-Object $env:GITHUB_OUTPUT -Append - "prBranch=$prBranch" | Tee-Object $env:GITHUB_OUTPUT -Append - $nonBotCommits = ${{ runner.temp }}/ghwf/updater/scripts/nonbot-commits.ps1 ` - -RepoUrl "$(git config --get remote.origin.url)" -PrBranch $prBranch -MainBranch $mainBranch - $changed = $nonBotCommits.Length -gt 0 ? 'true' : 'false' - "changed=$changed" | Tee-Object $env:GITHUB_OUTPUT -Append - if ("$changed" -eq "true") - { - Write-Output "::warning::Target branch '$prBranch' has been changed manually - skipping updater to avoid overwriting these changes." - } - - - name: Parse the existing PR URL - if: ${{ ( steps.target.outputs.latestTag != steps.target.outputs.originalTag ) && ( steps.root.outputs.changed == 'false') }} - id: existing-pr - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - $urls = @(gh api 'repos/${{ github.repository }}/pulls?base=${{ steps.root.outputs.baseBranch }}&head=${{ github.repository_owner }}:${{ steps.root.outputs.prBranch }}' --jq '.[].html_url') - if ($urls.Length -eq 0) - { - "url=" | Tee-Object $env:GITHUB_OUTPUT -Append - } - elseif ($urls.Length -eq 1) - { - "url=$($urls[0])" | Tee-Object $env:GITHUB_OUTPUT -Append - } - else - { - throw "Unexpected number of PRs matched ($($urls.Length)): $urls" - } - - - run: git --no-pager diff - if: ${{ ( steps.target.outputs.latestTag != steps.target.outputs.originalTag ) && ( steps.existing-pr.outputs.url == '') && ( steps.root.outputs.changed == 'false') }} - - - name: Get target changelog - if: ${{ ( steps.target.outputs.latestTag != steps.target.outputs.originalTag ) && ( steps.root.outputs.changed == 'false') }} - run: | - $changelog = ${{ runner.temp }}/ghwf/updater/scripts/get-changelog.ps1 ` - -RepoUrl '${{ steps.target.outputs.url }}' ` - -OldTag '${{ steps.target.outputs.originalTag }}' ` - -NewTag '${{ steps.target.outputs.latestTag }}' - ${{ runner.temp }}/ghwf/updater/scripts/set-github-env.ps1 TARGET_CHANGELOG $changelog - - # First we create a PR only if it doesn't exist. We will later overwrite the content with the same action. - - name: Create a PR - if: ${{ ( steps.target.outputs.latestTag != steps.target.outputs.originalTag ) && ( steps.existing-pr.outputs.url == '') && ( steps.root.outputs.changed == 'false') }} - uses: peter-evans/create-pull-request@a4f52f8033a6168103c2538976c07b467e8163bc # pin#v6.0.1 - id: create-pr - with: - base: ${{ steps.root.outputs.baseBranch }} - branch: ${{ steps.root.outputs.prBranch }} - commit-message: 'chore: update ${{ inputs.path }} to ${{ steps.target.outputs.latestTag }}' - author: 'GitHub ' - title: 'chore(deps): update ${{ inputs.name }} to ${{ steps.target.outputs.latestTagNice }}' - body: | - Bumps ${{ inputs.path }} from ${{ steps.target.outputs.originalTag }} to ${{ steps.target.outputs.latestTag }}. - - Auto-generated by a [dependency updater](https://github.com/getsentry/github-workflows/blob/main/.github/workflows/updater.yml). - ${{ env.TARGET_CHANGELOG }} - labels: dependencies - # draft: true - - - name: Verify we have a PR - if: ${{ ( steps.target.outputs.latestTag != steps.target.outputs.originalTag ) && ( steps.root.outputs.changed == 'false') }} - id: pr - run: | - if ('${{ steps.create-pr.outputs.pull-request-url }}' -ne '') - { - "url=${{ steps.create-pr.outputs.pull-request-url }}" | Tee-Object $env:GITHUB_OUTPUT -Append - } - elseif ('${{ steps.existing-pr.outputs.url }}' -ne '') - { - "url=${{ steps.existing-pr.outputs.url }}" | Tee-Object $env:GITHUB_OUTPUT -Append - } - else - { - throw "PR hasn't been created" - } - - # If we had to create a new PR, we must do a clean checkout & update the submodule again. - # If we didn't do this, the new PR would only have a changelog... - - name: 'After new PR: restore repo' - if: ${{ ( steps.target.outputs.latestTag != steps.target.outputs.originalTag ) && ( steps.existing-pr.outputs.url == '') && ( steps.root.outputs.changed == 'false') }} - uses: actions/checkout@v4 - with: - ssh-key: ${{ secrets.api-token }} - - - name: 'After new PR: redo the update' - if: ${{ ( steps.target.outputs.latestTag != steps.target.outputs.originalTag ) && ( steps.existing-pr.outputs.url == '') && ( steps.root.outputs.changed == 'false') }} - run: ${{ runner.temp }}/ghwf/updater/scripts/update-dependency.ps1 -Path '${{ inputs.path }}' -Tag '${{ steps.target.outputs.latestTag }}' - - - name: Update Changelog - if: ${{ inputs.changelog-entry && ( steps.target.outputs.latestTag != steps.target.outputs.originalTag ) && ( steps.root.outputs.changed == 'false') }} - run: | - ${{ runner.temp }}/ghwf/updater/scripts/update-changelog.ps1 ` - -Name '${{ inputs.name }}' ` - -PR '${{ steps.pr.outputs.url }}' ` - -RepoUrl '${{ steps.target.outputs.url }}' ` - -MainBranch '${{ steps.target.outputs.mainBranch }}' ` - -OldTag '${{ steps.target.outputs.originalTag }}' ` - -NewTag '${{ steps.target.outputs.latestTag }}' ` - -Section '${{ inputs.changelog-section }}' - - - run: git --no-pager diff - if: ${{ ( steps.target.outputs.latestTag != steps.target.outputs.originalTag ) && ( steps.root.outputs.changed == 'false') }} - - # Now make the PR in its final state. This way we only have one commit and no updates if there are no changes between runs. - - name: Update the PR - if: ${{ ( steps.target.outputs.latestTag != steps.target.outputs.originalTag ) && ( steps.root.outputs.changed == 'false') }} - uses: peter-evans/create-pull-request@a4f52f8033a6168103c2538976c07b467e8163bc # pin#v6.0.1 - with: - base: ${{ steps.root.outputs.baseBranch }} - branch: ${{ steps.root.outputs.prBranch }} - commit-message: 'chore: update ${{ inputs.path }} to ${{ steps.target.outputs.latestTag }}' - author: 'GitHub ' - title: 'chore(deps): update ${{ inputs.name }} to ${{ steps.target.outputs.latestTagNice }}' - body: | - Bumps ${{ inputs.path }} from ${{ steps.target.outputs.originalTag }} to ${{ steps.target.outputs.latestTag }}. - - Auto-generated by a [dependency updater](https://github.com/getsentry/github-workflows/blob/main/.github/workflows/updater.yml). - ${{ env.TARGET_CHANGELOG }} - labels: dependencies diff --git a/.github/workflows/versioning.yml b/.github/workflows/versioning.yml deleted file mode 100644 index 9f26dbce..00000000 --- a/.github/workflows/versioning.yml +++ /dev/null @@ -1,13 +0,0 @@ -name: Sync tags with releases - -on: - release: - types: [published, edited] - -jobs: - actions-tagger: - runs-on: ubuntu-latest - steps: - - uses: Actions-R-Us/actions-tagger@f411bd910a5ad370d4511517e3eac7ff887c90ea # v2.0.2 - with: - publish_latest_tag: true diff --git a/.github/workflows/workflow-tests.yml b/.github/workflows/workflow-tests.yml index 5867c330..b737ba18 100644 --- a/.github/workflows/workflow-tests.yml +++ b/.github/workflows/workflow-tests.yml @@ -4,46 +4,158 @@ name: Workflow Tests on: push: +permissions: + contents: write + pull-requests: write + actions: write + jobs: - updater-create-pr: - uses: ./.github/workflows/updater.yml - with: - path: updater/tests/sentry-cli.properties - name: WORKFLOW-TEST-DEPENDENCY-DO-NOT-MERGE - pattern: '^2\.0\.' - pr-strategy: update - _workflow_version: ${{ github.sha }} - secrets: - api-token: ${{ github.token }} - - updater-test-args: - uses: ./.github/workflows/updater.yml - with: - path: updater/tests/workflow-args.sh - name: Workflow args test script - runs-on: macos-latest - pattern: '.*' - _workflow_version: ${{ github.sha }} - secrets: - api-token: ${{ github.token }} - - updater-test-outputs: + # Test PR creation scenario - should create a PR with specific version pattern + updater-pr-creation: runs-on: ubuntu-latest - needs: - - updater-create-pr - - updater-test-args steps: - - run: "[[ '${{ needs.updater-create-pr.outputs.baseBranch }}' == 'main' ]]" - - run: "[[ '${{ needs.updater-create-pr.outputs.originalTag }}' == '2.0.0' ]]" - - run: "[[ '${{ needs.updater-create-pr.outputs.latestTag }}' =~ ^[0-9.]+$ ]]" - - run: "[[ '${{ needs.updater-create-pr.outputs.prUrl }}' =~ ^https://github.com/getsentry/github-workflows/pull/[0-9]+$ ]]" - - run: "[[ '${{ needs.updater-create-pr.outputs.prBranch }}' == 'deps/updater/tests/sentry-cli.properties' ]]" - - - run: "[[ '${{ needs.updater-test-args.outputs.baseBranch }}' == '' ]]" - - run: "[[ '${{ needs.updater-test-args.outputs.originalTag }}' == 'latest' ]]" - - run: "[[ '${{ needs.updater-test-args.outputs.latestTag }}' == 'latest' ]]" - - run: "[[ '${{ needs.updater-test-args.outputs.prUrl }}' == '' ]]" - - run: "[[ '${{ needs.updater-test-args.outputs.prBranch }}' == '' ]]" + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Run updater action + id: updater + uses: ./updater + with: + path: updater/tests/sentry-cli.properties + name: WORKFLOW-TEST-DEPENDENCY-DO-NOT-MERGE + pattern: '^2\.0\.' + pr-strategy: update + api-token: ${{ github.token }} + + - name: Validate PR creation outputs + env: + BASE_BRANCH: ${{ steps.updater.outputs.baseBranch }} + ORIGINAL_TAG: ${{ steps.updater.outputs.originalTag }} + LATEST_TAG: ${{ steps.updater.outputs.latestTag }} + PR_URL: ${{ steps.updater.outputs.prUrl }} + PR_BRANCH: ${{ steps.updater.outputs.prBranch }} + shell: pwsh + run: | + Write-Host "🔍 Validating PR creation scenario outputs..." + Write-Host "Base Branch: '$env:BASE_BRANCH'" + Write-Host "Original Tag: '$env:ORIGINAL_TAG'" + Write-Host "Latest Tag: '$env:LATEST_TAG'" + Write-Host "PR URL: '$env:PR_URL'" + Write-Host "PR Branch: '$env:PR_BRANCH'" + + # Validate base branch is main + $env:BASE_BRANCH | Should -Be "main" + + # Validate original tag is expected test value + $env:ORIGINAL_TAG | Should -Be "2.0.0" + + # Validate latest tag is a valid version + $env:LATEST_TAG | Should -Match "^[0-9]+\.[0-9]+\.[0-9]+$" + + # Validate PR URL format + $env:PR_URL | Should -Match "^https://github\.com/getsentry/github-workflows/pull/[0-9]+$" + + # Validate PR branch format + $env:PR_BRANCH | Should -Be "deps/updater/tests/sentry-cli.properties" + + Write-Host "✅ PR creation scenario validation passed!" + + # Test target-branch functionality - should use specified branch as base + updater-target-branch: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Run updater action with target-branch + id: updater + uses: ./updater + with: + path: updater/tests/sentry-cli.properties + name: TARGET-BRANCH-TEST-DO-NOT-MERGE + pattern: '^2\.0\.' + target-branch: test/nonbot-commits + pr-strategy: update + api-token: ${{ github.token }} + + - name: Validate target-branch outputs + env: + BASE_BRANCH: ${{ steps.updater.outputs.baseBranch }} + ORIGINAL_TAG: ${{ steps.updater.outputs.originalTag }} + LATEST_TAG: ${{ steps.updater.outputs.latestTag }} + PR_URL: ${{ steps.updater.outputs.prUrl }} + PR_BRANCH: ${{ steps.updater.outputs.prBranch }} + shell: pwsh + run: | + Write-Host "🔍 Validating target-branch scenario outputs..." + Write-Host "Base Branch: '$env:BASE_BRANCH'" + Write-Host "Original Tag: '$env:ORIGINAL_TAG'" + Write-Host "Latest Tag: '$env:LATEST_TAG'" + Write-Host "PR URL: '$env:PR_URL'" + Write-Host "PR Branch: '$env:PR_BRANCH'" + + # Validate base branch is the specified target-branch + $env:BASE_BRANCH | Should -Be "test/nonbot-commits" + + # Validate original tag is expected test value + $env:ORIGINAL_TAG | Should -Be "2.0.0" + + # Validate latest tag is a valid version + $env:LATEST_TAG | Should -Match "^[0-9]+\.[0-9]+\.[0-9]+$" + + # Validate PR URL format + $env:PR_URL | Should -Match "^https://github\.com/getsentry/github-workflows/pull/[0-9]+$" + + # Validate PR branch format + $env:PR_BRANCH | Should -Be "test/nonbot-commits-deps/updater/tests/sentry-cli.properties" + + Write-Host "✅ Target-branch scenario validation passed!" + + # Test no-change scenario - should detect no updates needed + updater-no-changes: + runs-on: macos-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Run updater action + id: updater + uses: ./updater + with: + path: updater/tests/workflow-args.sh + name: Workflow args test script + pattern: '.*' + api-token: ${{ github.token }} + + - name: Validate no-changes outputs + env: + BASE_BRANCH: ${{ steps.updater.outputs.baseBranch }} + ORIGINAL_TAG: ${{ steps.updater.outputs.originalTag }} + LATEST_TAG: ${{ steps.updater.outputs.latestTag }} + PR_URL: ${{ steps.updater.outputs.prUrl }} + PR_BRANCH: ${{ steps.updater.outputs.prBranch }} + shell: pwsh + run: | + Write-Host "🔍 Validating no-changes scenario outputs..." + Write-Host "Base Branch: '$env:BASE_BRANCH'" + Write-Host "Original Tag: '$env:ORIGINAL_TAG'" + Write-Host "Latest Tag: '$env:LATEST_TAG'" + Write-Host "PR URL: '$env:PR_URL'" + Write-Host "PR Branch: '$env:PR_BRANCH'" + + # Validate no PR was created (empty values) + $env:BASE_BRANCH | Should -BeNullOrEmpty + + $env:PR_URL | Should -BeNullOrEmpty + + $env:PR_BRANCH | Should -BeNullOrEmpty + + # Validate original equals latest (no update) + $env:ORIGINAL_TAG | Should -Be $env:LATEST_TAG + + # Validate tag format (should be 'latest' or valid version) + if ($env:ORIGINAL_TAG -ne "latest") { + $env:ORIGINAL_TAG | Should -Match "^v?[0-9]+\.[0-9]+\.[0-9]+$" + } + + Write-Host "✅ No-changes scenario validation passed!" cli-integration: runs-on: ${{ matrix.host }}-latest @@ -55,7 +167,7 @@ jobs: - macos - windows steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: ./sentry-cli/integration-test/ with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b470d77..6d5c4b5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,171 @@ # Changelog +## 3.4.0 + +### Features + +- Validate PR - Action is advisory: it posts a single friendly comment on community PRs that don't reference an issue with maintainer discussion. PRs are not closed and no labels are applied. Recommended trigger is `types: [opened]`. +- Validate PR - Skip validation for PRs with fewer than 100 lines changed, excluding common lock files (`Cargo.lock`, `yarn.lock`, `package-lock.json`, `Pipfile.lock`, etc.). Tiny PRs no longer go through the issue-discussion loop. +- Add validate-pr composite action for validating non-maintainer PRs against contribution guidelines ([#153](https://github.com/getsentry/github-workflows/pull/153)) + +### Fixes + +- Complete script injection hardening across all actions: move remaining step outputs to env vars, validate Danger version against semver ([#152](https://github.com/getsentry/github-workflows/pull/152)) +- Updater - Trigger CI for new PRs without changelog updates ([#166](https://github.com/getsentry/github-workflows/pull/166)) +- Updater - Select the first branch when multiple branches point at `HEAD` ([#165](https://github.com/getsentry/github-workflows/pull/165)) + +### Dependencies + +- Bump Danger JS from v13.0.4 to v13.0.5 ([#160](https://github.com/getsentry/github-workflows/pull/160)) + - [changelog](https://github.com/danger/danger-js/blob/main/CHANGELOG.md#1305) + - [diff](https://github.com/danger/danger-js/compare/13.0.4...13.0.5) + +## 3.3.0 + +### Features + +- Updater - Support CMake `GIT_TAG` with variable references like `${FOO_REF}`, resolving and updating the corresponding `set()` definition ([#149](https://github.com/getsentry/github-workflows/pull/149)) + +## 3.2.1 + +### Fixes + +- Sentry-CLI integration test action - Accept chunked ProGuard uploads for compatibility with Sentry CLI 3.x ([#140](https://github.com/getsentry/github-workflows/pull/140)) + +## 3.2.0 + +### Features + +- Danger - Add support for repository-specific dangerfiles ([#129](https://github.com/getsentry/github-workflows/pull/129)) + - Add `extra-dangerfile` input parameter to run custom Danger checks alongside shared workflow checks + - Add `extra-install-packages` input to install additional apt packages required by custom dangerfiles + - Custom dangerfiles receive full Danger API access (`fail`, `warn`, `message`, `markdown`, `danger`) + - Enables repositories to extend Danger checks without overwriting shared workflow comments +- Sentry-CLI integration test action - Add `InvokeSentryResult::Events()` method to extract events from envelopes ([#137](https://github.com/getsentry/github-workflows/pull/137)) + +### Fixes + +- Sentry-CLI integration test action - Replace literal "\n" with newlines ([#138](https://github.com/getsentry/github-workflows/pull/138)) + +## 3.1.0 + +### Features + +- Updater - Add `post-update-script` input parameter to run custom scripts after dependency updates ([#130](https://github.com/getsentry/github-workflows/pull/130), [#133](https://github.com/getsentry/github-workflows/pull/133)) + - Scripts receive original and new version as arguments + - Support both bash (`.sh`) and PowerShell (`.ps1`) scripts + - Enables workflows like updating lock files, running code generators, or modifying configuration files +- Updater - Add SSH key support and comprehensive authentication validation ([#134](https://github.com/getsentry/github-workflows/pull/134)) + - Add `ssh-key` input parameter for deploy key authentication + - Support using both `ssh-key` (for git) and `api-token` (for GitHub API) together + - Add detailed token validation with actionable error messages + - Detect common token issues: expiration, whitespace, SSH keys in wrong input, missing scopes + - Validate SSH key format when provided + +### Fixes + +- Updater - Fix boolean input handling for `changelog-entry` parameter and add input validation ([#127](https://github.com/getsentry/github-workflows/pull/127)) +- Updater - Fix cryptic authentication errors with better validation and error messages ([#134](https://github.com/getsentry/github-workflows/pull/134), closes [#128](https://github.com/getsentry/github-workflows/issues/128)) + +### Dependencies + +- Bump Danger JS from v11.3.1 to v13.0.4 ([#132](https://github.com/getsentry/github-workflows/pull/132)) + - [changelog](https://github.com/danger/danger-js/blob/main/CHANGELOG.md#1304) + - [diff](https://github.com/danger/danger-js/compare/11.3.1...13.0.4) + +## 3.0.0 + +### Breaking Changes + +- Updater: The default value for `pr-strategy` has been changed from `create` to `update`. ([#124](https://github.com/getsentry/github-workflows/pull/124)) + This change means the updater will now maintain a single PR that gets updated with new dependency versions (instead of creating separate PRs for each version). + If you want to preserve the previous behavior of creating separate PRs, explicitly set `pr-strategy: create` in your workflow: + + ```yaml + - uses: getsentry/github-workflows/updater@v3 + with: + # ... other inputs ... + pr-strategy: create # Add this to preserve previous behavior + ``` + + In case you have existing open PRs created with the `create` strategy, you will need to remove these old branches + manually as the new name would be a prefix of the old PRs, which git doesnt' allow. + +- Updater and Danger reusable workflows are now composite actions ([#114](https://github.com/getsentry/github-workflows/pull/114)) + + To update your existing Updater workflows: + + ```yaml + ### Before + native: + uses: getsentry/github-workflows/.github/workflows/updater.yml@v2 + with: + path: scripts/update-sentry-native-ndk.sh + name: Native SDK + secrets: + # If a custom token is used instead, a CI would be triggered on a created PR. + api-token: ${{ secrets.CI_DEPLOY_KEY }} + + ### After (v3.0) + native: + runs-on: ubuntu-latest + steps: + - uses: getsentry/github-workflows/updater@v3 + with: + path: scripts/update-sentry-native-ndk.sh + name: Native SDK + api-token: ${{ secrets.CI_DEPLOY_KEY }} + ``` + + **Note**: If you were using SSH deploy keys with the v2 reusable workflow, the v3.0 composite action initially only supported tokens. + SSH key support was restored in v3.1 ([#134](https://github.com/getsentry/github-workflows/pull/134)). To use SSH keys, update to v3.1+ and use the `ssh-key` input: + + ```yaml + ### With SSH key (v3.1+) + native: + runs-on: ubuntu-latest + steps: + - uses: getsentry/github-workflows/updater@v3 + with: + path: scripts/update-sentry-native-ndk.sh + name: Native SDK + ssh-key: ${{ secrets.CI_DEPLOY_KEY }} + ``` + + To update your existing Danger workflows: + + ```yaml + ### Before + danger: + uses: getsentry/github-workflows/.github/workflows/danger.yml@v2 + + ### After + danger: + runs-on: ubuntu-latest + steps: + - uses: getsentry/github-workflows/danger@v3 + ``` + +### Features + +- Updater now supports targeting non-default branches via the new `target-branch` input parameter ([#118](https://github.com/getsentry/github-workflows/pull/118)) +- Updater now supports filtering releases by GitHub release title patterns, e.g. to support release channels ([#117](https://github.com/getsentry/github-workflows/pull/117)) +- Updater now supports dependencies without changelog files by falling back to git commit messages ([#116](https://github.com/getsentry/github-workflows/pull/116)) +- Danger - Improve conventional commit scope handling, and non-conventional PR title support ([#105](https://github.com/getsentry/github-workflows/pull/105)) +- Add Proguard artifact endpoint for Android builds in sentry-server ([#100](https://github.com/getsentry/github-workflows/pull/100)) +- Updater - Add CMake FetchContent support for automated dependency updates ([#104](https://github.com/getsentry/github-workflows/pull/104)) + +### Security + +- Updater - Prevent script injection vulnerabilities through workflow inputs ([#98](https://github.com/getsentry/github-workflows/pull/98)) + +### Fixes + +- Updater - Fix null reference error when changelog has no existing bullet points ([#125](https://github.com/getsentry/github-workflows/pull/125)) +- Updater - Fix bullet-point resolution when plain text precedes bullet points ([#123](https://github.com/getsentry/github-workflows/pull/123)) +- Improve changelog generation for non-tagged commits and edge cases ([#115](https://github.com/getsentry/github-workflows/pull/115)) +- Use GITHUB_WORKFLOW_REF instead of _workflow_version input parameter to automatically determine workflow script versions ([#109](https://github.com/getsentry/github-workflows/pull/109)) + ## 2.13.1 ### Fixes diff --git a/README.md b/README.md index 48d4e1bc..48f7a559 100644 --- a/README.md +++ b/README.md @@ -1,100 +1,29 @@ -# Workflows +# GitHub Workflows -This repository contains reusable workflows and scripts to be used with GitHub Actions. +This repository contains composite actions and scripts to be used with GitHub Actions. -## Updater +## Composite Actions -Dependency updater - see [updater.yml](.github/workflows/updater.yml) - updates dependencies to the latest published git tag. +### Updater -### Example workflow definition +Dependency updater - updates dependencies to the latest published git tag and creates/updates PRs. -```yaml -name: Update Dependencies -on: - # Run every day. - schedule: - - cron: '0 3 * * *' - # And on on every PR merge so we get the updated dependencies ASAP, and to make sure the changelog doesn't conflict. - push: - branches: - - main -jobs: - # Update a git submodule - cocoa: - uses: getsentry/github-workflows/.github/workflows/updater.yml@v2 - with: - path: modules/sentry-cocoa - name: Cocoa SDK - pattern: '^1\.' # Limit to major version '1' - secrets: - api-token: ${{ secrets.CI_DEPLOY_KEY }} +**[📖 View full documentation →](updater/README.md)** - # Update a properties file - cli: - uses: getsentry/github-workflows/.github/workflows/updater.yml@v2 - with: - path: sentry-cli.properties - name: CLI - secrets: - api-token: ${{ secrets.CI_DEPLOY_KEY }} +### Danger - # Update using a custom shell script, see updater/scripts/update-dependency.ps1 for the required arguments - agp: - uses: getsentry/github-workflows/.github/workflows/updater.yml@v2 - with: - path: script.ps1 - name: Gradle Plugin - secrets: - api-token: ${{ secrets.CI_DEPLOY_KEY }} -``` +Runs DangerJS on Pull Requests with a pre-configured set of rules. -### Inputs +**[📖 View full documentation →](danger/README.md)** -* `path`: Dependency path in the source repository, this can be either a submodule, a .properties file or a shell script. - * type: string - * required: true -* `name`: Name used in the PR title and the changelog entry. - * type: string - * required: true -* `pattern`: RegEx pattern that will be matched against available versions when picking the latest one. - * type: string - * required: false - * default: '' -* `changelog-entry`: Whether to add a changelog entry for the update. - * type: boolean - * required: false - * default: true -* `changelog-section`: Section header to attach the changelog entry to. - * type: string - * required: false - * default: Dependencies -* `runs-on`: GitHub Actions virtual environment name to run the udpater job on. - * type: string - * required: false - * default: ubuntu-latest -* `pr-strategy`: How to handle PRs. - Can be either of the following: - * `create` (default) - create a new PR for new dependency versions as they are released - maintainers may merge or close older PRs manually - * `update` - keep a single PR that gets updated with new dependency versions until merged - only the latest version update is available at any time +### Validate PR -### Secrets +Validates non-maintainer PRs against contribution guidelines and enforces draft status. -* `api-token`: GH authentication token to create PRs with & push. - If you provide the usual `${{github.token}}`, no followup CI will run on the created PR. - If you want CI to run on the PRs created by the Updater, you need to provide custom user-specific auth token. +**[📖 View full documentation →](validate-pr/README.md)** -## Danger +## Legacy Reusable Workflows (v2) -Runs DangerJS on Pull Reqeusts in your repository. This uses custom set of rules defined in [this dangerfile](danger/dangerfile.js). +> âš ī¸ **Deprecated**: Reusable workflows have been converted to composite actions in v3. Please migrate to the composite actions above. -```yaml -name: Danger - -on: - pull_request: - types: [opened, synchronize, reopened, edited, ready_for_review, labeled, unlabeled] - -jobs: - danger: - uses: getsentry/github-workflows/.github/workflows/danger.yml@v2 -``` +For v2 migration guide and breaking changes, see [CHANGELOG.md](CHANGELOG.md#3.0.0). diff --git a/danger/README.md b/danger/README.md new file mode 100644 index 00000000..e979ab4e --- /dev/null +++ b/danger/README.md @@ -0,0 +1,79 @@ +# Danger Composite Action + +Runs DangerJS on Pull Requests in your repository. This uses custom set of rules defined in [dangerfile.js](dangerfile.js). + +## Usage + +```yaml +name: Danger + +on: + pull_request: + types: [opened, synchronize, reopened, edited, ready_for_review, labeled, unlabeled] + +permissions: + contents: read # To read repository files + pull-requests: write # To post comments on pull requests + statuses: write # To post commit status checks + +jobs: + danger: + runs-on: ubuntu-latest + steps: + - uses: getsentry/github-workflows/danger@v3 +``` + +## Inputs + +* `api-token`: Token for the repo. Can be passed in using `${{ secrets.GITHUB_TOKEN }}`. + * type: string + * required: false + * default: `${{ github.token }}` + +* `extra-dangerfile`: Path to an additional dangerfile to run custom checks. + * type: string + * required: false + * default: "" + +* `extra-install-packages`: Additional packages that are required by the extra-dangerfile, you can find a list of packages here: https://packages.debian.org/search?suite=bookworm&keywords=curl. + * type: string + * required: false + * default: "" + +## Outputs + +* `outcome`: Whether the Danger run finished successfully. Possible values are `success`, `failure`, `cancelled`, or `skipped`. + +## Migration from v2 Reusable Workflow + +If you're migrating from the v2 reusable workflow, see the [changelog migration guide](../CHANGELOG.md#unreleased) for detailed examples. + +Key changes: +- Add `runs-on` to specify the runner +- No need for explicit `actions/checkout` step (handled internally) +- Optional `api-token` input (defaults to `github.token`) + +## Rules + +The Danger action runs the following checks: + +- **Changelog validation**: Ensures PRs include appropriate changelog entries +- **Action pinning**: Verifies GitHub Actions are pinned to specific commits for security +- **Conventional commits**: Validates commit message format and PR title conventions +- **Cross-repo links**: Checks for proper formatting of links in changelog entries + +For detailed rule implementations, see [dangerfile.js](dangerfile.js). + +## Extra Danger File + +When using an extra dangerfile, the file must be inside the repository and written in CommonJS syntax. You can use the following snippet to export your dangerfile: + +```JavaScript +module.exports = async function ({ fail, warn, message, markdown, danger }) { + ... + const gitUrl = danger.github.pr.head.repo.git_url; + ... + warn('...'); +} + +``` diff --git a/danger/action.yml b/danger/action.yml new file mode 100644 index 00000000..a22b198e --- /dev/null +++ b/danger/action.yml @@ -0,0 +1,103 @@ +name: 'Danger JS' +description: 'Runs DangerJS with a pre-configured set of rules on a Pull Request' +author: 'Sentry' + +inputs: + api-token: + description: 'Token for the repo. Can be passed in using {{ secrets.GITHUB_TOKEN }}' + required: false + default: ${{ github.token }} + extra-dangerfile: + description: 'Path to additional dangerfile to run after the main checks' + type: string + required: false + extra-install-packages: + description: 'Additional apt packages to install in the DangerJS container (space-separated package names)' + type: string + required: false + +outputs: + outcome: + description: 'Whether the Danger run finished successfully. Possible values are success, failure, cancelled, or skipped.' + value: ${{ steps.danger.outcome }} + +runs: + using: 'composite' + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + token: ${{ inputs.api-token }} + fetch-depth: 0 + + # Read the Danger version from the properties file + - name: Get Danger version + id: config + shell: pwsh + run: Get-Content '${{ github.action_path }}/danger.properties' | Tee-Object $env:GITHUB_OUTPUT -Append + + # Validate extra-install-packages to prevent code injection + - name: Validate package names + if: ${{ inputs.extra-install-packages }} + shell: pwsh + env: + EXTRA_INSTALL_PACKAGES: ${{ inputs.extra-install-packages }} + run: | + # Validate against Debian package naming rules: must start with alphanumeric, + # contain only lowercase letters, digits, hyphens, plus signs, periods + # Package names cannot start with hyphen or period, and must be reasonable length + foreach ($pkg in $env:EXTRA_INSTALL_PACKAGES -split '\s+') { + if ($pkg -notmatch '^[a-z0-9][a-z0-9.+-]{0,100}$') { + Write-Host "::error::Invalid package name '$pkg'. Debian packages must start with lowercase letter or digit and contain only lowercase letters, digits, hyphens, periods, and plus signs." + exit 1 + } + } + + # Using a pre-built docker image in GitHub container registry instead of NPM to reduce possible attack vectors. + - name: Setup container + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.api-token }} + EXTRA_DANGERFILE_INPUT: ${{ inputs.extra-dangerfile }} + DANGER_VERSION: ${{ steps.config.outputs.version }} + run: | + # Validate version looks like a semver tag (defense in depth) + if ! [[ "$DANGER_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Invalid Danger version '$DANGER_VERSION'. Expected semver format (e.g., 13.0.4)." + exit 1 + fi + # Start a detached container with all necessary volumes and environment variables + docker run -td --name danger \ + --entrypoint /bin/bash \ + --volume ${{ github.workspace }}:/github/workspace \ + --volume ${{ github.action_path }}:${{ github.action_path }} \ + --volume ${{ github.event_path }}:${{ github.event_path }} \ + --workdir /github/workspace \ + --user $(id -u) \ + -e "INPUT_ARGS" -e "GITHUB_JOB" -e "GITHUB_REF" -e "GITHUB_SHA" -e "GITHUB_REPOSITORY" -e "GITHUB_REPOSITORY_OWNER" -e "GITHUB_RUN_ID" -e "GITHUB_RUN_NUMBER" -e "GITHUB_RETENTION_DAYS" -e "GITHUB_RUN_ATTEMPT" -e "GITHUB_ACTOR" -e "GITHUB_TRIGGERING_ACTOR" -e "GITHUB_WORKFLOW" -e "GITHUB_HEAD_REF" -e "GITHUB_BASE_REF" -e "GITHUB_EVENT_NAME" -e "GITHUB_SERVER_URL" -e "GITHUB_API_URL" -e "GITHUB_GRAPHQL_URL" -e "GITHUB_REF_NAME" -e "GITHUB_REF_PROTECTED" -e "GITHUB_REF_TYPE" -e "GITHUB_WORKSPACE" -e "GITHUB_ACTION" -e "GITHUB_EVENT_PATH" -e "GITHUB_ACTION_REPOSITORY" -e "GITHUB_ACTION_REF" -e "GITHUB_PATH" -e "GITHUB_ENV" -e "GITHUB_STEP_SUMMARY" -e "RUNNER_OS" -e "RUNNER_ARCH" -e "RUNNER_NAME" -e "RUNNER_TOOL_CACHE" -e "RUNNER_TEMP" -e "RUNNER_WORKSPACE" -e "ACTIONS_RUNTIME_URL" -e "ACTIONS_RUNTIME_TOKEN" -e "ACTIONS_CACHE_URL" -e GITHUB_ACTIONS=true -e CI=true \ + -e "GITHUB_TOKEN" \ + -e DANGER_DISABLE_TRANSPILATION="true" \ + -e "EXTRA_DANGERFILE_INPUT" \ + "ghcr.io/danger/danger-js:${DANGER_VERSION}" \ + -c "sleep infinity" + + - name: Setup additional packages + if: ${{ inputs.extra-install-packages }} + shell: bash + env: + EXTRA_INSTALL_PACKAGES: ${{ inputs.extra-install-packages }} + run: | + echo "Installing packages: $EXTRA_INSTALL_PACKAGES" + docker exec --user root danger sh -c "set -e && apt-get update && apt-get install -y --no-install-recommends $EXTRA_INSTALL_PACKAGES" + echo "All additional packages installed successfully." + + - name: Run DangerJS + id: danger + shell: bash + run: | + docker exec --user $(id -u) danger danger ci --fail-on-errors --dangerfile ${{ github.action_path }}/dangerfile.js + + - name: Cleanup container + if: always() + shell: bash + run: docker rm -f danger || true diff --git a/danger/danger.properties b/danger/danger.properties new file mode 100644 index 00000000..7ce4b836 --- /dev/null +++ b/danger/danger.properties @@ -0,0 +1,2 @@ +version=13.0.5 +repo=https://github.com/danger/danger-js diff --git a/danger/dangerfile-utils.js b/danger/dangerfile-utils.js new file mode 100644 index 00000000..daaed771 --- /dev/null +++ b/danger/dangerfile-utils.js @@ -0,0 +1,93 @@ +/// Unified configuration for PR flavors (based on real Sentry usage analysis) +const FLAVOR_CONFIG = [ + { + labels: ["feat", "feature", "add", "implement"], + changelog: "Features", + isFeature: true + }, + { + labels: ["fix", "bug", "bugfix", "resolve", "correct"], + changelog: "Fixes" + }, + { + labels: ["sec", "security"], + changelog: "Security" + }, + { + labels: ["perf", "performance"], + changelog: "Performance" + }, + { + // Internal changes - no changelog needed + changelog: undefined, + labels: [ + "docs", + "doc", + "style", + "ref", + "refactor", + "tests", + "test", + "build", + "ci", + "chore", + "meta", + "deps", + "dep", + "update", + "bump", + "cleanup", + "format" + ] + } +]; + +/// Get flavor configuration for a given PR flavor +function getFlavorConfig(prFlavor) { + const normalizedFlavor = prFlavor.toLowerCase().trim(); + + // Strip scope/context from conventional commit format: "type(scope)" -> "type" + const parenIndex = normalizedFlavor.indexOf('('); + const baseType = parenIndex !== -1 ? normalizedFlavor.substring(0, parenIndex) : normalizedFlavor; + + const config = FLAVOR_CONFIG.find(config => + config.labels.includes(normalizedFlavor) || config.labels.includes(baseType) + ); + + return config || { + changelog: "Features" // Default to Features + }; +} + + +/// Extract PR flavor from title or branch name +function extractPRFlavor(prTitle, prBranchRef) { + // Validate input parameters to prevent runtime errors + if (prTitle && typeof prTitle === 'string') { + // First try conventional commit format: "type(scope): description" + const colonParts = prTitle.split(":"); + if (colonParts.length > 1) { + return colonParts[0].toLowerCase().trim(); + } + + // Fallback: try first word for non-conventional titles like "fix memory leak" + const firstWord = prTitle.trim().split(/\s+/)[0]; + if (firstWord) { + return firstWord.toLowerCase(); + } + } + + if (prBranchRef && typeof prBranchRef === 'string') { + const parts = prBranchRef.split("/"); + if (parts.length > 1) { + return parts[0].toLowerCase(); + } + } + return ""; +} + +module.exports = { + FLAVOR_CONFIG, + getFlavorConfig, + extractPRFlavor +}; diff --git a/danger/dangerfile-utils.test.js b/danger/dangerfile-utils.test.js new file mode 100644 index 00000000..cfd1fe1a --- /dev/null +++ b/danger/dangerfile-utils.test.js @@ -0,0 +1,278 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const { getFlavorConfig, extractPRFlavor, FLAVOR_CONFIG } = require('./dangerfile-utils.js'); + +describe('dangerfile-utils', () => { + describe('getFlavorConfig', () => { + it('should return config for features with isFeature true', () => { + const featConfig = getFlavorConfig('feat'); + assert.strictEqual(featConfig.changelog, 'Features'); + assert.strictEqual(featConfig.isFeature, true); + + const featureConfig = getFlavorConfig('feature'); + assert.strictEqual(featureConfig.changelog, 'Features'); + assert.strictEqual(featureConfig.isFeature, true); + }); + + it('should return config for fixes without isFeature', () => { + const fixConfig = getFlavorConfig('fix'); + assert.strictEqual(fixConfig.changelog, 'Fixes'); + assert.strictEqual(fixConfig.isFeature, undefined); + + const bugConfig = getFlavorConfig('bug'); + assert.strictEqual(bugConfig.changelog, 'Fixes'); + assert.strictEqual(bugConfig.isFeature, undefined); + + const bugfixConfig = getFlavorConfig('bugfix'); + assert.strictEqual(bugfixConfig.changelog, 'Fixes'); + assert.strictEqual(bugfixConfig.isFeature, undefined); + }); + + it('should return config with undefined changelog for skipped flavors', () => { + const skipFlavors = ['docs', 'doc', 'ci', 'tests', 'test', 'style', 'refactor', 'build', 'chore', 'meta', 'deps', 'dep', 'chore(deps)', 'build(deps)']; + + skipFlavors.forEach(flavor => { + const config = getFlavorConfig(flavor); + assert.strictEqual(config.changelog, undefined, `${flavor} should have undefined changelog`); + assert.strictEqual(config.isFeature, undefined, `${flavor} should have undefined isFeature`); + }); + }); + + it('should return default config for unknown flavors', () => { + const unknownConfig = getFlavorConfig('unknown'); + assert.strictEqual(unknownConfig.changelog, 'Features'); + assert.strictEqual(unknownConfig.isFeature, undefined); + + const emptyConfig = getFlavorConfig(''); + assert.strictEqual(emptyConfig.changelog, 'Features'); + assert.strictEqual(emptyConfig.isFeature, undefined); + }); + + it('should be case-insensitive and handle whitespace', () => { + const config1 = getFlavorConfig('FEAT'); + assert.strictEqual(config1.changelog, 'Features'); + + const config2 = getFlavorConfig(' fix '); + assert.strictEqual(config2.changelog, 'Fixes'); + }); + + it('should handle all security-related flavors', () => { + const secConfig = getFlavorConfig('sec'); + assert.strictEqual(secConfig.changelog, 'Security'); + + const securityConfig = getFlavorConfig('security'); + assert.strictEqual(securityConfig.changelog, 'Security'); + }); + + it('should handle all performance-related flavors', () => { + const perfConfig = getFlavorConfig('perf'); + assert.strictEqual(perfConfig.changelog, 'Performance'); + + const performanceConfig = getFlavorConfig('performance'); + assert.strictEqual(performanceConfig.changelog, 'Performance'); + }); + + it('should handle ref flavor (internal changes - no changelog)', () => { + const refConfig = getFlavorConfig('ref'); + assert.strictEqual(refConfig.changelog, undefined); + assert.strictEqual(refConfig.isFeature, undefined); + }); + + it('should handle scoped flavors by stripping scope', () => { + const scopedFeat = getFlavorConfig('feat(core)'); + assert.strictEqual(scopedFeat.changelog, 'Features'); + assert.strictEqual(scopedFeat.isFeature, true); + + const scopedFix = getFlavorConfig('fix(browser)'); + assert.strictEqual(scopedFix.changelog, 'Fixes'); + assert.strictEqual(scopedFix.isFeature, undefined); + + const scopedChore = getFlavorConfig('chore(deps)'); + assert.strictEqual(scopedChore.changelog, undefined); + + // Test edge cases for scope stripping + const nestedParens = getFlavorConfig('feat(scope(nested))'); + assert.strictEqual(nestedParens.changelog, 'Features'); // Should strip at first ( + + const noCloseParen = getFlavorConfig('feat(scope'); + assert.strictEqual(noCloseParen.changelog, 'Features'); // Should still work + + const multipleParens = getFlavorConfig('feat(scope1)(scope2)'); + assert.strictEqual(multipleParens.changelog, 'Features'); // Should strip at first ( + }); + + it('should handle non-conventional action words', () => { + // Feature-related words + const addConfig = getFlavorConfig('add'); + assert.strictEqual(addConfig.changelog, 'Features'); + assert.strictEqual(addConfig.isFeature, true); + + const implementConfig = getFlavorConfig('implement'); + assert.strictEqual(implementConfig.changelog, 'Features'); + assert.strictEqual(implementConfig.isFeature, true); + + // Fix-related words + const resolveConfig = getFlavorConfig('resolve'); + assert.strictEqual(resolveConfig.changelog, 'Fixes'); + + const correctConfig = getFlavorConfig('correct'); + assert.strictEqual(correctConfig.changelog, 'Fixes'); + + // Internal change words + const updateConfig = getFlavorConfig('update'); + assert.strictEqual(updateConfig.changelog, undefined); + + const bumpConfig = getFlavorConfig('bump'); + assert.strictEqual(bumpConfig.changelog, undefined); + + const cleanupConfig = getFlavorConfig('cleanup'); + assert.strictEqual(cleanupConfig.changelog, undefined); + + const formatConfig = getFlavorConfig('format'); + assert.strictEqual(formatConfig.changelog, undefined); + }); + }); + + describe('extractPRFlavor', () => { + it('should extract flavor from PR title with colon', () => { + const flavor = extractPRFlavor('feat: add new feature', null); + assert.strictEqual(flavor, 'feat'); + + const flavor2 = extractPRFlavor('Fix: resolve bug in authentication', null); + assert.strictEqual(flavor2, 'fix'); + + const flavor3 = extractPRFlavor('Docs: Update readme', null); + assert.strictEqual(flavor3, 'docs'); + }); + + it('should extract flavor from branch name with slash', () => { + const flavor = extractPRFlavor(null, 'feature/new-api'); + assert.strictEqual(flavor, 'feature'); + + const flavor2 = extractPRFlavor(null, 'ci/update-workflows'); + assert.strictEqual(flavor2, 'ci'); + + const flavor3 = extractPRFlavor(null, 'fix/auth-bug'); + assert.strictEqual(flavor3, 'fix'); + }); + + it('should prefer title over branch if both available', () => { + const flavor = extractPRFlavor('feat: add feature', 'ci/update-workflows'); + assert.strictEqual(flavor, 'feat'); + }); + + it('should return empty string if no flavor found', () => { + // Empty or whitespace-only strings + const flavor1 = extractPRFlavor('', null); + assert.strictEqual(flavor1, ''); + + const flavor2 = extractPRFlavor(' ', null); + assert.strictEqual(flavor2, ''); + + // No branch with slash + const flavor3 = extractPRFlavor(null, 'simple-branch'); + assert.strictEqual(flavor3, ''); + + // All null/undefined + const flavor4 = extractPRFlavor(null, null); + assert.strictEqual(flavor4, ''); + }); + + it('should handle edge cases', () => { + const flavor1 = extractPRFlavor(':', null); + assert.strictEqual(flavor1, ''); + + const flavor2 = extractPRFlavor(null, '/'); + assert.strictEqual(flavor2, ''); + + const flavor3 = extractPRFlavor('title: with: multiple: colons', null); + assert.strictEqual(flavor3, 'title'); + }); + + it('should validate input parameters and handle non-string types', () => { + // Number inputs + const flavor1 = extractPRFlavor(123, 456); + assert.strictEqual(flavor1, ''); + + // Object inputs + const flavor2 = extractPRFlavor({ test: 'object' }, ['array']); + assert.strictEqual(flavor2, ''); + + // Boolean inputs + const flavor3 = extractPRFlavor(true, false); + assert.strictEqual(flavor3, ''); + + // Mixed valid/invalid inputs + const flavor4 = extractPRFlavor(null, 'valid/branch'); + assert.strictEqual(flavor4, 'valid'); + + const flavor5 = extractPRFlavor('valid: title', 42); + assert.strictEqual(flavor5, 'valid'); + }); + + it('should extract first word from non-conventional PR titles', () => { + // Non-conventional titles starting with action words + const flavor1 = extractPRFlavor('Fix memory leak in authentication', null); + assert.strictEqual(flavor1, 'fix'); + + const flavor2 = extractPRFlavor('Add support for new API endpoint', null); + assert.strictEqual(flavor2, 'add'); + + const flavor3 = extractPRFlavor('Update dependencies to latest versions', null); + assert.strictEqual(flavor3, 'update'); + + const flavor4 = extractPRFlavor('Remove deprecated configuration options', null); + assert.strictEqual(flavor4, 'remove'); + + const flavor5 = extractPRFlavor('Bump version to 2.0.0', null); + assert.strictEqual(flavor5, 'bump'); + + // Should still prefer conventional format over first word + const flavor6 = extractPRFlavor('chore: Update dependencies to latest versions', null); + assert.strictEqual(flavor6, 'chore'); + + // Handle extra whitespace + const flavor7 = extractPRFlavor(' Fix memory leak ', null); + assert.strictEqual(flavor7, 'fix'); + }); + }); + + + describe('FLAVOR_CONFIG integrity', () => { + it('should have unique labels across all configs', () => { + const allLabels = []; + FLAVOR_CONFIG.forEach(config => { + config.labels.forEach(label => { + assert.ok(!allLabels.includes(label), `Duplicate label found: ${label}`); + allLabels.push(label); + }); + }); + }); + + it('should have proper structure for all configs', () => { + FLAVOR_CONFIG.forEach((config, index) => { + assert.ok(Array.isArray(config.labels), `Config ${index} should have labels array`); + assert.ok(config.labels.length > 0, `Config ${index} should have at least one label`); + assert.ok(config.hasOwnProperty('changelog'), `Config ${index} should have changelog property`); + + // changelog should be either a string or undefined + if (config.changelog !== undefined) { + assert.strictEqual(typeof config.changelog, 'string', `Config ${index} changelog should be string or undefined`); + } + + // isFeature should be true or undefined (not false) + if (config.hasOwnProperty('isFeature')) { + assert.strictEqual(config.isFeature, true, `Config ${index} isFeature should be true or undefined`); + } + }); + }); + + it('should have only Features configs with isFeature true', () => { + FLAVOR_CONFIG.forEach(config => { + if (config.isFeature === true) { + assert.strictEqual(config.changelog, 'Features', 'Only Features configs should have isFeature true'); + } + }); + }); + }); +}); \ No newline at end of file diff --git a/danger/dangerfile.js b/danger/dangerfile.js index 6855f24c..d5feaa48 100644 --- a/danger/dangerfile.js +++ b/danger/dangerfile.js @@ -1,3 +1,5 @@ +const { getFlavorConfig, extractPRFlavor } = require('./dangerfile-utils.js'); + const headRepoName = danger.github.pr.head.repo.git_url; const baseRepoName = danger.github.pr.base.repo.git_url; const isFork = headRepoName != baseRepoName; @@ -36,27 +38,15 @@ if (isFork) { // e.g. "feat" if PR title is "Feat : add more useful stuff" // or "ci" if PR branch is "ci/update-danger" -const prFlavor = (function () { - if (danger.github && danger.github.pr) { - if (danger.github.pr.title) { - const parts = danger.github.pr.title.split(":"); - if (parts.length > 1) { - return parts[0].toLowerCase().trim(); - } - } - if (danger.github.pr.head && danger.github.pr.head.ref) { - const parts = danger.github.pr.head.ref.split("/"); - if (parts.length > 1) { - return parts[0].toLowerCase(); - } - } - } - return ""; -})(); +const prFlavor = extractPRFlavor( + danger.github?.pr?.title, + danger.github?.pr?.head?.ref +); console.log(`::debug:: PR Flavor: '${prFlavor}'`); async function checkDocs() { - if (prFlavor.startsWith("feat")) { + const flavorConfig = getFlavorConfig(prFlavor); + if (flavorConfig.isFeature) { message( 'Do not forget to update Sentry-docs with your feature once the pull request gets approved.' ); @@ -65,10 +55,11 @@ async function checkDocs() { async function checkChangelog() { const changelogFile = "CHANGELOG.md"; + const flavorConfig = getFlavorConfig(prFlavor); - // Check if skipped + // Check if skipped - either by flavor config, explicit skip, or skip label if ( - ["ci", "test", "deps", "chore(deps)", "build(deps)"].includes(prFlavor) || + flavorConfig.changelog === undefined || (danger.github.pr.body + "").includes("#skip-changelog") || (danger.github.pr.labels || []).some(label => label.name === 'skip-changelog') ) { @@ -103,6 +94,7 @@ async function checkChangelog() { } } + /// Report missing changelog entry function reportMissingChangelog(changelogFile) { fail("Please consider adding a changelog entry for the next release.", changelogFile); @@ -113,6 +105,10 @@ function reportMissingChangelog(changelogFile) { .trim() .replace(/\.+$/, ""); + // Determine the appropriate section based on PR flavor + const flavorConfig = getFlavorConfig(prFlavor); + const sectionName = flavorConfig.changelog || "Features"; + markdown( ` ### Instructions and example for changelog @@ -124,6 +120,8 @@ Example: \`\`\`markdown ## Unreleased +### ${sectionName} + - ${prTitleFormatted} ([#${danger.github.pr.number}](${danger.github.pr.html_url})) \`\`\` @@ -188,10 +186,52 @@ async function checkActionsArePinned() { } } +async function checkFromExternalChecks() { + // Get the external dangerfile path from environment variable (passed via workflow input) + // Priority: EXTRA_DANGERFILE (absolute path) -> EXTRA_DANGERFILE_INPUT (relative path) + const extraDangerFilePath = process.env.EXTRA_DANGERFILE || process.env.EXTRA_DANGERFILE_INPUT; + console.log(`::debug:: Checking from external checks: ${extraDangerFilePath}`); + if (extraDangerFilePath) { + try { + const workspaceDir = '/github/workspace'; + + const path = require('path'); + const fs = require('fs'); + const customPath = path.join(workspaceDir, extraDangerFilePath); + // Ensure the resolved path is within workspace + const resolvedPath = fs.realpathSync(customPath); + if (!resolvedPath.startsWith(workspaceDir)) { + fail(`Invalid dangerfile path: ${extraDangerFilePath}. Must be within workspace.`); + throw new Error('Security violation: dangerfile path outside workspace'); + } + + const extraModule = require(customPath); + if (typeof extraModule !== 'function') { + warn(`EXTRA_DANGERFILE must export a function at ${customPath}`); + return; + } + await extraModule({ + fail: fail, + warn: warn, + message: message, + markdown: markdown, + danger: danger, + }); + } catch (err) { + if (err.message && err.message.includes('Cannot use import statement outside a module')) { + warn(`External dangerfile uses ES6 imports. Please convert to CommonJS syntax (require/module.exports) or use .mjs extension with proper module configuration.\nFile: ${extraDangerFilePath}`); + } else { + warn(`Could not load custom Dangerfile: ${extraDangerFilePath}\n${err}`); + } + } + } +} + async function checkAll() { await checkDocs(); await checkChangelog(); await checkActionsArePinned(); + await checkFromExternalChecks(); } schedule(checkAll); diff --git a/sentry-cli/integration-test/action.psm1 b/sentry-cli/integration-test/action.psm1 index 73b2fbfe..9d3b7e31 100644 --- a/sentry-cli/integration-test/action.psm1 +++ b/sentry-cli/integration-test/action.psm1 @@ -33,12 +33,38 @@ class InvokeSentryResult } elseif ($null -ne $envelope) { - $envelope += $_ + "\n" + $envelope += $_ + "`n" } } return $envelopes } + # Events are extracted from envelopes, each event body as single item. + # Note: Unlike Envelopes(), this method discards potential duplicates based on event_id. + [string[]]Events() + { + $ids = @() + $events = @() + foreach ($envelope in $this.Envelopes()) + { + $lines = @($envelope -split "`n") + $header = $lines[0].Trim() | ConvertFrom-Json + $eventId = $header | Select-Object -ExpandProperty event_id -ErrorAction SilentlyContinue + if ($eventId -and $ids -notcontains $eventId) + { + $body = $lines | Select-Object -Skip 1 | Where-Object { + $_ -like "*`"event_id`":`"$eventId`"*" + } | Select-Object -First 1 + if ($body) + { + $ids += $eventId + $events += $body + } + } + } + return $events + } + [bool]HasErrors() { return $this.ServerStdErr.Length -gt 0 diff --git a/sentry-cli/integration-test/action.yml b/sentry-cli/integration-test/action.yml index 79d3158e..6b80835f 100644 --- a/sentry-cli/integration-test/action.yml +++ b/sentry-cli/integration-test/action.yml @@ -16,6 +16,9 @@ runs: steps: - name: Run tests shell: pwsh + env: + ACTION_PATH: ${{ github.action_path }} + TEST_PATH: ${{ inputs.path }} run: | - Import-Module -Name ${{ github.action_path }}/action.psm1 -Force - Invoke-Pester -Output Detailed '${{ inputs.path }}' + Import-Module -Name ($env:ACTION_PATH + '/action.psm1') -Force + Invoke-Pester -Output Detailed $env:TEST_PATH diff --git a/sentry-cli/integration-test/sentry-server.py b/sentry-cli/integration-test/sentry-server.py index 2e418ac9..2d6ba773 100644 --- a/sentry-cli/integration-test/sentry-server.py +++ b/sentry-cli/integration-test/sentry-server.py @@ -43,7 +43,7 @@ def do_GET(self): self.writeJSON('{"url":"' + uri.geturl() + self.path + '",' '"chunkSize":8388608,"chunksPerRequest":64,"maxFileSize":2147483648,' '"maxRequestSize":33554432,"concurrency":1,"hashAlgorithm":"sha1","compression":["gzip"],' - '"accept":["debug_files","release_files","pdbs","sources","bcsymbolmaps","il2cpp","portablepdbs"]}') + '"accept":["debug_files","release_files","pdbs","sources","bcsymbolmaps","il2cpp","portablepdbs","proguard"]}') elif self.isApi('/api/0/organizations/{}/repos/?cursor='.format(apiOrg)): self.writeJSONFile("assets/repos.json") elif self.isApi('/api/0/organizations/{}/releases/{}@{}/previous-with-commits/'.format(apiOrg, appIdentifier, version)): @@ -93,6 +93,8 @@ def do_POST(self): self.writeJSON('{ }') elif self.isApi('api/0/organizations/{}/chunk-upload/'.format(apiOrg)): self.writeJSON('{ }') + elif self.isApi('/api/0/projects/{}/{}/files/proguard-artifact-releases/'.format(apiOrg, apiProject)): + self.writeJSON('{ }') elif self.isApi('api/0/envelope'): sys.stdout.write(" envelope start\n") sys.stdout.write(self.body) diff --git a/sentry-cli/integration-test/tests/action.Tests.ps1 b/sentry-cli/integration-test/tests/action.Tests.ps1 index 332c0da3..df0582c7 100644 --- a/sentry-cli/integration-test/tests/action.Tests.ps1 +++ b/sentry-cli/integration-test/tests/action.Tests.ps1 @@ -43,10 +43,23 @@ Describe 'Invoke-SentryServer' { $result.UploadedDebugFiles() | Should -Be @('file3.dylib', 'file2.so', 'file1.dll') } + It "accepts chunked uploads" { + $result = Invoke-SentryServer { + param([string]$url) + $response = Invoke-WebRequest -Uri "$url/api/0/organizations/org/chunk-upload/" + $json = $response.Content | ConvertFrom-Json + @("debug_files", "pdbs", "sources", "portablepdbs", "proguard") | ForEach-Object { + $json.accept | Should -Contain $_ + } + } + Should -ActualValue $result.HasErrors() -BeFalse + } + It "collects proguard mapping" { $result = Invoke-SentryServer { Param([string]$url) Invoke-WebRequest -Uri "$url/api/0/projects/org/project/files/dsyms/associate/" -Method Post + Invoke-WebRequest -Uri "$url/api/0/projects/org/project/files/proguard-artifact-releases" -Method Post } Should -ActualValue $result.HasErrors() -BeFalse } @@ -69,8 +82,8 @@ helloworld } Should -ActualValue $result.HasErrors() -BeFalse $result.Envelopes().Length | Should -Be 2 - $result.Envelopes()[0].Length | Should -Be 357 - $result.Envelopes()[1].Length | Should -Be 84 + $result.Envelopes()[0].Length | Should -Be 352 + $result.Envelopes()[1].Length | Should -Be 81 } It "collects gzip compressed envelopes" { @@ -94,6 +107,36 @@ helloworld Should -ActualValue $result.HasErrors() -BeFalse $result.Envelopes().Length | Should -Be 1 - $result.Envelopes()[0].Length | Should -Be 357 + $result.Envelopes()[0].Length | Should -Be 352 + } + + It "discards duplicate events" { + $result = Invoke-SentryServer { + param([string]$url) + Invoke-WebRequest -Uri "$url/api/0/envelope" -Method Post -Body @' +{"dsn":"https://e12d836b15bb49d7bbf99e64295d995b:@sentry.io/42","sent_at":"2025-11-20T03:52:42.924Z"} +{"type":"session","length":42} +{"sid":"66356dadc138458a8d5cd9e258065175"} +'@ + Invoke-WebRequest -Uri "$url/api/0/envelope" -Method Post -Body @' +{"event_id":"9ec79c33ec9942ab8353589fcb2e04dc","dsn":"https://e12d836b15bb49d7bbf99e64295d995b:@sentry.io/42","sent_at":"2025-11-20T03:53:38.929Z"} +{"type":"attachment","length":10,"content_type":"text/plain","filename":"hello.txt"} +\xef\xbb\xbfHello\r\n +{"type":"event","length":47,"content_type":"application/json"} +{"event_id":"9ec79c33ec9942ab8353589fcb2e04dc"} +'@ + Invoke-WebRequest -Uri "$url/api/0/envelope" -Method Post -Body @' +{"event_id":"9ec79c33ec9942ab8353589fcb2e04dc","dsn":"https://e12d836b15bb49d7bbf99e64295d995b:@sentry.io/42","sent_at":"2025-11-20T03:53:41.505Z"} +{"type":"attachment","length":10,"content_type":"text/plain","filename":"hello.txt"} +\xef\xbb\xbfHello\r\n +{"type":"event","length":47,"content_type":"application/json"} +{"event_id":"9ec79c33ec9942ab8353589fcb2e04dc"} +'@ + } + + Should -ActualValue $result.HasErrors() -BeFalse + $result.Envelopes().Length | Should -Be 3 + $result.Events().Length | Should -Be 1 + $result.Events()[0].Length | Should -Be 47 } } diff --git a/updater/README.md b/updater/README.md new file mode 100644 index 00000000..96cbfea4 --- /dev/null +++ b/updater/README.md @@ -0,0 +1,268 @@ +# Updater Composite Action + +Dependency updater - updates dependencies to the latest published git tag and creates/updates PRs. + +## Usage + +```yaml +name: Update Dependencies +on: + # Run every day. + schedule: + - cron: '0 3 * * *' + # And on every PR merge so we get the updated dependencies ASAP, and to make sure the changelog doesn't conflict. + push: + branches: + - main + +permissions: + contents: write # To modify files and create commits + pull-requests: write # To create and update pull requests + actions: write # To cancel previous workflow runs + +jobs: + # Update a git submodule + cocoa: + runs-on: ubuntu-latest + steps: + - uses: getsentry/github-workflows/updater@v3 + with: + path: modules/sentry-cocoa + name: Cocoa SDK + pattern: '^1\.' # Limit to major version '1' + api-token: ${{ secrets.CI_DEPLOY_KEY }} + + # Update to stable releases only by filtering GitHub release titles + cocoa-stable: + runs-on: ubuntu-latest + steps: + - uses: getsentry/github-workflows/updater@v3 + with: + path: modules/sentry-cocoa + name: Cocoa SDK (Stable) + gh-title-pattern: '\(Stable\)$' # Only releases with "(Stable)" suffix + api-token: ${{ secrets.CI_DEPLOY_KEY }} + + # Update a properties file + cli: + runs-on: ubuntu-latest + steps: + - uses: getsentry/github-workflows/updater@v3 + with: + path: sentry-cli.properties + name: CLI + api-token: ${{ secrets.CI_DEPLOY_KEY }} + + # Update using a custom shell script, see updater/scripts/update-dependency.ps1 for the required arguments + agp: + runs-on: ubuntu-latest + steps: + - uses: getsentry/github-workflows/updater@v3 + with: + path: script.ps1 + name: Gradle Plugin + api-token: ${{ secrets.CI_DEPLOY_KEY }} + + # Update a CMake FetchContent dependency with auto-detection (single dependency only) + sentry-native: + runs-on: ubuntu-latest + steps: + - uses: getsentry/github-workflows/updater@v3 + with: + path: vendor/sentry-native.cmake + name: Sentry Native SDK + api-token: ${{ secrets.CI_DEPLOY_KEY }} + + # Update a CMake FetchContent dependency with explicit dependency name + deps: + runs-on: ubuntu-latest + steps: + - uses: getsentry/github-workflows/updater@v3 + with: + path: vendor/dependencies.cmake#googletest + name: GoogleTest + api-token: ${{ secrets.CI_DEPLOY_KEY }} + + # Update dependencies on a non-default branch (e.g., alpha, beta, or version branches) + # Note: due to limitations in GitHub Actions' schedule trigger, this code needs to be pushed to the default branch. + cocoa-v7: + runs-on: ubuntu-latest + steps: + - uses: getsentry/github-workflows/updater@v3 + with: + path: modules/sentry-cocoa + name: Cocoa SDK + target-branch: v7 + pattern: '^1\.' # Limit to major version '1' + api-token: ${{ secrets.CI_DEPLOY_KEY }} + + # Use a post-update script (sh or ps1) to make additional changes after dependency update + # The script receives two arguments: original version and new version + post-update-script: + runs-on: ubuntu-latest + steps: + - uses: getsentry/github-workflows/updater@v3 + with: + path: modules/sentry-cocoa + name: Cocoa SDK + post-update-script: scripts/post-update.sh # Receives args: $1=old version, $2=new version + api-token: ${{ secrets.CI_DEPLOY_KEY }} + + # Authentication with SSH deploy key (git operations via SSH, API via default token) + cocoa-ssh: + runs-on: ubuntu-latest + steps: + - uses: getsentry/github-workflows/updater@v3 + with: + path: modules/sentry-cocoa + name: Cocoa SDK + ssh-key: ${{ secrets.CI_DEPLOY_KEY }} + + # Authentication with both SSH key and API token (git via SSH, API via token) + # This is useful when you need CI to run on created PRs and use a deploy key + cocoa-ssh-and-token: + runs-on: ubuntu-latest + steps: + - uses: getsentry/github-workflows/updater@v3 + with: + path: modules/sentry-cocoa + name: Cocoa SDK + ssh-key: ${{ secrets.CI_DEPLOY_KEY }} + api-token: ${{ secrets.CI_GITHUB_TOKEN }} +``` + +## Inputs + +* `path`: Dependency path in the source repository. Supported formats: + * Submodule path + * Properties file (`.properties`) + * Shell script (`.ps1`, `.sh`) + * CMake file with FetchContent: + * `path/to/file.cmake#DepName` - specify dependency name + * `path/to/file.cmake` - auto-detection (single dependency only) + * type: string + * required: true +* `name`: Name used in the PR title and the changelog entry. + * type: string + * required: true +* `pattern`: RegEx pattern that will be matched against available versions when picking the latest one. + * type: string + * required: false + * default: '' +* `gh-title-pattern`: RegEx pattern to match against GitHub release titles. Only releases with matching titles will be considered. Useful for filtering to specific release channels (e.g., stable releases). + * type: string + * required: false + * default: '' +* `changelog-entry`: Whether to add a changelog entry for the update. + * type: boolean + * required: false + * default: true +* `changelog-section`: Section header to attach the changelog entry to. + * type: string + * required: false + * default: Dependencies +* `pr-strategy`: How to handle PRs. + Can be either of the following: + * `create` - create a new PR for new dependency versions as they are released - maintainers may merge or close older PRs manually + * `update` (default) - keep a single PR that gets updated with new dependency versions until merged - only the latest version update is available at any time +* `target-branch`: Branch to use as base for dependency updates. Defaults to repository default branch if not specified. + * type: string + * required: false + * default: '' (uses repository default branch) +* `post-update-script`: Optional script to run after successful dependency update. Can be a bash script (`.sh`) or PowerShell script (`.ps1`). The script will be executed in the repository root directory before PR creation. The script receives two arguments: + * `$1` / `$args[0]` - The original version (version before update) + * `$2` / `$args[1]` - The new version (version after update) + * type: string + * required: false + * default: '' +* `api-token`: GitHub API token for repository operations. Can be passed in using `${{ secrets.GITHUB_TOKEN }}`. + If you provide the usual `${{ github.token }}`, no followup CI will run on the created PR. + If you want CI to run on the PRs created by the Updater, you need to provide a custom user-specific auth token. + Not required if `ssh-key` is provided, but can be used together with `ssh-key` for GitHub API operations. + * type: string + * required: false + * default: '' +* `ssh-key`: SSH private key for repository authentication (e.g., deploy key). Can be used alone or together with `api-token`. + When used alone, the action will use SSH for git operations and fall back to the default GitHub token for API operations. + When used with `api-token`, SSH is used for git operations and the token is used for GitHub API operations. + * type: string + * required: false + * default: '' + +## Authentication + +The updater supports multiple authentication methods. Choose based on your requirements: + +### Option 1: API Token Only (Default) + +```yaml +api-token: ${{ secrets.GITHUB_TOKEN }} +``` + +* **Use when**: Standard GitHub token authentication is sufficient +* **Limitation**: If using `${{ github.token }}`, CI workflows won't run on created PRs +* **Solution**: Use a personal access token or GitHub App token to enable CI on PRs + +### Option 2: SSH Key Only + +```yaml +ssh-key: ${{ secrets.CI_DEPLOY_KEY }} +``` + +* **Use when**: Repository access requires SSH (e.g., deploy keys) +* **Behavior**: Git operations use SSH (CI will run on PRs since commits are made with SSH key), API operations use default GitHub token + +### Option 3: SSH Key + API Token (Recommended for Deploy Keys) + +```yaml +ssh-key: ${{ secrets.CI_DEPLOY_KEY }} +api-token: ${{ secrets.CI_GITHUB_TOKEN }} +``` + +* **Use when**: You need both deploy key access AND want to control the API token used for GitHub operations +* **Behavior**: Git operations use SSH deploy key, API operations use provided token +* **Benefits**: Full control over authentication for both git and API operations + +### Post-Update Script Example + +**Bash script** (`scripts/post-update.sh`): + +```bash +#!/usr/bin/env bash +set -euo pipefail + +ORIGINAL_VERSION="$1" +NEW_VERSION="$2" + +echo "Updated from $ORIGINAL_VERSION to $NEW_VERSION" +# Make additional changes to repository files here +``` + +**PowerShell script** (`scripts/post-update.ps1`): + +```powershell +param( + [Parameter(Mandatory = $true)][string] $OriginalVersion, + [Parameter(Mandatory = $true)][string] $NewVersion +) + +Write-Output "Updated from $OriginalVersion to $NewVersion" +# Make additional changes to repository files here +``` + +## Outputs + +* `prUrl`: The created/updated PR's URL. +* `baseBranch`: The base branch name. +* `prBranch`: The created/updated PR branch name. +* `originalTag`: The original tag from which the dependency was updated from. +* `latestTag`: The latest tag to which the dependency was updated to. + +## Migration from v2 Reusable Workflow + +If you're migrating from the v2 reusable workflow, see the [changelog migration guide](../CHANGELOG.md#unreleased) for detailed examples. + +Key changes: +- Add `runs-on` to specify the runner +- Move `secrets.api-token` to `with.api-token` +- No need for explicit `actions/checkout` step (handled internally) diff --git a/updater/action.yml b/updater/action.yml new file mode 100644 index 00000000..ead6fa15 --- /dev/null +++ b/updater/action.yml @@ -0,0 +1,516 @@ +name: 'Dependency Updater' +description: 'Updates dependencies to the latest published tag and creates/updates PRs' +author: 'Sentry' + +inputs: + path: + description: 'Dependency path in the source repository, this can be either a submodule, a .properties file, a shell script, or a CMake file with FetchContent.' + required: true + name: + description: 'Name used in the PR title and the changelog entry.' + required: true + pattern: + description: 'RegEx pattern that will be matched against available versions when picking the latest one.' + required: false + default: '' + gh-title-pattern: + description: 'RegEx pattern to match against GitHub release titles. Only releases with matching titles will be considered.' + required: false + default: '' + changelog-entry: + description: 'Whether to add a changelog entry for the update.' + required: false + default: 'true' + changelog-section: + description: 'Section header to attach the changelog entry to.' + required: false + default: 'Dependencies' + pr-strategy: + description: 'How to handle PRs - can be either "create" (create new PRs for each version) or "update" (keep single PR updated with latest version)' + required: false + default: 'update' + target-branch: + description: 'Branch to use as base for dependency updates. Defaults to repository default branch if not specified.' + required: false + default: '' + api-token: + description: 'Token for the repo. Can be passed in using {{ secrets.GITHUB_TOKEN }}. Not required if ssh-key is provided, but can be used together with ssh-key for GitHub API operations.' + required: false + default: '' + ssh-key: + description: 'SSH private key for repository authentication. Can be used alone or together with api-token (SSH for git, token for GitHub API).' + required: false + default: '' + post-update-script: + description: 'Optional script to run after successful dependency update. Can be a bash script (.sh) or PowerShell script (.ps1). The script will be executed in the caller-repo directory before PR creation.' + required: false + default: '' + +outputs: + prUrl: + description: 'The created/updated PRs url.' + value: ${{ steps.pr.outputs.url }} + baseBranch: + description: 'The base branch name.' + value: ${{ steps.root.outputs.baseBranch }} + prBranch: + description: 'The created/updated pr branch name.' + value: ${{ steps.root.outputs.prBranch }} + originalTag: + description: 'The original tag from which the dependency was updated from.' + value: ${{ steps.target.outputs.originalTag }} + latestTag: + description: 'The latest tag to which the dependency was updated to.' + value: ${{ steps.target.outputs.latestTag }} + +runs: + using: 'composite' + steps: + - name: Cancel Previous Runs + uses: styfle/cancel-workflow-action@85880fa0301c86cca9da44039ee3bb12d3bedbfa # Tag: 0.12.1 + with: + access_token: ${{ github.token }} + + - name: Validate dependency name + shell: pwsh + env: + DEPENDENCY_NAME: ${{ inputs.name }} + run: | + # Validate that inputs.name contains only safe characters + if ($env:DEPENDENCY_NAME -notmatch '^[a-zA-Z0-9_\./@\s-]+$') { + Write-Output ('::error::Invalid dependency name: "' + $env:DEPENDENCY_NAME + '". Only alphanumeric characters, spaces, and _-./@ are allowed.') + exit 1 + } + Write-Output ('Dependency name "' + $env:DEPENDENCY_NAME + '" is valid') + + - name: Validate dependency path + shell: pwsh + env: + DEPENDENCY_PATH: ${{ inputs.path }} + run: | + # Validate that inputs.path contains only safe characters (including # for CMake dependencies) + if ($env:DEPENDENCY_PATH -notmatch '^[a-zA-Z0-9_\./#-]+$') { + Write-Output ('::error::Invalid dependency path: "' + $env:DEPENDENCY_PATH + '". Only alphanumeric characters and _-./# are allowed.') + exit 1 + } + Write-Output ('Dependency path "' + $env:DEPENDENCY_PATH + '" is valid') + + - name: Validate changelog-entry + shell: pwsh + env: + CHANGELOG_ENTRY: ${{ inputs.changelog-entry }} + run: | + # Validate that inputs.changelog-entry is either 'true' or 'false' + if ($env:CHANGELOG_ENTRY -notin @('true', 'false')) { + Write-Output ('::error::Invalid changelog-entry value: "' + $env:CHANGELOG_ENTRY + '". Only "true" or "false" are allowed.') + exit 1 + } + Write-Output ('Changelog-entry value "' + $env:CHANGELOG_ENTRY + '" is valid') + + - name: Validate pr-strategy + shell: pwsh + env: + PR_STRATEGY: ${{ inputs.pr-strategy }} + run: | + # Validate that inputs.pr-strategy is either 'create' or 'update' + if ($env:PR_STRATEGY -notin @('create', 'update')) { + Write-Output ('::error::Invalid pr-strategy value: "' + $env:PR_STRATEGY + '". Only "create" or "update" are allowed.') + exit 1 + } + Write-Output ('PR strategy value "' + $env:PR_STRATEGY + '" is valid') + + - name: Validate post-update-script + if: ${{ inputs.post-update-script != '' }} + shell: pwsh + env: + POST_UPDATE_SCRIPT: ${{ inputs.post-update-script }} + run: | + # Validate that inputs.post-update-script contains only safe characters + if ($env:POST_UPDATE_SCRIPT -notmatch '^[a-zA-Z0-9_\./#\s-]+$') { + Write-Output ('::error::Invalid post-update-script path: "' + $env:POST_UPDATE_SCRIPT + '". Only alphanumeric characters, spaces, and _-./# are allowed.') + exit 1 + } + Write-Output ('Post-update script path "' + $env:POST_UPDATE_SCRIPT + '" is valid') + + - name: Validate authentication inputs + shell: pwsh + env: + GH_TOKEN: ${{ inputs.api-token || github.token }} + SSH_KEY: ${{ inputs.ssh-key }} + run: | + $hasToken = -not [string]::IsNullOrEmpty($env:GH_TOKEN) + $hasSshKey = -not [string]::IsNullOrEmpty($env:SSH_KEY) + + if (-not $hasToken -and -not $hasSshKey) { + Write-Output "::error::Either api-token or ssh-key must be provided for authentication." + exit 1 + } + + if ($hasToken -and $hasSshKey) { + Write-Output "✓ Using both SSH key (for git) and token (for GitHub API)" + } elseif ($hasToken) { + Write-Output "✓ Using token authentication" + } else { + Write-Output "✓ Using SSH key authentication" + } + + - name: Validate API token + if: ${{ inputs.api-token != '' }} + shell: pwsh + env: + GH_TOKEN: ${{ inputs.api-token || github.token }} + run: | + # Check if token is actually an SSH key + if ($env:GH_TOKEN -match '-----BEGIN') { + Write-Output "::error::The api-token input appears to contain an SSH private key." + Write-Output "::error::Please use the ssh-key input for SSH authentication instead of api-token." + exit 1 + } + + # Check for whitespace + if ($env:GH_TOKEN -match '\s') { + $tokenLength = $env:GH_TOKEN.Length + $whitespaceMatch = [regex]::Match($env:GH_TOKEN, '\s') + $position = $whitespaceMatch.Index + $char = $whitespaceMatch.Value + $charName = switch ($char) { + "`n" { "newline (LF)" } + "`r" { "carriage return (CR)" } + "`t" { "tab" } + " " { "space" } + default { "whitespace character (code: $([int][char]$char))" } + } + Write-Output "::error::GitHub token contains whitespace at position $position of $tokenLength characters: $charName" + Write-Output "::error::This suggests the token secret may be malformed. Check for extra newlines when setting the secret." + exit 1 + } + + # Check token scopes (works for classic PATs only) + $headers = curl -sS -I -H "Authorization: token $env:GH_TOKEN" https://api.github.com 2>&1 + $scopeLine = $headers | Select-String -Pattern '^x-oauth-scopes:' -CaseSensitive:$false + if ($scopeLine) { + $scopes = $scopeLine -replace '^x-oauth-scopes:\s*', '' -replace '\r', '' + if ([string]::IsNullOrWhiteSpace($scopes)) { + Write-Output "::warning::Token has no scopes. If using a fine-grained PAT, ensure it has Contents (write) and Pull Requests (write) permissions." + } else { + Write-Output "Token scopes: $scopes" + if ($scopes -notmatch '\brepo\b' -and $scopes -notmatch '\bpublic_repo\b') { + Write-Output "::warning::Token may be missing 'repo' or 'public_repo' scope. This may cause issues with private repositories." + } + } + } else { + Write-Output "::notice::Could not detect token scopes (this is normal for fine-grained PATs). Ensure token has Contents (write) and Pull Requests (write) permissions." + } + + # Check token validity and access + gh api repos/${{ github.repository }} --silent 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Output "::error::GitHub token validation failed. Please verify:" + Write-Output " 1. Token is not empty or malformed" + Write-Output " 2. Token has not expired" + Write-Output " 3. Token has an expiration date set" + Write-Output " 4. Token has 'repo' and 'workflow' scopes" + exit 1 + } + + Write-Output "✓ GitHub token is valid and has access to this repository" + + - name: Validate SSH key + if: ${{ inputs.ssh-key != '' }} + shell: pwsh + env: + SSH_KEY: ${{ inputs.ssh-key }} + run: | + # Check if SSH key looks valid + if ($env:SSH_KEY -notmatch '-----BEGIN') { + Write-Output "::warning::SSH key does not appear to start with a PEM header (-----BEGIN). Please verify the key format." + } + + # Check for common SSH key types + $validKeyTypes = @('RSA', 'OPENSSH', 'DSA', 'EC', 'PRIVATE KEY') + $hasValidType = $false + foreach ($type in $validKeyTypes) { + if ($env:SSH_KEY -match "-----BEGIN.*$type") { + $hasValidType = $true + break + } + } + + if (-not $hasValidType) { + Write-Output "::warning::SSH key type not recognized. Supported types: RSA, OPENSSH, DSA, EC, PRIVATE KEY" + } + + Write-Output "✓ SSH key format appears valid" + + # What we need to accomplish: + # * update to the latest tag + # * create a PR + # * update changelog (including the link to the just created PR) + # + # What we actually do is based on whether a PR exists already: + # * YES it does: + # * make the update + # * update changelog (with the ID of an existing PR) + # * push to the PR + # * NO it doesn't: + # * make the update + # * push to a new PR + # * update changelog (with the ID of the just created PR) + # * push to the PR + # We do different approach on subsequent runs because otherwise we would spam users' mailboxes + # with notifications about pushes to existing PRs. This way there is actually no push if not needed. + - name: Checkout repository + uses: actions/checkout@v4 + with: + token: ${{ inputs.api-token || github.token }} + ssh-key: ${{ inputs.ssh-key }} + ref: ${{ inputs.target-branch || github.ref }} + path: caller-repo + + - name: Update to the latest version + id: target + shell: pwsh + working-directory: caller-repo + env: + DEPENDENCY_PATH: ${{ inputs.path }} + DEPENDENCY_PATTERN: ${{ inputs.pattern }} + GH_TITLE_PATTERN: ${{ inputs.gh-title-pattern }} + POST_UPDATE_SCRIPT: ${{ inputs.post-update-script }} + GH_TOKEN: ${{ inputs.api-token || github.token }} + run: ${{ github.action_path }}/scripts/update-dependency.ps1 -Path $env:DEPENDENCY_PATH -Pattern $env:DEPENDENCY_PATTERN -GhTitlePattern $env:GH_TITLE_PATTERN -PostUpdateScript $env:POST_UPDATE_SCRIPT + + - name: Get the base repo info + if: steps.target.outputs.latestTag != steps.target.outputs.originalTag + id: root + shell: pwsh + working-directory: caller-repo + env: + PR_STRATEGY: ${{ inputs.pr-strategy }} + DEPENDENCY_PATH: ${{ inputs.path }} + TARGET_BRANCH: ${{ inputs.target-branch }} + LATEST_TAG: ${{ steps.target.outputs.latestTag }} + run: | + if ([string]::IsNullOrEmpty($env:TARGET_BRANCH)) { + $mainBranch = $(git remote show origin | Select-String "HEAD branch: (.*)").Matches[0].Groups[1].Value + $prBranchPrefix = '' + } else { + $mainBranch = $env:TARGET_BRANCH + $prBranchPrefix = $mainBranch + '-' + } + $prBranch = switch ($env:PR_STRATEGY) + { + 'create' { 'deps/' + $env:DEPENDENCY_PATH + '/' + $env:LATEST_TAG } + 'update' { 'deps/' + $env:DEPENDENCY_PATH } + default { throw ('Unknown PR strategy "' + $env:PR_STRATEGY + '".') } + } + $prBranch = $prBranchPrefix + $prBranch + ('baseBranch=' + $mainBranch) | Tee-Object $env:GITHUB_OUTPUT -Append + ('prBranch=' + $prBranch) | Tee-Object $env:GITHUB_OUTPUT -Append + $nonBotCommits = ${{ github.action_path }}/scripts/nonbot-commits.ps1 ` + -RepoUrl $(git config --get remote.origin.url) -PrBranch $prBranch -MainBranch $mainBranch + $changed = $nonBotCommits.Length -gt 0 ? 'true' : 'false' + ('changed=' + $changed) | Tee-Object $env:GITHUB_OUTPUT -Append + if ($changed -eq 'true') + { + Write-Output ('::warning::Target branch "' + $prBranch + '" has been changed manually - skipping updater to avoid overwriting these changes.') + } + + - name: Parse the existing PR URL + if: ${{ ( steps.target.outputs.latestTag != steps.target.outputs.originalTag ) && ( steps.root.outputs.changed == 'false') }} + id: existing-pr + shell: pwsh + working-directory: caller-repo + env: + GH_TOKEN: ${{ inputs.api-token || github.token }} + BASE_BRANCH: ${{ steps.root.outputs.baseBranch }} + PR_BRANCH: ${{ steps.root.outputs.prBranch }} + run: | + # Use -f to let gh URL-encode query params; PR branch can contain '#' from CMake dependency paths. + $head = '${{ github.repository_owner }}:' + $env:PR_BRANCH + $base = $env:BASE_BRANCH + $urls = @(gh api 'repos/${{ github.repository }}/pulls' ` + -X GET ` + -f ('base=' + $base) ` + -f ('head=' + $head) ` + --jq '.[].html_url') + if ($urls.Length -eq 0) + { + 'url=' | Tee-Object $env:GITHUB_OUTPUT -Append + } + elseif ($urls.Length -eq 1) + { + ('url=' + $urls[0]) | Tee-Object $env:GITHUB_OUTPUT -Append + } + else + { + throw ('Unexpected number of PRs matched (' + $urls.Length + '): ' + $urls) + } + + - name: Show git diff + if: ${{ ( steps.target.outputs.latestTag != steps.target.outputs.originalTag ) && ( steps.existing-pr.outputs.url == '') && ( steps.root.outputs.changed == 'false') }} + shell: bash + working-directory: caller-repo + run: git --no-pager diff + + - name: Get target changelog + if: ${{ ( steps.target.outputs.latestTag != steps.target.outputs.originalTag ) && ( steps.root.outputs.changed == 'false') }} + shell: pwsh + working-directory: caller-repo + env: + GH_TOKEN: ${{ inputs.api-token || github.token }} + TARGET_REPO_URL: ${{ steps.target.outputs.url }} + ORIGINAL_TAG: ${{ steps.target.outputs.originalTag }} + LATEST_TAG: ${{ steps.target.outputs.latestTag }} + run: | + $changelog = ${{ github.action_path }}/scripts/get-changelog.ps1 ` + -RepoUrl $env:TARGET_REPO_URL ` + -OldTag $env:ORIGINAL_TAG ` + -NewTag $env:LATEST_TAG + ${{ github.action_path }}/scripts/set-github-env.ps1 TARGET_CHANGELOG $changelog + + # First we create a PR only if it doesn't exist. We will later overwrite the content with the same action. + - name: Create a PR + if: ${{ ( steps.target.outputs.latestTag != steps.target.outputs.originalTag ) && ( steps.existing-pr.outputs.url == '') && ( steps.root.outputs.changed == 'false') }} + uses: peter-evans/create-pull-request@a4f52f8033a6168103c2538976c07b467e8163bc # pin#v6.0.1 + id: create-pr + env: + DEPENDENCY_PATH: ${{ inputs.path }} + DEPENDENCY_NAME: ${{ inputs.name }} + with: + path: caller-repo + base: ${{ steps.root.outputs.baseBranch }} + branch: ${{ steps.root.outputs.prBranch }} + commit-message: 'chore: update ${{ env.DEPENDENCY_PATH }} to ${{ steps.target.outputs.latestTag }}' + author: 'GitHub ' + title: 'chore(deps): update ${{ env.DEPENDENCY_NAME }} to ${{ steps.target.outputs.latestTagNice }}' + body: | + Bumps ${{ env.DEPENDENCY_PATH }} from ${{ steps.target.outputs.originalTag }} to ${{ steps.target.outputs.latestTag }}. + + Auto-generated by a [dependency updater](https://github.com/getsentry/github-workflows/blob/main/updater/action.yml). + ${{ env.TARGET_CHANGELOG }} + labels: dependencies + + - name: Verify we have a PR + if: ${{ ( steps.target.outputs.latestTag != steps.target.outputs.originalTag ) && ( steps.root.outputs.changed == 'false') }} + id: pr + shell: pwsh + working-directory: caller-repo + env: + CREATED_PR_URL: ${{ steps.create-pr.outputs.pull-request-url }} + EXISTING_PR_URL: ${{ steps.existing-pr.outputs.url }} + run: | + if (-not [string]::IsNullOrEmpty($env:CREATED_PR_URL)) + { + ("url=" + $env:CREATED_PR_URL) | Tee-Object $env:GITHUB_OUTPUT -Append + } + elseif (-not [string]::IsNullOrEmpty($env:EXISTING_PR_URL)) + { + ("url=" + $env:EXISTING_PR_URL) | Tee-Object $env:GITHUB_OUTPUT -Append + } + else + { + throw "PR hasn't been created" + } + + # If we had to create a new PR, we must do a clean checkout & update the submodule again. + # If we didn't do this, the new PR would only have a changelog... + - name: 'After new PR: restore repo' + if: ${{ ( steps.target.outputs.latestTag != steps.target.outputs.originalTag ) && ( steps.existing-pr.outputs.url == '') && ( steps.root.outputs.changed == 'false') }} + uses: actions/checkout@v4 + with: + token: ${{ inputs.api-token || github.token }} + ssh-key: ${{ inputs.ssh-key }} + ref: ${{ inputs.target-branch || github.ref }} + path: caller-repo + + - name: 'After new PR: redo the update' + if: ${{ ( steps.target.outputs.latestTag != steps.target.outputs.originalTag ) && ( steps.existing-pr.outputs.url == '') && ( steps.root.outputs.changed == 'false') }} + shell: pwsh + working-directory: caller-repo + env: + DEPENDENCY_PATH: ${{ inputs.path }} + POST_UPDATE_SCRIPT: ${{ inputs.post-update-script }} + GH_TOKEN: ${{ inputs.api-token || github.token }} + LATEST_TAG: ${{ steps.target.outputs.latestTag }} + ORIGINAL_TAG: ${{ steps.target.outputs.originalTag }} + run: ${{ github.action_path }}/scripts/update-dependency.ps1 -Path $env:DEPENDENCY_PATH -Tag $env:LATEST_TAG -OriginalTag $env:ORIGINAL_TAG -PostUpdateScript $env:POST_UPDATE_SCRIPT + + - name: Update Changelog + if: ${{ inputs.changelog-entry == 'true' && ( steps.target.outputs.latestTag != steps.target.outputs.originalTag ) && ( steps.root.outputs.changed == 'false') }} + shell: pwsh + working-directory: caller-repo + env: + DEPENDENCY_NAME: ${{ inputs.name }} + CHANGELOG_SECTION: ${{ inputs.changelog-section }} + GH_TOKEN: ${{ inputs.api-token || github.token }} + PR_URL: ${{ steps.pr.outputs.url }} + TARGET_REPO_URL: ${{ steps.target.outputs.url }} + TARGET_MAIN_BRANCH: ${{ steps.target.outputs.mainBranch }} + ORIGINAL_TAG: ${{ steps.target.outputs.originalTag }} + LATEST_TAG: ${{ steps.target.outputs.latestTag }} + run: | + ${{ github.action_path }}/scripts/update-changelog.ps1 ` + -Name $env:DEPENDENCY_NAME ` + -PR $env:PR_URL ` + -RepoUrl $env:TARGET_REPO_URL ` + -MainBranch $env:TARGET_MAIN_BRANCH ` + -OldTag $env:ORIGINAL_TAG ` + -NewTag $env:LATEST_TAG ` + -Section $env:CHANGELOG_SECTION + + - name: Show final git diff + if: ${{ ( steps.target.outputs.latestTag != steps.target.outputs.originalTag ) && ( steps.root.outputs.changed == 'false') }} + shell: bash + working-directory: caller-repo + run: git --no-pager diff + + # Now make the PR in its final state. This way we only have one commit and no updates if there are no changes between runs. + - name: Update the PR + if: ${{ ( steps.target.outputs.latestTag != steps.target.outputs.originalTag ) && ( steps.root.outputs.changed == 'false') }} + uses: peter-evans/create-pull-request@a4f52f8033a6168103c2538976c07b467e8163bc # pin#v6.0.1 + id: update + env: + DEPENDENCY_PATH: ${{ inputs.path }} + DEPENDENCY_NAME: ${{ inputs.name }} + with: + path: caller-repo + base: ${{ steps.root.outputs.baseBranch }} + branch: ${{ steps.root.outputs.prBranch }} + commit-message: 'chore: update ${{ env.DEPENDENCY_PATH }} to ${{ steps.target.outputs.latestTag }}' + author: 'GitHub ' + title: 'chore(deps): update ${{ env.DEPENDENCY_NAME }} to ${{ steps.target.outputs.latestTagNice }}' + body: | + Bumps ${{ env.DEPENDENCY_PATH }} from ${{ steps.target.outputs.originalTag }} to ${{ steps.target.outputs.latestTag }}. + + Auto-generated by a [dependency updater](https://github.com/getsentry/github-workflows/blob/main/updater/action.yml). + ${{ env.TARGET_CHANGELOG }} + labels: dependencies + + - name: Trigger PR workflows + if: ${{ (inputs.changelog-entry != 'true') && ( steps.create-pr.outputs.pull-request-operation == 'created' ) && ( steps.update.outputs.pull-request-head-sha == '' || steps.update.outputs.pull-request-head-sha == steps.create-pr.outputs.pull-request-head-sha ) }} + shell: bash + working-directory: caller-repo + env: + PR_BRANCH: ${{ steps.root.outputs.prBranch }} + PR_HEAD_SHA: ${{ steps.create-pr.outputs.pull-request-head-sha }} + API_TOKEN: ${{ inputs.api-token }} + SSH_KEY: ${{ inputs.ssh-key }} + run: | + git fetch origin "$PR_BRANCH" + git checkout --force -B "$PR_BRANCH" "origin/$PR_BRANCH" + + current_head=$(git rev-parse HEAD) + if [ "$current_head" != "$PR_HEAD_SHA" ]; then + echo "::notice::PR branch changed from $PR_HEAD_SHA to $current_head; skipping synthetic CI trigger." + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit --amend --no-edit + + if [ -n "$API_TOKEN" ] && [ -z "$SSH_KEY" ]; then + git remote set-url origin "https://x-access-token:${API_TOKEN}@github.com/${{ github.repository }}.git" + fi + + git push --force-with-lease="refs/heads/$PR_BRANCH:$PR_HEAD_SHA" origin "HEAD:refs/heads/$PR_BRANCH" diff --git a/updater/scripts/cmake-functions.ps1 b/updater/scripts/cmake-functions.ps1 new file mode 100644 index 00000000..8ef4fb32 --- /dev/null +++ b/updater/scripts/cmake-functions.ps1 @@ -0,0 +1,208 @@ +# CMake FetchContent helper functions for update-dependency.ps1 + +function Parse-CMakeFetchContent { + [CmdletBinding()] + param( + [Parameter(Mandatory=$true)] + [ValidateScript({Test-Path $_ -PathType Leaf})] + [string]$filePath, + + [Parameter(Mandatory=$false)] + [ValidateScript({[string]::IsNullOrEmpty($_) -or $_ -match '^[a-zA-Z][a-zA-Z0-9_.-]*$'})] + [string]$depName + ) + $content = Get-Content $filePath -Raw + + if ($depName) { + $pattern = "FetchContent_Declare\s*\(\s*$depName\s+([^)]+)\)" + } else { + # Find all FetchContent_Declare blocks + $allMatches = [regex]::Matches($content, "FetchContent_Declare\s*\(\s*([a-zA-Z0-9_-]+)", 'Singleline') + if ($allMatches.Count -eq 1) { + $depName = $allMatches[0].Groups[1].Value + $pattern = "FetchContent_Declare\s*\(\s*$depName\s+([^)]+)\)" + } else { + throw "Multiple FetchContent declarations found. Use #DepName syntax." + } + } + + $match = [regex]::Match($content, $pattern, 'Singleline,IgnoreCase') + if (-not $match.Success) { + throw "FetchContent_Declare for '$depName' not found in $filePath" + } + $block = $match.Groups[1].Value + + # Look for GIT_REPOSITORY and GIT_TAG patterns specifically + # Exclude matches that are in comments (lines starting with #) + $repoMatch = [regex]::Match($block, '(?m)^\s*GIT_REPOSITORY\s+(\S+)') + $tagMatch = [regex]::Match($block, '(?m)^\s*GIT_TAG\s+(\S+)') + + $repo = if ($repoMatch.Success) { $repoMatch.Groups[1].Value } else { "" } + $tag = if ($tagMatch.Success) { $tagMatch.Groups[1].Value } else { "" } + + if ([string]::IsNullOrEmpty($repo) -or [string]::IsNullOrEmpty($tag)) { + throw "Could not parse GIT_REPOSITORY or GIT_TAG from FetchContent_Declare block" + } + + # Resolve CMake variable references like ${FOO_REF} + $gitTagVariable = $null + if ($tag -match '^\$\{(\w+)\}$') { + $gitTagVariable = $Matches[1] + $setMatch = [regex]::Match($content, "(?m)^\s*set\s*\(\s*$gitTagVariable\s+`"?([^`"\s)]+)") + if (-not $setMatch.Success) { + throw "CMake variable '$gitTagVariable' referenced by GIT_TAG not found in $filePath" + } + $tag = $setMatch.Groups[1].Value + } + + return @{ GitRepository = $repo; GitTag = $tag; DepName = $depName; GitTagVariable = $gitTagVariable } +} + +function Find-TagForHash { + [CmdletBinding()] + param( + [Parameter(Mandatory=$true)] + [ValidateNotNullOrEmpty()] + [string]$repo, + + [Parameter(Mandatory=$true)] + [ValidatePattern('^[a-f0-9]{40}$')] + [string]$hash + ) + try { + $refs = git ls-remote --tags $repo + if ($LASTEXITCODE -ne 0) { + throw "Failed to fetch tags from repository $repo (git ls-remote failed with exit code $LASTEXITCODE)" + } + foreach ($ref in $refs) { + $commit, $tagRef = $ref -split '\s+', 2 + if ($commit -eq $hash) { + return $tagRef -replace '^refs/tags/', '' + } + } + return $null + } + catch { + Write-Host "Warning: Could not resolve hash $hash to tag name: $_" + return $null + } +} + +function Test-HashAncestry { + [CmdletBinding()] + param( + [Parameter(Mandatory=$true)] + [ValidateNotNullOrEmpty()] + [string]$repo, + + [Parameter(Mandatory=$true)] + [ValidatePattern('^[a-f0-9]{40}$')] + [string]$oldHash, + + [Parameter(Mandatory=$true)] + [ValidatePattern('^[a-f0-9]{40}$')] + [string]$newHash + ) + try { + # Create a temporary directory for git operations + $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid()) + New-Item -ItemType Directory -Path $tempDir -Force | Out-Null + + try { + Push-Location $tempDir + + # Initialize a bare repository and add the remote + git init --bare 2>$null | Out-Null + git remote add origin $repo 2>$null | Out-Null + + # Fetch both commits + git fetch origin $oldHash 2>$null | Out-Null + git fetch origin $newHash 2>$null | Out-Null + + # Check if old hash is ancestor of new hash + git merge-base --is-ancestor $oldHash $newHash 2>$null + $isAncestor = $LastExitCode -eq 0 + + return $isAncestor + } + finally { + Pop-Location + Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue + } + } + catch { + Write-Host "Error: Could not validate ancestry for $oldHash -> $newHash : $_" + # When in doubt, fail safely to prevent incorrect updates + return $false + } +} + +function Update-CMakeFile { + [CmdletBinding()] + param( + [Parameter(Mandatory=$true)] + [ValidateScript({Test-Path $_ -PathType Leaf})] + [string]$filePath, + + [Parameter(Mandatory=$false)] + [ValidateScript({[string]::IsNullOrEmpty($_) -or $_ -match '^[a-zA-Z][a-zA-Z0-9_.-]*$'})] + [string]$depName, + + [Parameter(Mandatory=$true)] + [ValidateNotNullOrEmpty()] + [string]$newValue + ) + $content = Get-Content $filePath -Raw + $fetchContent = Parse-CMakeFetchContent $filePath $depName + $originalValue = $fetchContent.GitTag + $repo = $fetchContent.GitRepository + $wasHash = $originalValue -match '^[a-f0-9]{40}$' + + if ($wasHash) { + # Convert tag to hash and add comment + $newHashRefs = git ls-remote $repo "refs/tags/$newValue" + if ($LASTEXITCODE -ne 0) { + throw "Failed to fetch tag $newValue from repository $repo (git ls-remote failed with exit code $LASTEXITCODE)" + } + if (-not $newHashRefs) { + throw "Tag $newValue not found in repository $repo" + } + $newHash = ($newHashRefs -split '\s+')[0] + $replacement = "$newHash # $newValue" + + # Validate ancestry: ensure old hash is reachable from new tag + if (-not (Test-HashAncestry $repo $originalValue $newHash)) { + throw "Cannot update: hash $originalValue is not in history of tag $newValue" + } + } else { + $replacement = $newValue + } + + # Update the value, replacing entire line content after the value + # This removes potentially outdated version-specific comments + $gitTagVariable = $fetchContent.GitTagVariable + if ($gitTagVariable) { + # Update the set() line that defines the variable + $pattern = "(?m)(^\s*set\s*\(\s*$gitTagVariable\s+`"?)([^`"\s)]+)(`"?[^)]*\))[^\r\n]*" + $valueOnly = if ($wasHash) { $newHash } else { $newValue } + $trailingComment = if ($wasHash) { " # $newValue" } else { "" } + $newContent = [regex]::Replace($content, $pattern, "`${1}$valueOnly`${3}$trailingComment") + } else { + # Update GIT_TAG value in FetchContent_Declare block + $pattern = "(FetchContent_Declare\s*\(\s*$depName\s+[^)]*GIT_TAG\s+)[^\r\n]+(\r?\n[^)]*\))" + $newContent = [regex]::Replace($content, $pattern, "`${1}$replacement`${2}", 'Singleline') + } + + if ($newContent -eq $content) { + throw "Failed to update GIT_TAG in $filePath - pattern may not have matched" + } + + $newContent | Out-File $filePath -NoNewline + + # Verify the update worked + $verifyContent = Parse-CMakeFetchContent $filePath $depName + $expectedValue = $wasHash ? $newHash : $newValue + if ($verifyContent.GitTag -notmatch [regex]::Escape($expectedValue)) { + throw "Update verification failed - read-after-write did not match expected value" + } +} diff --git a/updater/scripts/get-changelog.ps1 b/updater/scripts/get-changelog.ps1 index 90d5d72d..c892160a 100644 --- a/updater/scripts/get-changelog.ps1 +++ b/updater/scripts/get-changelog.ps1 @@ -5,122 +5,267 @@ param( ) Set-StrictMode -Version latest +$PSNativeCommandErrorActionPreference = $false +$ErrorActionPreference = 'Stop' $prefix = 'https?://(www\.)?github.com/' -if (-not ($RepoUrl -match "^$prefix")) -{ - Write-Warning "Only github.com repositories are currently supported. Given RepoUrl doesn't look like one: $RepoUrl" +if (-not ($RepoUrl -match "^$prefix([^/]+)/([^/]+?)(?:\.git)?/?$")) { + Write-Warning "Only https://github.com repositories are currently supported. Could not parse repository from URL: $RepoUrl" return } +$repoOwner = $matches[2] +$repoName = $matches[3] +$apiRepo = "$repoOwner/$repoName" + +# Create temporary directory for changelog files $tmpDir = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid()) New-Item -ItemType Directory $tmpDir | Out-Null -try -{ - git clone --depth 1 $RepoUrl $tmpDir +# Function to try different changelog filenames +function Get-ChangelogContent { + param($ref, $filePath) - $file = $(Get-ChildItem -Path $tmpDir | Where-Object { $_.Name -match '^changelog(\.md|\.txt|)$' } ) - if ("$file" -eq '') - { - Write-Warning "Couldn't find a changelog" - return - } - elseif ($file -is [Array]) - { - Write-Warning "Multiple changelogs found: $file" - return + $changelogNames = @('CHANGELOG.md', 'changelog.md', 'CHANGELOG.txt', 'changelog.txt', 'CHANGELOG') + + foreach ($name in $changelogNames) { + try { + # Try fetching directly from raw.githubusercontent.com + $rawUrl = "https://raw.githubusercontent.com/$apiRepo/$ref/$name" + $content = Invoke-RestMethod -Uri $rawUrl -Method Get -ErrorAction SilentlyContinue + if ($content) { + Set-Content -Path $filePath -Value $content -Encoding UTF8 + Write-Host "Found $name for ref $ref" + return $true + } + } catch { + # Continue to next filename + } } - Write-Host "Found changelog: $file" - [string[]]$lines = Get-Content $file -} -finally -{ - Write-Host "Removing $tmpDir" - Remove-Item -Recurse -Force -ErrorAction Continue -Path $tmpDir + return $false } -$startIndex = -1 -$endIndex = -1 -$changelog = '' -for ($i = 0; $i -lt $lines.Count; $i++) -{ - $line = $lines[$i] - - if ($startIndex -lt 0) - { - if ($line -match "^#+ +v?$NewTag\b") - { - $startIndex = $i - } +# Function to generate changelog from git commits +function Get-ChangelogFromCommits { + param($repoUrl, $oldTag, $newTag, $tmpDir) + + # Clone the repository + $repoDir = Join-Path $tmpDir 'repo' + Write-Host "Cloning repository to generate changelog from commits..." + git clone --no-single-branch --quiet $repoUrl $repoDir + if ($LASTEXITCODE -ne 0) { + Write-Warning "Could not clone repository $repoUrl" + return $null } - elseif ($line -match "^#+ +v?$OldTag\b") - { - $endIndex = $i - 1 - break + + if (-not (Test-Path $repoDir)) { + Write-Warning "Repository directory was not created successfully" + return $null } -} -# If the changelog doesn't have a section for the oldTag, stop at the first SemVer that's lower than oldTag. -if ($endIndex -lt 0) -{ - $endIndex = $lines.Count - 1 # fallback, may be overwritten below - try - { - $semverOldTag = [System.Management.Automation.SemanticVersion]::Parse($OldTag) - for ($i = $startIndex; $i -lt $lines.Count; $i++) - { - $line = $lines[$i] - if ($line -match '^#+ +v?([0-9]+.*)$') - { - try - { - if ($semverOldTag -ge [System.Management.Automation.SemanticVersion]::Parse($matches[1])) - { - $endIndex = $i - 1 - break - } - } - catch {} + Push-Location $repoDir + try { + # Ensure we have both tags + git fetch --tags --quiet + if ($LASTEXITCODE -ne 0) { + Write-Warning "Could not fetch tags from repository" + return $null + } + + # Get commit messages between tags + Write-Host "Getting commits between $oldTag and $newTag..." + $commitMessages = git log "$oldTag..$newTag" --pretty=format:'%s' + if ($LASTEXITCODE -ne 0) { + Write-Warning "Could not get commits between $oldTag and $newTag (exit code: $LASTEXITCODE)" + return $null + } + + if ([string]::IsNullOrEmpty($commitMessages)) { + Write-Host "No commits found between $oldTag and $newTag" + return $null + } + + # Filter out version tag commits and format as list + $commits = $commitMessages -split "`n" | + Where-Object { + $_ -and + $_ -notmatch '^\s*v?\d+\.\d+\.\d+' -and # Skip version commits + $_.Trim().Length -gt 0 + } | + ForEach-Object { "- $_" } + + if ($commits.Count -eq 0) { + Write-Host "No meaningful commits found between $oldTag and $newTag" + return $null + } + + # Create changelog from commits + $changelog = "## Changelog`n`n" + $changelog += "### Commits between $oldTag and $newTag`n`n" + $changelog += $commits -join "`n" + + Write-Host "Generated changelog from $($commits.Count) commits" + return $changelog + } + catch { + Write-Warning "Error generating changelog from commits: $($_.Exception.Message)" + return $null + } + finally { + Pop-Location + # Ensure repository directory is cleaned up + if (Test-Path $repoDir) { + try { + Remove-Item -Recurse -Force $repoDir -ErrorAction SilentlyContinue + Write-Host "Cleaned up temporary repository directory" + } + catch { + Write-Warning "Could not clean up temporary repository directory: $repoDir" } } } - catch {} } -# Slice changelog lines from startIndex to endIndex. -if ($startIndex -ge 0) -{ - $changelog = ($lines[$startIndex..$endIndex] -join "`n").Trim() -} -else -{ - $changelog = '' +# Function to generate changelog from diff between changelog files +function Get-ChangelogFromDiff { + param($oldTag, $newTag, $tmpDir) + + # Try to fetch changelog files for both tags + $oldChangelogPath = Join-Path $tmpDir 'old-changelog.md' + $hasOldChangelog = Get-ChangelogContent $oldTag $oldChangelogPath + + $newChangelogPath = Join-Path $tmpDir 'new-changelog.md' + $hasNewChangelog = Get-ChangelogContent $newTag $newChangelogPath + + # Return null if we don't have both changelog files + if (-not $hasOldChangelog -or -not $hasNewChangelog) { + return $null + } + + Write-Host "Generating changelog diff between $oldTag and $newTag..." + + # Generate diff using git diff --no-index + # git diff returns exit code 1 when differences are found, which is expected behavior + $fullDiff = git diff --no-index $oldChangelogPath $newChangelogPath + + # The first lines are diff metadata, skip them + $fullDiff = $fullDiff -split "`n" | Select-Object -Skip 4 + if ([string]::IsNullOrEmpty("$fullDiff")) { + Write-Host "No differences found between $oldTag and $newTag" + return $null + } else { + Write-Host "Successfully created a changelog diff - $($fullDiff.Count) lines" + } + + # Extract only the added lines (lines starting with + but not ++) + $addedLines = $fullDiff | Where-Object { $_ -match '^[+][^+]*' } | ForEach-Object { $_.Substring(1) } + + if ($addedLines.Count -eq 0) { + Write-Host "No changelog additions found between $oldTag and $newTag" + return $null + } + + # Create clean changelog from added lines + $changelog = ($addedLines -join "`n").Trim() + + if ($changelog.Length -eq 0) { + return $null + } + + # Add header if needed + if (-not ($changelog -match '^(##|#) Changelog')) { + $changelog = "## Changelog`n`n$changelog" + } + + # Increase header level by one for content (not the main header) + $changelog = $changelog -replace '(^|\n)(#+) ', '$1$2# ' -replace '^### Changelog', '## Changelog' + + # Only add details section if there are deletions or modifications (not just additions) + $hasModifications = $fullDiff | Where-Object { $_ -match '^[-]' -and $_ -notmatch '^[-]{3}' } + if ($hasModifications) { + $changelog += "`n`n
`nFull CHANGELOG.md diff`n`n" + $changelog += '```diff' + "`n" + $changelog += $fullDiff -join "`n" + $changelog += "`n" + '```' + "`n`n
" + } + + return $changelog } -if ($changelog.Length -gt 1) -{ - $changelog = "# Changelog`n$changelog" - # Increase header level by one. - $changelog = $changelog -replace '(^|\n)(#+) ', '$1$2# ' - # Remove at-mentions. + +# Function to sanitize and format changelog content +function Format-ChangelogContent { + param($changelog, $repoUrl) + + if ([string]::IsNullOrEmpty($changelog)) { + return $null + } + + # Apply standard formatting + # Remove at-mentions $changelog = $changelog -replace '@', '' - # Make PR/issue references into links to the original repository (unless they already are links). - $changelog = $changelog -replace '(? :warning: **Changelog content truncated by $($oldLength - $changelog.Length) characters because it was over the limit ($limit) and wouldn't fit into PR description.**" + } + + Write-Host "Final changelog length: $($changelog.Length) characters" + return $changelog } -# Limit the changelog length to ~60k to allow for other text in the PR body (total PR limit is 65536 characters). -$limit = 60000 -if ($changelog.Length -gt $limit) -{ - $oldLength = $changelog.Length - Write-Warning "Truncating changelog because it's $($changelog.Length - $limit) characters longer than the limit $limit." - while ($changelog.Length -gt $limit) - { - $changelog = $changelog.Substring(0, $changelog.LastIndexOf("`n")) - } - $changelog += "`n`n> :warning: **Changelog content truncated by $($oldLength - $changelog.Length) characters because it was over the limit ($limit) and wouldn't fit into PR description.**" +try { + Write-Host 'Fetching CHANGELOG files for comparison...' + + $changelog = $null + + # Try changelog file diff first, fall back to git commits if not available + $changelog = Get-ChangelogFromDiff $OldTag $NewTag $tmpDir + + # Fall back to git commits if no changelog files or no diff found + if (-not $changelog) { + Write-Host "No changelog files found or no changes detected, falling back to git commits..." + $changelog = Get-ChangelogFromCommits $RepoUrl $OldTag $NewTag $tmpDir + } + + # Apply formatting and output result + if ($changelog) { + $formattedChangelog = Format-ChangelogContent $changelog $RepoUrl + if ($formattedChangelog) { + Write-Output $formattedChangelog + } else { + Write-Host "No changelog content to display after formatting" + } + } else { + Write-Host "No changelog found between $OldTag and $NewTag" + } +} catch { + Write-Warning "Failed to get changelog: $($_.Exception.Message)" +} finally { + if (Test-Path $tmpDir) { + Write-Host 'Cleaning up temporary files...' + Remove-Item -Recurse -Force -ErrorAction Continue $tmpDir + Write-Host 'Cleanup complete.' + } } -$changelog +# This resets the $LASTEXITCODE set by git diff above. +# Note that this only runs in the successful path. +exit 0 diff --git a/updater/scripts/update-changelog.ps1 b/updater/scripts/update-changelog.ps1 index 5c054e1e..1ab2ec59 100644 --- a/updater/scripts/update-changelog.ps1 +++ b/updater/scripts/update-changelog.ps1 @@ -97,7 +97,6 @@ for ($i = 0; $i -lt $lines.Count; $i++) throw "Prettier comment format - expected , but found: '$line'" } # End of prettier comment - # Next, we expect a header if (-not $line.StartsWith("#")) @@ -180,9 +179,8 @@ for ($i = 0; $i -lt $sectionEnd; $i++) if (!$updated) { # Find what character is used as a bullet-point separator - look for the first bullet-point object that wasn't created by this script. - $bulletPoint = $lines | Where-Object { ($_ -match "^ *[-*] ") -and -not ($_ -match "(Bump .* to|\[changelog\]|\[diff\])") } | Select-Object -First 1 - $bulletPoint = "$bulletPoint-"[0] - + $bulletPoint = $lines | Where-Object { ($_ -match '^ *[-*] ') -and -not ($_ -match '(Bump .* to|\[changelog\]|\[diff\])') } | Select-Object -First 1 + $bulletPoint = "$("$bulletPoint".Trim())-"[0] $entry = @("$bulletPoint Bump $Name from $oldTagNice to $newTagNice ($PullRequestMD)", " $bulletPoint [changelog]($RepoUrl/blob/$MainBranch/CHANGELOG.md#$tagAnchor)", " $bulletPoint [diff]($RepoUrl/compare/$OldTag...$NewTag)") diff --git a/updater/scripts/update-dependency.ps1 b/updater/scripts/update-dependency.ps1 index c9af07b0..b17b50a4 100644 --- a/updater/scripts/update-dependency.ps1 +++ b/updater/scripts/update-dependency.ps1 @@ -6,122 +6,151 @@ param( # * `get-version` - return the currently specified dependency version # * `get-repo` - return the repository url (e.g. https://github.com/getsentry/dependency) # * `set-version` - update the dependency version (passed as another string argument after this one) + # - a CMake file (.cmake) with FetchContent_Declare statements: + # * Use `path/to/file.cmake#DepName` to specify dependency name + # * Or just `path/to/file.cmake` if file contains single FetchContent_Declare [Parameter(Mandatory = $true)][string] $Path, # RegEx pattern that will be matched against available versions when picking the latest one [string] $Pattern = '', + # RegEx pattern to match against GitHub release titles. Only releases with matching titles will be considered + [string] $GhTitlePattern = '', # Specific version - if passed, no discovery is performed and the version is set directly - [string] $Tag = '' + [string] $Tag = '', + # Version that the dependency was on before the update - should be only passed if $Tag is set. Necessary for PostUpdateScript. + [string] $OriginalTag = '', + # Optional post-update script to run after successful dependency update + # The script receives the original and new version as arguments + [string] $PostUpdateScript = '' ) +$ErrorActionPreference = 'Stop' Set-StrictMode -Version latest . "$PSScriptRoot/common.ps1" -if (-not (Test-Path $Path )) -{ +# Parse CMake file with dependency name +if ($Path -match '^(.+\.cmake)(#(.+))?$') { + $Path = $Matches[1] # Set Path to file for existing logic + if ($Matches[3]) { + $cmakeDep = $Matches[3] + # Validate dependency name follows CMake naming conventions + if ($cmakeDep -notmatch '^[a-zA-Z][a-zA-Z0-9_.-]*$') { + throw "Invalid CMake dependency name: '$cmakeDep'. Must start with letter and contain only alphanumeric, underscore, dot, or hyphen." + } + } else { + $cmakeDep = $null # Will auto-detect + } + $isCMakeFile = $true +} else { + $cmakeDep = $null + $isCMakeFile = $false +} + +if (-not (Test-Path $Path )) { throw "Dependency $Path doesn't exit"; } # If it's a directory, we consider it a submodule dependendency. Otherwise, it must a properties-style file or a script. $isSubmodule = (Test-Path $Path -PathType Container) -function SetOutput([string] $name, $value) -{ - if (Test-Path env:GITHUB_OUTPUT) - { +function SetOutput([string] $name, $value) { + if (Test-Path env:GITHUB_OUTPUT) { "$name=$value" | Tee-Object $env:GITHUB_OUTPUT -Append - } - else - { + } else { "$name=$value" } } -if (-not $isSubmodule) -{ +if (-not $isSubmodule) { $isScript = $Path -match '\.(ps1|sh)$' - function DependencyConfig ([Parameter(Mandatory = $true)][string] $action, [string] $value = $null) - { - if ($isScript) - { - if (Get-Command 'chmod' -ErrorAction SilentlyContinue) - { + function DependencyConfig ([Parameter(Mandatory = $true)][string] $action, [string] $value = $null) { + if ($isCMakeFile) { + # CMake file handling + switch ($action) { + 'get-version' { + $fetchContent = Parse-CMakeFetchContent $Path $cmakeDep + $currentValue = $fetchContent.GitTag + if ($currentValue -match '^[a-f0-9]{40}$') { + # Try to resolve hash to tag for version comparison + $repo = $fetchContent.GitRepository + $tagForHash = Find-TagForHash $repo $currentValue + return $tagForHash ?? $currentValue + } + return $currentValue + } + 'get-repo' { + return (Parse-CMakeFetchContent $Path $cmakeDep).GitRepository + } + 'set-version' { + Update-CMakeFile $Path $cmakeDep $value + } + default { + throw "Unknown action $action" + } + } + } elseif ($isScript) { + if (Get-Command 'chmod' -ErrorAction SilentlyContinue) { chmod +x $Path - if ($LastExitCode -ne 0) - { + if ($LastExitCode -ne 0) { throw 'chmod failed'; } } - try - { + try { $result = & $Path $action $value $failed = -not $? - } - catch - { + } catch { $result = $_ $failed = $true } - if ($failed) - { + if ($failed) { throw "Script execution failed: $Path $action $value | output: $result" } return $result - } - else - { - switch ($action) - { - 'get-version' - { + } else { + switch ($action) { + 'get-version' { return (Get-Content $Path -Raw | ConvertFrom-StringData).version } - 'get-repo' - { + 'get-repo' { return (Get-Content $Path -Raw | ConvertFrom-StringData).repo } - 'set-version' - { + 'set-version' { $content = Get-Content $Path $content = $content -replace '^(?version *= *).*$', "`${prop}$value" $content | Out-File $Path $readVersion = (Get-Content $Path -Raw | ConvertFrom-StringData).version - if ("$readVersion" -ne "$value") - { + if ("$readVersion" -ne "$value") { throw "Update failed - read-after-write yielded '$readVersion' instead of expected '$value'" } } - Default - { + default { throw "Unknown action $action" } } } } + + # Load CMake helper functions + . "$PSScriptRoot/cmake-functions.ps1" } -if ("$Tag" -eq '') -{ - if ($isSubmodule) - { +if ("$Tag" -eq '') { + $OriginalTag | Should -Be '' + + if ($isSubmodule) { git submodule update --init --no-fetch --single-branch $Path Push-Location $Path - try - { + try { $originalTag = $(git describe --tags) git fetch --tags [string[]]$tags = $(git tag --list) $url = $(git remote get-url origin) $mainBranch = $(git remote show origin | Select-String 'HEAD branch: (.*)').Matches[0].Groups[1].Value - } - finally - { + } finally { Pop-Location } - } - else - { + } else { $originalTag = DependencyConfig 'get-version' $url = DependencyConfig 'get-repo' @@ -130,17 +159,50 @@ if ("$Tag" -eq '') $tags = $tags | ForEach-Object { ($_ -split '\s+')[1] -replace '^refs/tags/', '' } $headRef = ($(git ls-remote $url HEAD) -split '\s+')[0] - if ("$headRef" -eq '') - { + if ("$headRef" -eq '') { throw "Couldn't determine repository head (no ref returned by ls-remote HEAD" } - $mainBranch = (git ls-remote --heads $url | Where-Object { $_.StartsWith($headRef) }) -replace '.*\srefs/heads/', '' + $mainBranch = (git ls-remote --heads $url | Where-Object { $_.StartsWith($headRef) } | Select-Object -First 1) -replace '.*\srefs/heads/', '' } $url = $url -replace '\.git$', '' - if ("$Pattern" -eq '') - { + # Filter by GitHub release titles if pattern is provided + if ("$GhTitlePattern" -ne '') { + Write-Host "Filtering tags by GitHub release title pattern '$GhTitlePattern'" + + # Parse GitHub repo owner/name from URL + if ($url -notmatch 'github\.com[:/]([^/]+)/([^/]+?)(?:\.git)?$') { + throw "Could not parse GitHub owner/repo from URL: $url" + } + + $owner, $repo = $Matches[1], $Matches[2] + + # Fetch releases from GitHub API + $releases = @(gh api "repos/$owner/$repo/releases" --paginate --jq '.[] | {tag_name: .tag_name, name: .name}' | ConvertFrom-Json) + if ($LASTEXITCODE -ne 0) { + throw "Failed to fetch GitHub releases from $owner/$repo (exit code: $LASTEXITCODE)" + } + + # Find tags that have matching release titles + $validTags = @{} + foreach ($release in $releases) { + if ($release.name -match $GhTitlePattern) { + $validTags[$release.tag_name] = $true + } + } + + # Filter tags to only include those with matching release titles + $originalTagCount = $tags.Length + $tags = @($tags | Where-Object { $validTags.ContainsKey($_) }) + Write-Host "GitHub release title filtering: $originalTagCount -> $($tags.Count) tags" + + if ($tags.Count -eq 0) { + throw "Found no tags with GitHub releases matching title pattern '$GhTitlePattern'" + } + } + + if ("$Pattern" -eq '') { # Use a default pattern that excludes pre-releases $Pattern = '^v?([0-9.]+)$' } @@ -148,8 +210,7 @@ if ("$Tag" -eq '') Write-Host "Filtering tags with pattern '$Pattern'" $tags = $tags -match $Pattern - if ($tags.Length -le 0) - { + if ($tags.Length -le 0) { throw "Found no tags matching pattern '$Pattern'" } @@ -158,14 +219,11 @@ if ("$Tag" -eq '') Write-Host "Sorted tags: $tags" $latestTag = $tags[-1] - if (("$originalTag" -ne '') -and ("$latestTag" -ne '') -and ("$latestTag" -ne "$originalTag")) - { - do - { + if (("$originalTag" -ne '') -and ("$latestTag" -ne '') -and ("$latestTag" -ne "$originalTag")) { + do { # It's possible that the dependency was updated to a pre-release version manually in which case we don't want to # roll back, even though it's not the latest version matching the configured pattern. - if ((GetComparableVersion $originalTag) -ge (GetComparableVersion $latestTag)) - { + if ((GetComparableVersion $originalTag) -ge (GetComparableVersion $latestTag)) { Write-Host "SemVer represented by the original tag '$originalTag' is newer than the latest tag '$latestTag'. Skipping update." $latestTag = $originalTag break @@ -175,8 +233,7 @@ if ("$Tag" -eq '') $refs = $(git ls-remote --tags $url) $refOriginal = (($refs -match "refs/tags/$originalTag" ) -split '[ \t]') | Select-Object -First 1 $refLatest = (($refs -match "refs/tags/$latestTag" ) -split '[ \t]') | Select-Object -First 1 - if ($refOriginal -eq $refLatest) - { + if ($refOriginal -eq $refLatest) { Write-Host "Latest tag '$latestTag' points to the same commit as the original tag '$originalTag'. Skipping update." $latestTag = $originalTag break @@ -192,23 +249,43 @@ if ("$Tag" -eq '') SetOutput 'url' $url SetOutput 'mainBranch' $mainBranch - if ("$originalTag" -eq "$latestTag") - { + if ("$originalTag" -eq "$latestTag") { return } $Tag = $latestTag +} else { + $OriginalTag | Should -Not -Be '' } -if ($isSubmodule) -{ +if ($isSubmodule) { Write-Host "Updating submodule $Path to $Tag" Push-Location $Path git checkout $Tag Pop-Location -} -else -{ +} else { Write-Host "Updating 'version' in $Path to $Tag" DependencyConfig 'set-version' $tag } + +# Run post-update script if provided +if ("$PostUpdateScript" -ne '') { + Write-Host "Running post-update script: $PostUpdateScript" + if (-not (Test-Path $PostUpdateScript)) { + throw "Post-update script not found: $PostUpdateScript" + } + + if (Get-Command 'chmod' -ErrorAction SilentlyContinue) { + chmod +x $PostUpdateScript + if ($LastExitCode -ne 0) { + throw 'chmod failed'; + } + } + + & $PostUpdateScript "$originalTag" "$tag" + if ($LastExitCode -ne 0) { + throw "Post-update script failed with exit code $LastExitCode" + } + + Write-Host '✓ Post-update script completed successfully' +} diff --git a/updater/tests/get-changelog.Tests.ps1 b/updater/tests/get-changelog.Tests.ps1 index 0a09e0db..aaa48c15 100644 --- a/updater/tests/get-changelog.Tests.ps1 +++ b/updater/tests/get-changelog.Tests.ps1 @@ -2,31 +2,15 @@ Describe 'get-changelog' { It 'with existing versions' { $actual = & "$PSScriptRoot/../scripts/get-changelog.ps1" ` - -RepoUrl 'https://github.com/getsentry/github-workflows' -OldTag '1.0.0' -NewTag '2.1.0' + -RepoUrl 'https://github.com/getsentry/github-workflows' -OldTag 'v2.0.0' -NewTag 'v2.1.0' $expected = @' ## Changelog + ### 2.1.0 #### Features - New reusable workflow, `danger.yml`, to check Pull Requests with predefined rules ([#34](https://github-redirect.dependabot.com/getsentry/github-workflows/pull/34)) - -### 2.0.0 - -#### Changes - -- Rename `api_token` secret to `api-token` ([#21](https://github-redirect.dependabot.com/getsentry/github-workflows/pull/21)) -- Change changelog target section header from "Features" to "Dependencies" ([#19](https://github-redirect.dependabot.com/getsentry/github-workflows/pull/19)) - -#### Features - -- Add `pr-strategy` switch to choose between creating new PRs or updating an existing one ([#22](https://github-redirect.dependabot.com/getsentry/github-workflows/pull/22)) -- Add `changelog-section` input setting to specify target changelog section header ([#19](https://github-redirect.dependabot.com/getsentry/github-workflows/pull/19)) - -#### Fixes - -- Preserve changelog bullet-point format ([#20](https://github-redirect.dependabot.com/getsentry/github-workflows/pull/20)) -- Changelog section parsing when an entry text contains the section name in the text ([#25](https://github-redirect.dependabot.com/getsentry/github-workflows/pull/25)) '@ $actual | Should -Be $expected @@ -34,7 +18,7 @@ Describe 'get-changelog' { It 'with missing versions' { $actual = & "$PSScriptRoot/../scripts/get-changelog.ps1" ` - -RepoUrl 'https://github.com/getsentry/sentry-javascript' -OldTag 'XXXXXXX' -NewTag 'YYYYYYYYY' + -RepoUrl 'https://github.com/getsentry/github-workflows' -OldTag 'XXXXXXX' -NewTag 'YYYYYYYYY' $actual | Should -BeNullOrEmpty } @@ -57,6 +41,7 @@ Describe 'get-changelog' { -RepoUrl 'https://github.com/getsentry/sentry-cli' -OldTag '2.1.0' -NewTag '2.2.0' $expected = @' ## Changelog + ### 2.2.0 #### Various fixes & improvements @@ -73,6 +58,7 @@ Describe 'get-changelog' { -RepoUrl 'https://github.com/getsentry/sentry-native' -OldTag '0.4.16' -NewTag '0.4.17' $expected = @' ## Changelog + ### 0.4.17 **Fixes**: @@ -91,15 +77,17 @@ Features, fixes and improvements in this release have been contributed by: It 'Does not show versions older than OldTag even if OldTag is missing' { $actual = & "$PSScriptRoot/../scripts/get-changelog.ps1" ` - -RepoUrl 'https://github.com/getsentry/github-workflows' -OldTag '2.1.5' -NewTag '2.2.1' + -RepoUrl 'https://github.com/getsentry/github-workflows' -OldTag 'v2.1.1' -NewTag 'v2.2.1' $actual | Should -Be @' ## Changelog + ### 2.2.1 #### Fixes - Support comments when parsing pinned actions in Danger ([#40](https://github-redirect.dependabot.com/getsentry/github-workflows/pull/40)) + ### 2.2.0 #### Features @@ -110,7 +98,7 @@ Features, fixes and improvements in this release have been contributed by: It 'truncates too long text' { $actual = & "$PSScriptRoot/../scripts/get-changelog.ps1" ` - -RepoUrl 'https://github.com/getsentry/sentry-cli' -OldTag '1.0.0' -NewTag '2.4.0' + -RepoUrl 'https://github.com/getsentry/sentry-cli' -OldTag '1.60.0' -NewTag '2.32.0' if ($actual.Length -gt 61000) { throw "Expected the content to be truncated to less-than 61k characters, but got: $($actual.Length)" @@ -128,16 +116,269 @@ Features, fixes and improvements in this release have been contributed by: -RepoUrl 'https://github.com/getsentry/sentry-native' -OldTag '0.7.17' -NewTag '0.7.18' $expected = @' ## Changelog + ### 0.7.18 **Features**: - Add support for Xbox Series X/S. ([#1100](https://github-redirect.dependabot.com/getsentry/sentry-native/pull/1100)) - Add option to set debug log level. ([#1107](https://github-redirect.dependabot.com/getsentry/sentry-native/pull/1107)) -- Add `traces_sampler`. ([#1108](https://github-redirect.dependabot.com/getsentry/sentry-native/pull/1108)) +- Add `traces_sampler` ([#1108](https://github-redirect.dependabot.com/getsentry/sentry-native/pull/1108)) - Provide support for C++17 compilers when using the `crashpad` backend. ([#1110](https://github-redirect.dependabot.com/getsentry/sentry-native/pull/1110), [crashpad#116](https://github-redirect.dependabot.com/getsentry/crashpad/pull/116), [mini_chromium#1](https://github-redirect.dependabot.com/getsentry/mini_chromium/pull/1)) '@ $actual | Should -Be $expected } + + It 'handles commit SHA as OldTag by resolving to tag' { + # Test with a SHA that corresponds to a known tag (0.9.1) + # This should resolve the SHA to the tag and use normal changelog logic + $actual = & "$PSScriptRoot/../scripts/get-changelog.ps1" ` + -RepoUrl 'https://github.com/getsentry/sentry-native' ` + -OldTag 'a64d5bd8ee130f2cda196b6fa7d9b65bfa6d32e2' ` + -NewTag '0.11.0' + + $expected = @' +## Changelog + +### 0.11.0 + +**Breaking changes**: + +- Add `user_data` parameter to `traces_sampler`. ([#1346](https://github-redirect.dependabot.com/getsentry/sentry-native/pull/1346)) + +**Fixes**: + +- Include `stddef.h` explicitly in `crashpad` since future `libc++` revisions will stop providing this include transitively. ([#1375](https://github-redirect.dependabot.com/getsentry/sentry-native/pull/1375), [crashpad#132](https://github-redirect.dependabot.com/getsentry/crashpad/pull/132)) +- Fall back on `JWASM` in the _MinGW_ `crashpad` build only if _no_ `CMAKE_ASM_MASM_COMPILER` has been defined. ([#1375](https://github-redirect.dependabot.com/getsentry/sentry-native/pull/1375), [crashpad#133](https://github-redirect.dependabot.com/getsentry/crashpad/pull/133)) +- Prevent `crashpad` from leaking Objective-C ARC compile options into any parent target linkage. ([#1375](https://github-redirect.dependabot.com/getsentry/sentry-native/pull/1375), [crashpad#134](https://github-redirect.dependabot.com/getsentry/crashpad/pull/134)) +- Fixed a TOCTOU race between session init/shutdown and event capture. ([#1377](https://github-redirect.dependabot.com/getsentry/sentry-native/pull/1377)) +- Make the Windows resource generation aware of config-specific output paths for multi-config generators. ([#1383](https://github-redirect.dependabot.com/getsentry/sentry-native/pull/1383)) +- Remove the `ASM` language from the top-level CMake project, as this triggered CMake policy `CMP194` which isn't applicable to the top-level. ([#1384](https://github-redirect.dependabot.com/getsentry/sentry-native/pull/1384)) + +**Features**: + +- Add a configuration to disable logging after a crash has been detected - `sentry_options_set_logger_enabled_when_crashed()`. ([#1371](https://github-redirect.dependabot.com/getsentry/sentry-native/pull/1371)) + +**Internal**: + +- Support downstream Xbox SDK specifying networking initialization mechanism. ([#1359](https://github-redirect.dependabot.com/getsentry/sentry-native/pull/1359)) +- Added `crashpad` support infrastructure for the external crash reporter feature. ([#1375](https://github-redirect.dependabot.com/getsentry/sentry-native/pull/1375), [crashpad#131](https://github-redirect.dependabot.com/getsentry/crashpad/pull/131)) + +**Docs**: + +- Document the CMake 4 requirement on macOS `SDKROOT` due to its empty default for `CMAKE_OSX_SYSROOT` in the `README`. ([#1368](https://github-redirect.dependabot.com/getsentry/sentry-native/pull/1368)) + +**Thank you**: + +- [JanFellner](https://github-redirect.dependabot.com/JanFellner) + +### 0.10.1 + +**Internal**: + +- Correctly apply dynamic mutex initialization in unit-tests (fixes running unit-tests in downstream console SDKs). ([#1337](https://github-redirect.dependabot.com/getsentry/sentry-native/pull/1337)) + +### 0.10.0 + +**Breaking changes**: + +- By using transactions as automatic trace boundaries, transactions will, by default, no longer be part of the same singular trace. This is not the case when setting trace boundaries explicitly (`sentry_regenerate_trace()` or `sentry_set_trace()`), which turns off the automatic management of trace boundaries. ([#1270](https://github-redirect.dependabot.com/getsentry/sentry-native/pull/1270)) +- Change transaction sampling to be trace-based. This does not affect you when transactions are used for automatic trace boundaries (as described above), since every transaction is part of a new trace. However, if you manage trace boundaries manually (using `sentry_regenerate_trace()`) or run the Native SDK inside a downstream SDK like the Unity SDK, where these SDKs will manage the trace boundaries, for a given `traces_sample_rate`, either all transactions in a trace get sampled or none do with probability equal to that sample rate. ([#1254](https://github-redirect.dependabot.com/getsentry/sentry-native/pull/1254)) +- Moved Xbox toolchains to an Xbox-specific repository [sentry-xbox](https://github-redirect.dependabot.com/getsentry/sentry-xbox). You can request access to the repository by following the instructions in [Xbox documentation](https://docs.sentry.io/platforms/xbox/). ([#1329](https://github-redirect.dependabot.com/getsentry/sentry-native/pull/1329)) + +**Features**: + +- Add `sentry_clear_attachments()` to allow clearing all previously added attachments in the global scope. ([#1290](https://github-redirect.dependabot.com/getsentry/sentry-native/pull/1290)) +- Automatically set trace boundaries with every transaction. ([#1270](https://github-redirect.dependabot.com/getsentry/sentry-native/pull/1270)) +- Provide `sentry_regenerate_trace()` to allow users to set manual trace boundaries. ([#1293](https://github-redirect.dependabot.com/getsentry/sentry-native/pull/1293)) +- Add `Dynamic Sampling Context (DSC)` to events. ([#1254](https://github-redirect.dependabot.com/getsentry/sentry-native/pull/1254)) +- Add `sentry_value_new_feedback` and `sentry_capture_feedback` to allow capturing [User Feedback](https://develop.sentry.dev/sdk/data-model/envelope-items/#user-feedback). ([#1304](https://github-redirect.dependabot.com/getsentry/sentry-native/pull/1304)) + - Deprecate `sentry_value_new_user_feedback` and `sentry_capture_user_feedback` in favor of the new API. +- Add `sentry_envelope_read_from_file`, `sentry_envelope_get_header`, and `sentry_capture_envelope`. ([#1320](https://github-redirect.dependabot.com/getsentry/sentry-native/pull/1320)) +- Add `(u)int64` `sentry_value_t` type. ([#1326](https://github-redirect.dependabot.com/getsentry/sentry-native/pull/1326)) + +**Meta**: + +- Marked deprecated functions with `SENTRY_DEPRECATED(msg)`. ([#1308](https://github-redirect.dependabot.com/getsentry/sentry-native/pull/1308)) + +**Internal**: + +- Crash events from Crashpad now have `event_id` defined similarly to other backends. This makes it possible to associate feedback at the time of crash. ([#1319](https://github-redirect.dependabot.com/getsentry/sentry-native/pull/1319)) +'@ + + $actual | Should -Be $expected + } + + It 'handles commit SHA as OldTag by getting changelog diff when SHA does not map to tag' { + # Test with a SHA that doesn't correspond to any tag - should use diff approach + # This SHA is between v2.8.0 and v2.8.1 in github-workflows repo + $actual = & "$PSScriptRoot/../scripts/get-changelog.ps1" ` + -RepoUrl 'https://github.com/getsentry/github-workflows' ` + -OldTag 'cc24e8eb3c13d3d2e949f4a20c86d2ccac310c11' ` + -NewTag 'v2.8.1' + + $expected = @' +## Changelog + +### 2.8.1 +#### Fixes +- Sentry-CLI integration test - set server script root so assets access works. ([#63](https://github-redirect.dependabot.com/getsentry/github-workflows/pull/63)) + +
+Full CHANGELOG.md diff + +```diff + -1,12 +1,10 + # Changelog + +-## Unreleased ++## 2.8.1 + +-### Dependencies ++### Fixes + +-- Bump CLI from v2.0.0 to v2.0.4 ([#60](https://github-redirect.dependabot.com/getsentry/github-workflows/pull/60)) +- - [changelog](https://github-redirect.dependabot.com/getsentry/sentry-cli/blob/master/CHANGELOG.md[#204](https://github-redirect.dependabot.com/getsentry/github-workflows/issues/204)) +- - [diff](https://github-redirect.dependabot.com/getsentry/sentry-cli/compare/2.0.0...2.0.4) ++- Sentry-CLI integration test - set server script root so assets access works. ([#63](https://github-redirect.dependabot.com/getsentry/github-workflows/pull/63)) + + ## 2.8.0 + +``` + +
+'@ + + # there's an issue with line endings so we'll compare line by line + $actualLines = $actual -split "`n" + $expectedLines = $expected -split "`n" + $actualLines.Count | Should -Be $expectedLines.Count + for ($i = 0; $i -lt $actualLines.Count; $i++) { + $actualLines[$i].Trim() | Should -Be $expectedLines[$i].Trim() + } + } + + It 'falls back to git commits when no changelog files exist' { + # Test with a repository that doesn't have changelog files + $actual = & "$PSScriptRoot/../scripts/get-changelog.ps1" ` + -RepoUrl 'https://github.com/getsentry/responses.git' -OldTag '0.7.0' -NewTag '0.8.0' + + $expected = @' +## Changelog + +### Commits between 0.7.0 and 0.8.0 + +- Note passthru changes +- Add support for removing and replacing existing mocked URLs +- Add support for removing and replacing existing mocked URLs +- Use inspect.getfullargspec() in Python 3 +- ci: add codecov dep +- Changes for 0.7.0 +'@ + + $actual | Should -Be $expected + } + + It 'git commit fallback handles PR references correctly' { + # Test with a known repository and tags that contain PR references + $actual = & "$PSScriptRoot/../scripts/get-changelog.ps1" ` + -RepoUrl 'https://github.com/getsentry/responses.git' -OldTag '0.8.0' -NewTag '0.9.0' + + # This test verifies PR link formatting in commit messages + $expected = @' +## Changelog + +### Commits between 0.8.0 and 0.9.0 + +- Update CHANGES for 0.9.0 +- Merge pull request [#196](https://github-redirect.dependabot.com/getsentry/responses.git/issues/196) from getsentry/fix/python-37 +- fix: Adapt to re.Pattern in Python 3.7 +- test: Correct paths to artifacts +- test: Correct paths to artifacts +- test: Add Zeus +- Merge pull request [#192](https://github-redirect.dependabot.com/getsentry/responses.git/issues/192) from xmo-odoo/patch-1 +- force rebuild +- Merge pull request [#189](https://github-redirect.dependabot.com/getsentry/responses.git/issues/189) from wimglenn/issue_188 +- Add stream attribute to BaseResponse +- add 3.5 support +- add support for custom patch target +- Merge pull request [#187](https://github-redirect.dependabot.com/getsentry/responses.git/issues/187) from rmad17/master +- Update README.rst +- Adding installing section +- Merge pull request [#181](https://github-redirect.dependabot.com/getsentry/responses.git/issues/181) from feliperuhland/master +- Merge pull request [#178](https://github-redirect.dependabot.com/getsentry/responses.git/issues/178) from kathawala/unicode_passthru +- Fix README examples with import of requests library +- Satisfy linter +- Better test which doesn't rely on external requests +- Add unicode support for passthru urls +- Add support for unicode in domain names and tlds ([#177](https://github-redirect.dependabot.com/getsentry/responses.git/issues/177)) +- Attempt to satisfy linter +- All tests passed for fixing issue [#175](https://github-redirect.dependabot.com/getsentry/responses.git/issues/175) +- Adds unicode handling to BaseRequest init, fixes issue [#175](https://github-redirect.dependabot.com/getsentry/responses.git/issues/175) +- fix: Maintain 'method' param on 'add' +'@ + + $actual | Should -Be $expected + } + + It 'git commit fallback returns empty when no commits found' { + # Test with same tags (no commits between them) + $actual = & "$PSScriptRoot/../scripts/get-changelog.ps1" ` + -RepoUrl 'https://github.com/getsentry/responses.git' -OldTag '0.9.0' -NewTag '0.9.0' + + $actual | Should -BeNullOrEmpty + } + + It 'git commit fallback filters out version tag commits' { + # Test that version commits like "0.8.0" are filtered out + $actual = & "$PSScriptRoot/../scripts/get-changelog.ps1" ` + -RepoUrl 'https://github.com/getsentry/responses.git' -OldTag '0.6.0' -NewTag '0.8.0' + + # Expected output should not contain version tag commits but should have meaningful commits + # This range includes version commits that should be filtered out + $expected = @' +## Changelog + +### Commits between 0.6.0 and 0.8.0 + +- Note passthru changes +- Add support for removing and replacing existing mocked URLs +- Add support for removing and replacing existing mocked URLs +- Use inspect.getfullargspec() in Python 3 +- ci: add codecov dep +- Changes for 0.7.0 +- Change behavior for multiple matches per PR comment +- Issue [#170](https://github-redirect.dependabot.com/getsentry/responses.git/issues/170): Fix bug with handling multiple matches +- ci: add codecov +- test: multiple urls same domain (refs GH-170) +- Changes for 0.6.2 +- compare query params length if match_querystring is set +- fix: ensuring default path if match_querystring is set +- update multiple responses example in README.rst +- fix: fix multiple responses +- fix: count mocked errors in RequestsMock +- fix: allow returning arbitrary status codes +- Changes for 0.6.1 +- Update README.rst +- drop support for Python 2.6 +- travis: dont setup pre-commit +- pre-commit 0.16.0 +- fix: restore adding_headers compatibility +- missing change refs +- Merge branch 'feature/do_not_remove_urls_when_assert_all_requests_are_fired' of https://github.com/j0hnsmith/responses into j0hnsmith-feature/do_not_remove_urls_when_assert_all_requests_are_fired +- The only change in behaviour when setting `assert_all_requests_are_fired=True` should be the expected assertion. +'@ + + $actual | Should -Be $expected + } + + It 'git commit fallback handles invalid repository gracefully' { + # Test with a non-existent repository to verify error handling + $actual = & "$PSScriptRoot/../scripts/get-changelog.ps1" ` + -RepoUrl 'https://github.com/nonexistent/repository' -OldTag 'v1.0.0' -NewTag 'v2.0.0' + + # Should return empty/null and not crash the script + $actual | Should -BeNullOrEmpty + } } diff --git a/updater/tests/testdata/changelog/no-bullet-points/CHANGELOG.md.expected b/updater/tests/testdata/changelog/no-bullet-points/CHANGELOG.md.expected new file mode 100644 index 00000000..8869c20e --- /dev/null +++ b/updater/tests/testdata/changelog/no-bullet-points/CHANGELOG.md.expected @@ -0,0 +1,16 @@ +# Changelog + +## Unreleased + +### Dependencies + +- Bump Dependency from v7.16.0 to v7.17.0 ([#123](https://github.com/getsentry/dependant/pulls/123)) + - [changelog](https://github.com/getsentry/dependency/blob/main/CHANGELOG.md#7170) + - [diff](https://github.com/getsentry/dependency/compare/7.16.0...7.17.0) + +## 0.14.0 + +### Dependencies + +This section contains only plain text with no bullet points. +The update-changelog script should handle this case gracefully. \ No newline at end of file diff --git a/updater/tests/testdata/changelog/no-bullet-points/CHANGELOG.md.original b/updater/tests/testdata/changelog/no-bullet-points/CHANGELOG.md.original new file mode 100644 index 00000000..6a9d64de --- /dev/null +++ b/updater/tests/testdata/changelog/no-bullet-points/CHANGELOG.md.original @@ -0,0 +1,8 @@ +# Changelog + +## 0.14.0 + +### Dependencies + +This section contains only plain text with no bullet points. +The update-changelog script should handle this case gracefully. diff --git a/updater/tests/testdata/changelog/plain-text-intro/CHANGELOG.md.expected b/updater/tests/testdata/changelog/plain-text-intro/CHANGELOG.md.expected new file mode 100644 index 00000000..9c3a9c63 --- /dev/null +++ b/updater/tests/testdata/changelog/plain-text-intro/CHANGELOG.md.expected @@ -0,0 +1,55 @@ +# Changelog + +## Unreleased + +### Breaking Changes + +Updater and Danger reusable workflows are now composite actions ([#114](https://github.com/getsentry/github-workflows/pull/114)) + +To update your existing Updater workflows: +```yaml +### Before + native: + uses: getsentry/github-workflows/.github/workflows/updater.yml@v2 + with: + path: scripts/update-sentry-native-ndk.sh + name: Native SDK + secrets: + # If a custom token is used instead, a CI would be triggered on a created PR. + api-token: ${{ secrets.CI_DEPLOY_KEY }} + +### After + native: + runs-on: ubuntu-latest + steps: + - uses: getsentry/github-workflows/updater@v3 + with: + path: scripts/update-sentry-native-ndk.sh + name: Native SDK + api-token: ${{ secrets.CI_DEPLOY_KEY }} +``` + +To update your existing Danger workflows: +```yaml +### Before + danger: + uses: getsentry/github-workflows/.github/workflows/danger.yml@v2 + +### After + danger: + runs-on: ubuntu-latest + steps: + - uses: getsentry/github-workflows/danger@v3 +``` + +### Dependencies + +- Bump Dependency from v7.16.0 to v7.17.0 ([#123](https://github.com/getsentry/dependant/pulls/123)) + - [changelog](https://github.com/getsentry/dependency/blob/main/CHANGELOG.md#7170) + - [diff](https://github.com/getsentry/dependency/compare/7.16.0...7.17.0) + +## 2.14.1 + +### Features + +- Do something ([#100](https://github.com/getsentry/dependant/pulls/100)) diff --git a/updater/tests/testdata/changelog/plain-text-intro/CHANGELOG.md.original b/updater/tests/testdata/changelog/plain-text-intro/CHANGELOG.md.original new file mode 100644 index 00000000..1f7cbc77 --- /dev/null +++ b/updater/tests/testdata/changelog/plain-text-intro/CHANGELOG.md.original @@ -0,0 +1,49 @@ +# Changelog + +## Unreleased + +### Breaking Changes + +Updater and Danger reusable workflows are now composite actions ([#114](https://github.com/getsentry/github-workflows/pull/114)) + +To update your existing Updater workflows: +```yaml +### Before + native: + uses: getsentry/github-workflows/.github/workflows/updater.yml@v2 + with: + path: scripts/update-sentry-native-ndk.sh + name: Native SDK + secrets: + # If a custom token is used instead, a CI would be triggered on a created PR. + api-token: ${{ secrets.CI_DEPLOY_KEY }} + +### After + native: + runs-on: ubuntu-latest + steps: + - uses: getsentry/github-workflows/updater@v3 + with: + path: scripts/update-sentry-native-ndk.sh + name: Native SDK + api-token: ${{ secrets.CI_DEPLOY_KEY }} +``` + +To update your existing Danger workflows: +```yaml +### Before + danger: + uses: getsentry/github-workflows/.github/workflows/danger.yml@v2 + +### After + danger: + runs-on: ubuntu-latest + steps: + - uses: getsentry/github-workflows/danger@v3 +``` + +## 2.14.1 + +### Features + +- Do something ([#100](https://github.com/getsentry/dependant/pulls/100)) diff --git a/updater/tests/update-changelog.Tests.ps1 b/updater/tests/update-changelog.Tests.ps1 index aa6d3a24..2665c420 100644 --- a/updater/tests/update-changelog.Tests.ps1 +++ b/updater/tests/update-changelog.Tests.ps1 @@ -1,6 +1,4 @@ -$testCases = - Describe 'update-changelog' { It '<_>' -ForEach @(Get-ChildItem "$PSScriptRoot/testdata/changelog/") { $testCase = $_ @@ -17,4 +15,37 @@ Describe 'update-changelog' { Get-Content "$testCase/CHANGELOG.md" | Should -Be (Get-Content "$testCase/CHANGELOG.md.expected") } + + It 'should correctly detect bullet points when plain text appears before bullet points' { + $testCasePath = "$PSScriptRoot/testdata/changelog/plain-text-intro" + Copy-Item "$testCasePath/CHANGELOG.md.original" "$testCasePath/CHANGELOG.md" + + pwsh -WorkingDirectory $testCasePath -File "$PSScriptRoot/../scripts/update-changelog.ps1" ` + -Name 'Dependency' ` + -PR 'https://github.com/getsentry/dependant/pulls/123' ` + -RepoUrl 'https://github.com/getsentry/dependency' ` + -MainBranch 'main' ` + -OldTag '7.16.0' ` + -NewTag '7.17.0' ` + -Section 'Dependencies' + + # verify the full output matches expected + Get-Content "$testCasePath/CHANGELOG.md" | Should -Be (Get-Content "$testCasePath/CHANGELOG.md.expected") + } + + It 'should handle changelogs with no bullet points by defaulting to dash' { + $testCasePath = "$PSScriptRoot/testdata/changelog/no-bullet-points" + Copy-Item "$testCasePath/CHANGELOG.md.original" "$testCasePath/CHANGELOG.md" + + pwsh -WorkingDirectory $testCasePath -File "$PSScriptRoot/../scripts/update-changelog.ps1" ` + -Name 'Dependency' ` + -PR 'https://github.com/getsentry/dependant/pulls/123' ` + -RepoUrl 'https://github.com/getsentry/dependency' ` + -MainBranch 'main' ` + -OldTag '7.16.0' ` + -NewTag '7.17.0' ` + -Section 'Dependencies' + + Get-Content "$testCasePath/CHANGELOG.md" | Should -Be (Get-Content "$testCasePath/CHANGELOG.md.expected") + } } diff --git a/updater/tests/update-dependency-cmake.Tests.ps1 b/updater/tests/update-dependency-cmake.Tests.ps1 new file mode 100644 index 00000000..c4400e20 --- /dev/null +++ b/updater/tests/update-dependency-cmake.Tests.ps1 @@ -0,0 +1,518 @@ +BeforeAll { + # Load CMake helper functions from the main script + . "$PSScriptRoot/../scripts/cmake-functions.ps1" +} + +Describe 'Parse-CMakeFetchContent' { + Context 'Basic single dependency file' { + BeforeAll { + $script:tempDir = "$TestDrive/cmake-tests" + New-Item $tempDir -ItemType Directory -Force | Out-Null + + $script:basicFile = "$tempDir/basic.cmake" + @' +include(FetchContent) + +FetchContent_Declare( + sentry-native + GIT_REPOSITORY https://github.com/getsentry/sentry-native + GIT_TAG v0.9.1 + GIT_SHALLOW FALSE +) + +FetchContent_MakeAvailable(sentry-native) +'@ | Out-File $basicFile + } + + It 'parses with explicit dependency name' { + $result = Parse-CMakeFetchContent $basicFile 'sentry-native' + + $result.GitRepository | Should -Be 'https://github.com/getsentry/sentry-native' + $result.GitTag | Should -Be 'v0.9.1' + $result.DepName | Should -Be 'sentry-native' + } + + It 'auto-detects single dependency' { + $result = Parse-CMakeFetchContent $basicFile $null + + $result.GitRepository | Should -Be 'https://github.com/getsentry/sentry-native' + $result.GitTag | Should -Be 'v0.9.1' + $result.DepName | Should -Be 'sentry-native' + } + + It 'throws on missing dependency' { + { Parse-CMakeFetchContent $basicFile 'nonexistent' } | Should -Throw "*FetchContent_Declare for 'nonexistent' not found*" + } + } + + Context 'Hash-based dependency file' { + BeforeAll { + $script:tempDir = "$TestDrive/cmake-tests" + New-Item $tempDir -ItemType Directory -Force | Out-Null + + $script:hashFile = "$tempDir/hash.cmake" + @' +include(FetchContent) + +FetchContent_Declare( + sentry-native + GIT_REPOSITORY https://github.com/getsentry/sentry-native + GIT_TAG a64d5bd8ee130f2cda196b6fa7d9b65bfa6d32e2 # 0.9.1 + GIT_SHALLOW FALSE + GIT_SUBMODULES "external/breakpad" +) + +FetchContent_MakeAvailable(sentry-native) +'@ | Out-File $hashFile + } + + It 'handles hash values correctly' { + $result = Parse-CMakeFetchContent $hashFile 'sentry-native' + + $result.GitRepository | Should -Be 'https://github.com/getsentry/sentry-native' + $result.GitTag | Should -Be 'a64d5bd8ee130f2cda196b6fa7d9b65bfa6d32e2' + $result.DepName | Should -Be 'sentry-native' + } + } + + Context 'Complex formatting file' { + BeforeAll { + $script:tempDir = "$TestDrive/cmake-tests" + New-Item $tempDir -ItemType Directory -Force | Out-Null + + $script:complexFile = "$tempDir/complex.cmake" + @' +include(FetchContent) + +FetchContent_Declare( + sentry-native + GIT_REPOSITORY + https://github.com/getsentry/sentry-native + GIT_TAG + v0.9.1 + GIT_SHALLOW + FALSE + GIT_SUBMODULES + "external/breakpad" +) + +FetchContent_MakeAvailable(sentry-native) +'@ | Out-File $complexFile + } + + It 'handles complex multi-line formatting' { + $result = Parse-CMakeFetchContent $complexFile 'sentry-native' + + $result.GitRepository | Should -Be 'https://github.com/getsentry/sentry-native' + $result.GitTag | Should -Be 'v0.9.1' + $result.DepName | Should -Be 'sentry-native' + } + } + + Context 'Multiple dependencies file' { + BeforeAll { + $script:tempDir = "$TestDrive/cmake-tests" + New-Item $tempDir -ItemType Directory -Force | Out-Null + + $script:multipleFile = "$tempDir/multiple.cmake" + @' +include(FetchContent) + +FetchContent_Declare( + sentry-native + GIT_REPOSITORY https://github.com/getsentry/sentry-native + GIT_TAG v0.9.1 +) + +FetchContent_Declare( + googletest + GIT_REPOSITORY https://github.com/google/googletest + GIT_TAG v1.14.0 +) + +FetchContent_MakeAvailable(sentry-native googletest) +'@ | Out-File $multipleFile + } + + It 'throws on multiple dependencies without explicit name' { + { Parse-CMakeFetchContent $multipleFile $null } | Should -Throw '*Multiple FetchContent declarations found*' + } + + It 'handles specific dependency from multiple dependencies' { + $result = Parse-CMakeFetchContent $multipleFile 'googletest' + + $result.GitRepository | Should -Be 'https://github.com/google/googletest' + $result.GitTag | Should -Be 'v1.14.0' + $result.DepName | Should -Be 'googletest' + } + } + + Context 'Variable reference GIT_TAG' { + BeforeAll { + $script:tempDir = "$TestDrive/cmake-tests" + New-Item $tempDir -ItemType Directory -Force | Out-Null + + $script:varRefFile = "$tempDir/varref.cmake" + @' +include(FetchContent) + +set(SENTRY_NATIVE_REF "v0.9.1" CACHE STRING "The sentry-native ref") + +FetchContent_Declare( + sentry-native + GIT_REPOSITORY https://github.com/getsentry/sentry-native + GIT_TAG ${SENTRY_NATIVE_REF} + GIT_SHALLOW FALSE +) + +FetchContent_MakeAvailable(sentry-native) +'@ | Out-File $varRefFile + + $script:varRefHashFile = "$tempDir/varref-hash.cmake" + @' +include(FetchContent) + +set(SENTRY_NATIVE_REF a64d5bd8ee130f2cda196b6fa7d9b65bfa6d32e2) # 0.9.1 + +FetchContent_Declare( + sentry-native + GIT_REPOSITORY https://github.com/getsentry/sentry-native + GIT_TAG ${SENTRY_NATIVE_REF} +) + +FetchContent_MakeAvailable(sentry-native) +'@ | Out-File $varRefHashFile + + $script:varRefDirectFile = "$tempDir/varref-direct.cmake" + @' +include(FetchContent) + +FetchContent_Declare( + sentry-native + GIT_REPOSITORY https://github.com/getsentry/sentry-native + GIT_TAG v0.9.1 +) +'@ | Out-File $varRefDirectFile + + $script:varRefMissingFile = "$tempDir/varref-missing.cmake" + @' +include(FetchContent) + +FetchContent_Declare( + sentry-native + GIT_REPOSITORY https://github.com/getsentry/sentry-native + GIT_TAG ${UNDEFINED_VAR} +) +'@ | Out-File $varRefMissingFile + } + + It 'resolves quoted variable to actual value' { + $result = Parse-CMakeFetchContent $varRefFile 'sentry-native' + + $result.GitRepository | Should -Be 'https://github.com/getsentry/sentry-native' + $result.GitTag | Should -Be 'v0.9.1' + $result.GitTagVariable | Should -Be 'SENTRY_NATIVE_REF' + $result.DepName | Should -Be 'sentry-native' + } + + It 'resolves unquoted variable with hash value' { + $result = Parse-CMakeFetchContent $varRefHashFile 'sentry-native' + + $result.GitTag | Should -Be 'a64d5bd8ee130f2cda196b6fa7d9b65bfa6d32e2' + $result.GitTagVariable | Should -Be 'SENTRY_NATIVE_REF' + } + + It 'throws when variable definition is missing' { + { Parse-CMakeFetchContent $varRefMissingFile 'sentry-native' } | Should -Throw "*CMake variable 'UNDEFINED_VAR' referenced by GIT_TAG not found*" + } + + It 'returns null GitTagVariable for direct values' { + $result = Parse-CMakeFetchContent $varRefDirectFile 'sentry-native' + + $result.GitTagVariable | Should -BeNullOrEmpty + } + } + + Context 'Malformed files' { + BeforeAll { + $script:tempDir = "$TestDrive/cmake-tests" + New-Item $tempDir -ItemType Directory -Force | Out-Null + + $script:missingRepoFile = "$tempDir/missing-repo.cmake" + @' +include(FetchContent) + +FetchContent_Declare( + sentry-native + GIT_TAG v0.9.1 +) +'@ | Out-File $missingRepoFile + + $script:missingTagFile = "$tempDir/missing-tag.cmake" + @' +include(FetchContent) + +FetchContent_Declare( + sentry-native + GIT_REPOSITORY https://github.com/getsentry/sentry-native +) +'@ | Out-File $missingTagFile + } + + It 'throws on missing GIT_REPOSITORY' { + { Parse-CMakeFetchContent $missingRepoFile 'sentry-native' } | Should -Throw '*Could not parse GIT_REPOSITORY or GIT_TAG*' + } + + It 'throws on missing GIT_TAG' { + { Parse-CMakeFetchContent $missingTagFile 'sentry-native' } | Should -Throw '*Could not parse GIT_REPOSITORY or GIT_TAG*' + } + } +} + +Describe 'Find-TagForHash' { + Context 'Hash resolution scenarios' { + It 'returns null for hash without matching tag' { + # Use a fake hash that won't match any real tag + $fakeHash = 'abcdef1234567890abcdef1234567890abcdef12' + $repo = 'https://github.com/getsentry/sentry-native' + + $result = Find-TagForHash $repo $fakeHash + + $result | Should -BeNullOrEmpty + } + + It 'handles network failures gracefully' { + $invalidRepo = 'https://github.com/nonexistent/repo' + $hash = 'abcdef1234567890abcdef1234567890abcdef12' + + # Should not throw, but return null and show warning + $result = Find-TagForHash $invalidRepo $hash + + $result | Should -BeNullOrEmpty + } + + # Note: Testing actual hash resolution requires network access + # and is better suited for integration tests + } +} + +Describe 'Update-CMakeFile' { + Context 'Basic tag updates' { + BeforeAll { + $script:tempDir = "$TestDrive/cmake-update-tests" + New-Item $tempDir -ItemType Directory -Force | Out-Null + + $script:basicTemplate = @' +include(FetchContent) + +FetchContent_Declare( + sentry-native + GIT_REPOSITORY https://github.com/getsentry/sentry-native + GIT_TAG v0.9.1 + GIT_SHALLOW FALSE +) + +FetchContent_MakeAvailable(sentry-native) +'@ + } + + BeforeEach { + $script:basicTestFile = "$tempDir/basic-test.cmake" + } + + It 'updates tag to tag preserving format' { + $basicTemplate | Out-File $basicTestFile + + Update-CMakeFile $basicTestFile 'sentry-native' 'v0.9.2' + + $content = Get-Content $basicTestFile -Raw + $content | Should -Match 'GIT_TAG v0.9.2' + $content | Should -Not -Match 'v0.9.1' + } + + It 'preserves file structure and other content' { + $basicTemplate | Out-File $basicTestFile + + Update-CMakeFile $basicTestFile 'sentry-native' 'v0.9.2' + + $content = Get-Content $basicTestFile -Raw + $content | Should -Match 'include\(FetchContent\)' + $content | Should -Match 'FetchContent_MakeAvailable' + $content | Should -Match 'GIT_REPOSITORY https://github.com/getsentry/sentry-native' + $content | Should -Match 'GIT_SHALLOW FALSE' + } + + It 'throws on failed regex match' { + $basicTemplate | Out-File $basicTestFile + + # Try to update a dependency that doesn't exist + { Update-CMakeFile $basicTestFile 'nonexistent-dep' 'v1.0.0' } | Should -Throw "*FetchContent_Declare for 'nonexistent-dep' not found*" + } + } + + Context 'Hash updates' { + BeforeAll { + $script:tempDir = "$TestDrive/cmake-update-tests" + New-Item $tempDir -ItemType Directory -Force | Out-Null + + $script:hashTemplate = @' +include(FetchContent) + +FetchContent_Declare( + sentry-native + GIT_REPOSITORY https://github.com/getsentry/sentry-native + GIT_TAG a64d5bd8ee130f2cda196b6fa7d9b65bfa6d32e2 # 0.9.1 + GIT_SHALLOW FALSE + GIT_SUBMODULES "external/breakpad" +) + +FetchContent_MakeAvailable(sentry-native) +'@ + } + + BeforeEach { + $script:hashTestFile = "$tempDir/hash-test.cmake" + } + + It 'updates hash to newer hash preserving format' { + $hashTemplate | Out-File $hashTestFile + + # Update to a newer tag that will be converted to hash (0.11.0 is known to exist) + Update-CMakeFile $hashTestFile 'sentry-native' '0.11.0' + + $content = Get-Content $hashTestFile -Raw + # Should have new hash with tag comment + $content | Should -Match 'GIT_TAG 3bd091313ae97be90be62696a2babe591a988eb8 # 0.11.0' + # Should not have old hash or old comment + $content | Should -Not -Match 'a64d5bd8ee130f2cda196b6fa7d9b65bfa6d32e2' + $content | Should -Not -Match '# 0.9.1' + } + } + + Context 'Complex formatting' { + BeforeAll { + $script:tempDir = "$TestDrive/cmake-update-tests" + New-Item $tempDir -ItemType Directory -Force | Out-Null + + $script:complexTemplate = @' +include(FetchContent) + +FetchContent_Declare( + sentry-native + GIT_REPOSITORY + https://github.com/getsentry/sentry-native + GIT_TAG + v0.9.1 + GIT_SHALLOW + FALSE + GIT_SUBMODULES + "external/breakpad" +) + +FetchContent_MakeAvailable(sentry-native) +'@ + } + + BeforeEach { + $script:complexTestFile = "$tempDir/complex-test.cmake" + } + + It 'handles complex formatting correctly' { + $complexTemplate | Out-File $complexTestFile + + Update-CMakeFile $complexTestFile 'sentry-native' 'v0.9.2' + + $content = Get-Content $complexTestFile -Raw + $content | Should -Match 'GIT_TAG\s+v0.9.2' + $content | Should -Not -Match 'v0.9.1' + } + } + + Context 'Variable reference tag updates' { + BeforeAll { + $script:tempDir = "$TestDrive/cmake-update-tests" + New-Item $tempDir -ItemType Directory -Force | Out-Null + + $script:varRefTagTemplate = @' +include(FetchContent) + +set(SENTRY_NATIVE_REF "v0.9.1" CACHE STRING "The sentry-native ref") + +FetchContent_Declare( + sentry-native + GIT_REPOSITORY https://github.com/getsentry/sentry-native + GIT_TAG ${SENTRY_NATIVE_REF} + GIT_SHALLOW FALSE +) + +FetchContent_MakeAvailable(sentry-native) +'@ + } + + BeforeEach { + $script:varRefTagTestFile = "$tempDir/varref-tag-test.cmake" + } + + It 'updates set() value and leaves GIT_TAG variable reference untouched' { + $varRefTagTemplate | Out-File $varRefTagTestFile + + Update-CMakeFile $varRefTagTestFile 'sentry-native' 'v0.9.2' + + $content = Get-Content $varRefTagTestFile -Raw + $content | Should -Match 'set\(SENTRY_NATIVE_REF "v0.9.2"' + $content | Should -Match 'GIT_TAG \$\{SENTRY_NATIVE_REF\}' + $content | Should -Not -Match 'v0.9.1' + } + + It 'preserves file structure' { + $varRefTagTemplate | Out-File $varRefTagTestFile + + Update-CMakeFile $varRefTagTestFile 'sentry-native' 'v0.9.2' + + $content = Get-Content $varRefTagTestFile -Raw + $content | Should -Match 'include\(FetchContent\)' + $content | Should -Match 'FetchContent_MakeAvailable' + $content | Should -Match 'GIT_SHALLOW FALSE' + } + } + + Context 'Variable reference hash updates' { + BeforeAll { + $script:tempDir = "$TestDrive/cmake-update-tests" + New-Item $tempDir -ItemType Directory -Force | Out-Null + + $script:varRefHashTemplate = @' +include(FetchContent) + +set(SENTRY_NATIVE_REF a64d5bd8ee130f2cda196b6fa7d9b65bfa6d32e2) # 0.9.1 + +FetchContent_Declare( + sentry-native + GIT_REPOSITORY https://github.com/getsentry/sentry-native + GIT_TAG ${SENTRY_NATIVE_REF} +) + +FetchContent_MakeAvailable(sentry-native) +'@ + } + + BeforeEach { + $script:varRefHashTestFile = "$tempDir/varref-hash-test.cmake" + } + + It 'updates set() value with new hash and comment' { + $varRefHashTemplate | Out-File $varRefHashTestFile + + Update-CMakeFile $varRefHashTestFile 'sentry-native' '0.11.0' + + $content = Get-Content $varRefHashTestFile -Raw + $content | Should -Match 'set\(SENTRY_NATIVE_REF 3bd091313ae97be90be62696a2babe591a988eb8\) # 0.11.0' + $content | Should -Match 'GIT_TAG \$\{SENTRY_NATIVE_REF\}' + $content | Should -Not -Match 'a64d5bd8ee130f2cda196b6fa7d9b65bfa6d32e2' + $content | Should -Not -Match '# 0.9.1' + } + } + + # Note: Hash update tests require network access for git ls-remote + # and are better suited for integration tests +} diff --git a/updater/tests/update-dependency.Tests.ps1 b/updater/tests/update-dependency.Tests.ps1 index 3d1c9fe0..46908049 100644 --- a/updater/tests/update-dependency.Tests.ps1 +++ b/updater/tests/update-dependency.Tests.ps1 @@ -1,7 +1,12 @@ BeforeAll { - function UpdateDependency([Parameter(Mandatory = $true)][string] $path, [string] $pattern = $null) + function UpdateDependency([Parameter(Mandatory = $true)][string] $path, [string] $pattern = $null, [string] $ghTitlePattern = $null, [string] $postUpdateScript = $null) { - $result = & "$PSScriptRoot/../scripts/update-dependency.ps1" -Path $path -Pattern $pattern + $params = @{ Path = $path } + if ($pattern) { $params.Pattern = $pattern } + if ($ghTitlePattern) { $params.GhTitlePattern = $ghTitlePattern } + if ($postUpdateScript) { $params.PostUpdateScript = $postUpdateScript } + + $result = & "$PSScriptRoot/../scripts/update-dependency.ps1" @params if (-not $?) { throw $result @@ -16,10 +21,13 @@ BeforeAll { } $repoUrl = 'https://github.com/getsentry/github-workflows' - # Find the latest latest version in this repo. We're intentionally using different code than `update-dependency.ps1` - # script uses to be able to catch issues, if any. - $currentVersion = (git -c 'versionsort.suffix=-' ls-remote --tags --sort='v:refname' $repoUrl ` - | Select-Object -Last 1 | Select-String -Pattern 'refs/tags/(.*)$').Matches.Groups[1].Value + # Find the latest latest version in this repo using the same logic as update-dependency.ps1 + . "$PSScriptRoot/../scripts/common.ps1" + [string[]]$tags = $(git ls-remote --refs --tags $repoUrl) + $tags = $tags | ForEach-Object { ($_ -split '\s+')[1] -replace '^refs/tags/', '' } + $tags = $tags -match '^v?([0-9.]+)$' + $tags = & "$PSScriptRoot/../scripts/sort-versions.ps1" $tags + $currentVersion = $tags[-1] } Describe ('update-dependency') { @@ -212,7 +220,9 @@ switch ($action) $output | Should -Contain 'latestTag=0.28.0' $output | Should -Contain 'latestTagNice=v0.28.0' $output | Should -Contain 'url=https://github.com/getsentry/sentry-cli' - $output | Should -Contain 'mainBranch=master' + # sentry-cli has both `main` and `master`; which one is reported depends on + # which currently points at HEAD upstream, so accept either. + ($output | Where-Object { $_ -like 'mainBranch=*' }) | Should -BeIn @('mainBranch=main', 'mainBranch=master') } } @@ -247,4 +257,414 @@ switch ($action) } } } + + Context 'cmake-fetchcontent' { + BeforeAll { + $cmakeTestDir = "$testDir/cmake" + if (-not (Test-Path $cmakeTestDir)) { + New-Item $cmakeTestDir -ItemType Directory + } + } + + It 'updates CMake file with explicit dependency name' { + $testFile = "$cmakeTestDir/sentry-explicit.cmake" + @' +include(FetchContent) + +FetchContent_Declare( + sentry-native + GIT_REPOSITORY https://github.com/getsentry/sentry-native + GIT_TAG v0.9.1 + GIT_SHALLOW FALSE +) + +FetchContent_MakeAvailable(sentry-native) +'@ | Out-File $testFile + + UpdateDependency "$testFile#sentry-native" + + $content = Get-Content $testFile -Raw + $content | Should -Not -Match 'v0.9.1' + $content | Should -Match 'GIT_TAG \d+\.\d+\.\d+' + $content | Should -Match 'GIT_REPOSITORY https://github.com/getsentry/sentry-native' + } + + It 'auto-detects single FetchContent dependency' { + $testFile = "$cmakeTestDir/sentry-auto.cmake" + @' +include(FetchContent) + +FetchContent_Declare( + sentry-native + GIT_REPOSITORY https://github.com/getsentry/sentry-native + GIT_TAG v0.9.0 + GIT_SHALLOW FALSE +) + +FetchContent_MakeAvailable(sentry-native) +'@ | Out-File $testFile + + UpdateDependency $testFile + + $content = Get-Content $testFile -Raw + $content | Should -Not -Match 'v0.9.0' + $content | Should -Match 'GIT_TAG \d+\.\d+\.\d+' + } + + It 'updates from hash to newer tag preserving hash format' { + $testFile = "$cmakeTestDir/sentry-hash.cmake" + @' +include(FetchContent) + +FetchContent_Declare( + sentry-native + GIT_REPOSITORY https://github.com/getsentry/sentry-native + GIT_TAG a64d5bd8ee130f2cda196b6fa7d9b65bfa6d32e2 # 0.9.1 + GIT_SHALLOW FALSE +) + +FetchContent_MakeAvailable(sentry-native) +'@ | Out-File $testFile + + UpdateDependency $testFile + + $content = Get-Content $testFile -Raw + # Should update to a new hash with tag comment + $content | Should -Match 'GIT_TAG [a-f0-9]{40} # \d+\.\d+\.\d+' + $content | Should -Not -Match 'a64d5bd8ee130f2cda196b6fa7d9b65bfa6d32e2' + } + + It 'handles multiple dependencies with explicit selection' { + $testFile = "$cmakeTestDir/multiple-deps.cmake" + @' +include(FetchContent) + +FetchContent_Declare( + sentry-native + GIT_REPOSITORY https://github.com/getsentry/sentry-native + GIT_TAG v0.9.1 +) + +FetchContent_Declare( + googletest + GIT_REPOSITORY https://github.com/google/googletest + GIT_TAG v1.14.0 +) + +FetchContent_MakeAvailable(sentry-native googletest) +'@ | Out-File $testFile + + UpdateDependency "$testFile#googletest" + + $content = Get-Content $testFile -Raw + # sentry-native should remain unchanged + $content | Should -Match 'sentry-native[\s\S]*GIT_TAG v0\.9\.1' + # googletest should be updated + $content | Should -Match 'googletest[\s\S]*GIT_TAG v1\.\d+\.\d+' + $content | Should -Not -Match 'googletest[\s\S]*GIT_TAG v1\.14\.0' + } + + It 'outputs correct GitHub Actions variables' { + $testFile = "$cmakeTestDir/output-test.cmake" + @' +include(FetchContent) + +FetchContent_Declare( + sentry-native + GIT_REPOSITORY https://github.com/getsentry/sentry-native + GIT_TAG v0.9.0 +) +'@ | Out-File $testFile + + $output = UpdateDependency $testFile + + # Join output lines for easier searching + $outputText = $output -join "`n" + $outputText | Should -Match 'originalTag=v0\.9\.0' + $outputText | Should -Match 'latestTag=\d+\.\d+\.\d+' + $outputText | Should -Match 'url=https://github.com/getsentry/sentry-native' + $outputText | Should -Match 'mainBranch=master' + } + + It 'respects version patterns' { + $testFile = "$cmakeTestDir/pattern-test.cmake" + @' +include(FetchContent) + +FetchContent_Declare( + sentry-native + GIT_REPOSITORY https://github.com/getsentry/sentry-native + GIT_TAG v0.8.0 +) +'@ | Out-File $testFile + + # Limit to 0.9.x versions + UpdateDependency $testFile '^v?0\.9\.' + + $content = Get-Content $testFile -Raw + $content | Should -Match 'GIT_TAG 0\.9\.\d+' + $content | Should -Not -Match 'v0\.8\.0' + } + + It 'fails on multiple dependencies without explicit name' { + $testFile = "$cmakeTestDir/multi-fail.cmake" + @' +include(FetchContent) + +FetchContent_Declare(sentry-native GIT_REPOSITORY https://github.com/getsentry/sentry-native GIT_TAG v0.9.1) +FetchContent_Declare(googletest GIT_REPOSITORY https://github.com/google/googletest GIT_TAG v1.14.0) +'@ | Out-File $testFile + + { UpdateDependency $testFile } | Should -Throw '*Multiple FetchContent declarations found*' + } + + It 'fails on missing dependency' { + $testFile = "$cmakeTestDir/missing-dep.cmake" + @' +include(FetchContent) + +FetchContent_Declare( + sentry-native + GIT_REPOSITORY https://github.com/getsentry/sentry-native + GIT_TAG v0.9.1 +) +'@ | Out-File $testFile + + { UpdateDependency "$testFile#nonexistent" } | Should -Throw "*FetchContent_Declare for 'nonexistent' not found*" + } + } + + Context 'gh-title-pattern' { + It 'filters by GitHub release title pattern' { + $testFile = "$testDir/test.properties" + # Use sentry-cocoa repo which has releases with "(Stable)" suffix + $repo = 'https://github.com/getsentry/sentry-cocoa' + @("repo=$repo", 'version=0') | Out-File $testFile + + # Test filtering for releases with "(Stable)" suffix + UpdateDependency $testFile '' '\(Stable\)$' + + $content = Get-Content $testFile + $version = ($content | Where-Object { $_ -match '^version\s*=\s*(.+)$' }) -replace '^version\s*=\s*', '' + + # Verify that a version was selected (should be a stable release) + $version | Should -Not -Be '0' + $version | Should -Match '^\d+\.\d+\.\d+$' + } + + It 'throws error when no releases match title pattern' { + $testFile = "$testDir/test.properties" + $repo = 'https://github.com/getsentry/github-workflows' + @("repo=$repo", 'version=0') | Out-File $testFile + + # Use a pattern that should match no releases + { UpdateDependency $testFile '' 'NonExistentPattern' } | Should -Throw '*Found no tags with GitHub releases matching title pattern*' + } + + It 'matches specific release version by exact title pattern' { + $testFile = "$testDir/test.properties" + $repo = 'https://github.com/getsentry/github-workflows' + @("repo=$repo", 'version=0') | Out-File $testFile + + # Target a specific known release by exact title match + UpdateDependency $testFile '' '^2\.11\.1$' + + $content = Get-Content $testFile + $version = ($content | Where-Object { $_ -match '^version\s*=\s*(.+)$' }) -replace '^version\s*=\s*', '' + + # Should get exactly version 2.11.1 (with or without 'v' prefix) + $version | Should -Match '^v?2\.11\.1$' + } + + It 'works without title pattern (backward compatibility)' { + $testFile = "$testDir/test.properties" + $repo = 'https://github.com/getsentry/sentry-cocoa' + @("repo=$repo", 'version=0') | Out-File $testFile + + # Test without title pattern should work as before + UpdateDependency $testFile '^8\.' + + $content = Get-Content $testFile + $version = ($content | Where-Object { $_ -match '^version\s*=\s*(.+)$' }) -replace '^version\s*=\s*', '' + + # Should get a version starting with 8 + $version | Should -Match '^8\.' + } + } + + Context 'post-update-script' { + It 'runs PowerShell post-update script with version arguments' { + $testFile = "$testDir/test.properties" + $repo = 'https://github.com/getsentry/sentry-cli' + @("repo=$repo", 'version=0') | Out-File $testFile + + $postUpdateScript = "$testDir/post-update-test.ps1" + $markerFile = "$testDir/post-update-marker.txt" + @' +param([string] $originalVersion, [string] $newVersion) +"$originalVersion|$newVersion" | Out-File +'@ + " '$markerFile'" | Out-File $postUpdateScript + + UpdateDependency $testFile '^0\.' -postUpdateScript $postUpdateScript + + # Verify post-update script was executed + Test-Path $markerFile | Should -Be $true + $markerContent = Get-Content $markerFile + $markerContent | Should -Match '^0\|0\.28\.0$' + + # Clean up + Remove-Item $markerFile -ErrorAction SilentlyContinue + Remove-Item $postUpdateScript -ErrorAction SilentlyContinue + } + + It 'runs PowerShell post-update script when Tag and OriginalTag are explicitly provided' { + $testFile = "$testDir/test.properties" + $repo = 'https://github.com/getsentry/sentry-cli' + @("repo=$repo", 'version=0.27.0') | Out-File $testFile + + $postUpdateScript = "$testDir/post-update-explicit.ps1" + $markerFile = "$testDir/post-update-marker-explicit.txt" + @' +param([string] $originalVersion, [string] $newVersion) +"$originalVersion|$newVersion" | Out-File +'@ + " '$markerFile'" | Out-File $postUpdateScript + + # Simulate the second run where we explicitly set Tag and OriginalTag + $params = @{ + Path = $testFile + Tag = '0.28.0' + OriginalTag = '0.27.0' + PostUpdateScript = $postUpdateScript + } + $result = & "$PSScriptRoot/../scripts/update-dependency.ps1" @params + if (-not $?) { + throw $result + } + + # Verify post-update script was executed with correct versions + Test-Path $markerFile | Should -Be $true + $markerContent = Get-Content $markerFile + $markerContent | Should -Match '^0\.27\.0\|0\.28\.0$' + + # Clean up + Remove-Item $markerFile -ErrorAction SilentlyContinue + Remove-Item $postUpdateScript -ErrorAction SilentlyContinue + } + + It 'fails when Tag is provided without OriginalTag' { + $testFile = "$testDir/test.properties" + $repo = 'https://github.com/getsentry/sentry-cli' + @("repo=$repo", 'version=0.27.0') | Out-File $testFile + + $postUpdateScript = "$testDir/post-update-fail.ps1" + @' +param([string] $originalVersion, [string] $newVersion) +"$originalVersion|$newVersion" | Out-File marker.txt +'@ | Out-File $postUpdateScript + + # This should fail because Tag requires OriginalTag + $params = @{ + Path = $testFile + Tag = '0.28.0' + PostUpdateScript = $postUpdateScript + } + { & "$PSScriptRoot/../scripts/update-dependency.ps1" @params } | Should -Throw '*Expected*to be different*' + + # Clean up + Remove-Item $postUpdateScript -ErrorAction SilentlyContinue + } + + It 'runs bash post-update script with version arguments' -Skip:$IsWindows { + $testFile = "$testDir/test.properties" + $repo = 'https://github.com/getsentry/sentry-cli' + @("repo=$repo", 'version=0') | Out-File $testFile + + $postUpdateScript = "$testDir/post-update-test.sh" + $markerFile = "$testDir/post-update-marker.txt" + @" +#!/usr/bin/env bash +set -euo pipefail +echo "`$1|`$2" > '$markerFile' +"@ | Out-File $postUpdateScript + + UpdateDependency $testFile '^0\.' -postUpdateScript $postUpdateScript + + # Verify post-update script was executed + Test-Path $markerFile | Should -Be $true + $markerContent = Get-Content $markerFile + $markerContent | Should -Match '^0\|0\.28\.0$' + + # Clean up + Remove-Item $markerFile -ErrorAction SilentlyContinue + Remove-Item $postUpdateScript -ErrorAction SilentlyContinue + } + + It 'fails when post-update script does not exist' { + $testFile = "$testDir/test.properties" + $repo = 'https://github.com/getsentry/sentry-cli' + @("repo=$repo", 'version=0') | Out-File $testFile + + $postUpdateScript = "$testDir/nonexistent-script.ps1" + + { UpdateDependency $testFile '^0\.' -postUpdateScript $postUpdateScript } | Should -Throw '*Post-update script not found*' + } + + It 'fails when PowerShell post-update script exits with error' { + $testFile = "$testDir/test.properties" + $repo = 'https://github.com/getsentry/sentry-cli' + @("repo=$repo", 'version=0') | Out-File $testFile + + $postUpdateScript = "$testDir/failing-post-update.ps1" + @' +param([string] $originalVersion, [string] $newVersion) +throw "Post-update script failed intentionally" +'@ | Out-File $postUpdateScript + + { UpdateDependency $testFile '^0\.' -postUpdateScript $postUpdateScript } | Should -Throw '*Post-update script failed*' + + # Clean up + Remove-Item $postUpdateScript -ErrorAction SilentlyContinue + } + + It 'fails when bash post-update script exits with error' -Skip:$IsWindows { + $testFile = "$testDir/test.properties" + $repo = 'https://github.com/getsentry/sentry-cli' + @("repo=$repo", 'version=0') | Out-File $testFile + + $postUpdateScript = "$testDir/failing-post-update.sh" + @' +#!/usr/bin/env bash +exit 1 +'@ | Out-File $postUpdateScript + + { UpdateDependency $testFile '^0\.' -postUpdateScript $postUpdateScript } | Should -Throw '*Post-update script failed*' + + # Clean up + Remove-Item $postUpdateScript -ErrorAction SilentlyContinue + } + + It 'receives empty string for original version when updating from scratch' { + $testFile = "$testDir/test.properties" + $repo = 'https://github.com/getsentry/sentry-cli' + @("repo=$repo", 'version=') | Out-File $testFile + + $postUpdateScript = "$testDir/post-update-empty-original.ps1" + $markerFile = "$testDir/post-update-marker-empty.txt" + @' +param([string] $originalVersion, [string] $newVersion) +"original=[$originalVersion]|new=[$newVersion]" | Out-File +'@ + " '$markerFile'" | Out-File $postUpdateScript + + UpdateDependency $testFile '^0\.' -postUpdateScript $postUpdateScript + + # Verify post-update script received empty original version + Test-Path $markerFile | Should -Be $true + $markerContent = Get-Content $markerFile + $markerContent | Should -Match 'original=\[\]\|new=\[0\.28\.0\]' + + # Clean up + Remove-Item $markerFile -ErrorAction SilentlyContinue + Remove-Item $postUpdateScript -ErrorAction SilentlyContinue + } + } } diff --git a/updater/tests/workflow-args.sh b/updater/tests/workflow-args.sh old mode 100644 new mode 100755 index 6b8d1d45..8d68c115 --- a/updater/tests/workflow-args.sh +++ b/updater/tests/workflow-args.sh @@ -5,7 +5,21 @@ set -euo pipefail case $1 in get-version) - echo "latest" + # Return the actual latest tag to ensure no update is needed + # Always use remote lookup for consistency with update-dependency.ps1 + tags=$(git ls-remote --tags --refs https://github.com/getsentry/github-workflows.git | \ + sed 's/.*refs\/tags\///' | \ + grep -E '^v?[0-9.]+$') + + # Sort by version number, handling mixed v prefixes + latest=$(echo "$tags" | sed 's/^v//' | sort -V | tail -1) + + # Check if original had v prefix and restore it + if echo "$tags" | grep -q "^v$latest$"; then + echo "v$latest" + else + echo "$latest" + fi # Run actual tests here. if [[ "$(uname)" != 'Darwin' ]]; then diff --git a/validate-pr/README.md b/validate-pr/README.md new file mode 100644 index 00000000..779b179f --- /dev/null +++ b/validate-pr/README.md @@ -0,0 +1,96 @@ +# Validate PR + +Advisory validation for non-maintainer pull requests against contribution guidelines. Posts a single friendly comment when a PR doesn't reference an issue with prior maintainer discussion. **PRs are never closed, and no labels are applied.** + +## What it does + +For PRs from non-maintainer authors, the action checks that the PR body references a GitHub issue where the PR author and a maintainer have discussed the approach. When that's not the case, the action posts one short advisory comment inviting the contributor to start with an issue. Maintainers (`admin` or `maintain` role) and a hard-coded list of trusted bots are exempt. + +Small PRs (< 100 lines changed, excluding lock files) are skipped entirely — typo fixes and tiny bug fixes don't need to go through the issue-discussion loop. + +## Usage + +Create `.github/workflows/validate-pr.yml` in your repository: + +```yaml +name: Validate PR + +on: + pull_request_target: + types: [opened] + +jobs: + validate-pr: + runs-on: ubuntu-24.04 + permissions: + pull-requests: write + steps: + - uses: getsentry/github-workflows/validate-pr@ + with: + app-id: ${{ vars.SDK_MAINTAINER_BOT_APP_ID }} + private-key: ${{ secrets.SDK_MAINTAINER_BOT_PRIVATE_KEY }} +``` + +Pin to a specific commit SHA (consumers in `getsentry/*` already follow this convention). The `pull-requests: write` permission is needed because the action posts comments on the PR. + +## Inputs + +| Input | Required | Description | +|-------|----------|-------------| +| `app-id` | Yes | GitHub App ID for the SDK Maintainer Bot | +| `private-key` | Yes | GitHub App private key for the SDK Maintainer Bot | + +## Validation rules + +### Skipped entirely + +- PR author is in the trusted-bot allowlist (Dependabot, Renovate, Codecov AI, etc.) +- PR author has `admin`, `maintain`, `push`, or `write` access on the repo +- PR has fewer than 100 lines changed (`additions + deletions`), excluding common lock files + +### Lock files excluded from line counts + +Matched by basename, case-insensitive: + +| Ecosystem | File | +|-----------|------| +| Rust | `Cargo.lock` | +| JS | `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml` | +| Python | `Pipfile.lock`, `poetry.lock`, `uv.lock` | +| Ruby | `Gemfile.lock` | +| PHP | `composer.lock` | +| Go | `go.sum` (`go.mod` is hand-edited and stays counted) | +| Elixir | `mix.lock` | +| Dart/Flutter | `pubspec.lock` | +| .NET/NuGet | `packages.lock.json` | +| CocoaPods | `Podfile.lock` | +| Nix | `flake.lock` | + +### Issue reference check + +For PRs that reach validation, the action scans the PR body for issue references in these formats: + +- `#123` (same-repo) +- `getsentry/repo#123` (cross-repo) +- `https://github.com/getsentry/repo/issues/123` (full URL) +- With optional keywords: `Fixes #123`, `Closes getsentry/repo#123`, etc. + +The PR is considered compliant if **any** referenced issue passes all of: + +- The issue is fetchable and in a `getsentry` repository +- If the issue has assignees, the PR author is one of them +- Both the PR author and a maintainer have participated in the issue discussion + +If no referenced issue passes, the action posts one advisory comment. The PR remains open and reviewable; no labels or status checks are applied. The comment is idempotent — workflow re-runs on the same PR will not produce duplicates. + +## Updating from earlier revisions + +Earlier revisions of this action closed non-compliant PRs and applied labels (`violating-contribution-guidelines`, `missing-issue-reference`, `missing-maintainer-discussion`, `issue-already-assigned`). The current version does neither — it only posts a comment. + +To update an existing consumer: + +- Bump the pinned commit SHA to the latest on `main`. +- Change `types: [opened, reopened]` → `types: [opened]`. +- Remove any code that reads the `was-closed` output (it no longer exists). + +Existing labels on old PRs are not removed automatically. Clean them up with a one-off script if desired. diff --git a/validate-pr/action.yml b/validate-pr/action.yml new file mode 100644 index 00000000..d5b78b35 --- /dev/null +++ b/validate-pr/action.yml @@ -0,0 +1,32 @@ +name: 'Validate PR' +description: 'Validates non-maintainer PRs against contribution guidelines' +author: 'Sentry' + +inputs: + app-id: + description: 'GitHub App ID for the SDK Maintainer Bot' + required: true + private-key: + description: 'GitHub App private key for the SDK Maintainer Bot' + required: true + +runs: + using: 'composite' + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2 + with: + app-id: ${{ inputs.app-id }} + private-key: ${{ inputs.private-key }} + + - name: Validate PR + id: validate + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + env: + APP_SLUG: ${{ steps.app-token.outputs.app-slug }} + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const script = require('${{ github.action_path }}/scripts/validate-pr.js'); + await script({ github, context, core }); diff --git a/validate-pr/scripts/validate-pr.js b/validate-pr/scripts/validate-pr.js new file mode 100644 index 00000000..ac823c7d --- /dev/null +++ b/validate-pr/scripts/validate-pr.js @@ -0,0 +1,277 @@ +// @ts-check + +/** + * Advisory validation for community PRs. Posts a single friendly comment + * when the PR doesn't reference an issue with maintainer discussion. + * Never closes PRs, never applies labels. + * + * @param {object} params + * @param {import('@actions/github').getOctokit} params.github + * @param {import('@actions/github').context} params.context + * @param {import('@actions/core')} params.core + */ +module.exports = async ({ github, context, core }) => { + const pullRequest = context.payload.pull_request; + const repo = context.repo; + const prAuthor = pullRequest.user.login; + const contributingUrl = `https://github.com/${repo.owner}/${repo.repo}/blob/${context.payload.repository.default_branch}/CONTRIBUTING.md`; + + // --- Step 0: Skip allowed bots and service accounts --- + const ALLOWED_BOTS = [ + 'codecov-ai[bot]', + 'dependabot[bot]', + 'fix-it-felix-sentry[bot]', + 'getsentry-bot', + 'github-actions[bot]', + 'javascript-sdk-gitflow[bot]', + 'renovate[bot]', + 'sentry-mobile-updater[bot]', + ]; + if (ALLOWED_BOTS.includes(prAuthor)) { + core.info(`PR author ${prAuthor} is an allowed bot. Skipping.`); + return; + } + + // --- Helpers: cached collaborator-role lookup --- + const roleCache = new Map(); + async function getRole(owner, repoName, username) { + const key = `${owner}/${repoName}:${username}`; + if (roleCache.has(key)) return roleCache.get(key); + let roleName = null; + try { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, + repo: repoName, + username, + }); + roleName = data.role_name; + } catch { + // noop — roleName stays null + } + roleCache.set(key, roleName); + return roleName; + } + + async function hasWriteAccess(owner, repoName, username) { + const role = await getRole(owner, repoName, username); + return ['admin', 'maintain', 'push', 'write'].includes(role); + } + + async function isMaintainer(owner, repoName, username) { + const role = await getRole(owner, repoName, username); + return ['admin', 'maintain'].includes(role); + } + + // --- Step 1: Skip if PR author has write+ access --- + if (await hasWriteAccess(repo.owner, repo.repo, prAuthor)) { + core.info(`PR author ${prAuthor} has write+ access. Skipping.`); + return; + } + core.info(`PR author ${prAuthor} does not have write access.`); + + // --- Step 2: Small-PR bypass (excluding lock files) --- + const LOCK_FILE_BASENAMES = new Set([ + 'cargo.lock', + 'package-lock.json', + 'yarn.lock', + 'pnpm-lock.yaml', + 'pipfile.lock', + 'poetry.lock', + 'uv.lock', + 'gemfile.lock', + 'composer.lock', + 'go.sum', + 'mix.lock', + 'pubspec.lock', + 'packages.lock.json', + 'podfile.lock', + 'flake.lock', + ]); + const SMALL_PR_THRESHOLD = 100; + + const aggregateLOC = (pullRequest.additions || 0) + (pullRequest.deletions || 0); + if (aggregateLOC < SMALL_PR_THRESHOLD) { + core.info( + `PR has ${aggregateLOC} lines changed (< ${SMALL_PR_THRESHOLD}). Skipping.` + ); + return; + } + + // Stage 2: fetch file list and recompute excluding lock files. On API + // failure, fall through to validation rather than aborting — a transient + // GitHub error shouldn't break the action. + let files = null; + try { + files = await github.paginate(github.rest.pulls.listFiles, { + owner: repo.owner, + repo: repo.repo, + pull_number: pullRequest.number, + per_page: 100, + }); + } catch (e) { + core.warning( + `Could not fetch PR file list (${e.message}); skipping lock-file exclusion and proceeding to validation.` + ); + } + if (files) { + function basenameLower(path) { + const idx = path.lastIndexOf('/'); + return (idx >= 0 ? path.slice(idx + 1) : path).toLowerCase(); + } + const nonLockLOC = files + .filter((f) => !LOCK_FILE_BASENAMES.has(basenameLower(f.filename))) + .reduce((sum, f) => sum + (f.additions || 0) + (f.deletions || 0), 0); + if (nonLockLOC < SMALL_PR_THRESHOLD) { + core.info( + `PR has ${nonLockLOC} non-lock-file lines changed (< ${SMALL_PR_THRESHOLD}). Skipping.` + ); + return; + } + } + + // --- Step 3: Parse issue references from PR body --- + const body = pullRequest.body || ''; + const issueRefs = []; + const seen = new Set(); + + // Pattern 1: full GitHub URLs + const urlPattern = /https?:\/\/github\.com\/(getsentry)\/([\w.-]+)\/issues\/(\d+)/gi; + for (const match of body.matchAll(urlPattern)) { + const key = `${match[1]}/${match[2]}#${match[3]}`; + if (!seen.has(key)) { + seen.add(key); + issueRefs.push({ owner: match[1], repo: match[2], number: parseInt(match[3]) }); + } + } + + // Pattern 2: cross-repo references (getsentry/repo#123) + const crossRepoPattern = /(?:(?:fix|fixes|fixed|close|closes|closed|resolve|resolves|resolved)\s+)?(getsentry)\/([\w.-]+)#(\d+)/gi; + for (const match of body.matchAll(crossRepoPattern)) { + const key = `${match[1]}/${match[2]}#${match[3]}`; + if (!seen.has(key)) { + seen.add(key); + issueRefs.push({ owner: match[1], repo: match[2], number: parseInt(match[3]) }); + } + } + + // Pattern 3: same-repo references (#123) + const sameRepoPattern = /(?:(?:fix|fixes|fixed|close|closes|closed|resolve|resolves|resolved)\s+)?(? 0) { + const assignedToAuthor = issue.assignees.some((a) => a.login === prAuthor); + if (!assignedToAuthor) { + core.info(`Issue ${ref.owner}/${ref.repo}#${ref.number} is assigned to someone else.`); + continue; + } + } + + // Discussion check: PR author and a maintainer must both have participated. + const comments = await github.paginate(github.rest.issues.listComments, { + owner: ref.owner, + repo: ref.repo, + issue_number: ref.number, + per_page: 100, + }); + + const prAuthorParticipated = + issue.user?.login === prAuthor || + comments.some((c) => c.user?.login === prAuthor); + + if (!prAuthorParticipated) { + core.info(`Issue ${ref.owner}/${ref.repo}#${ref.number} has no PR author participation.`); + continue; + } + + const usersToCheck = new Set(); + if (issue.user?.login && issue.user.login !== prAuthor) { + usersToCheck.add(issue.user.login); + } + for (const comment of comments) { + if (comment.user?.login && comment.user.login !== prAuthor) { + usersToCheck.add(comment.user.login); + } + } + + let maintainerParticipated = false; + for (const user of usersToCheck) { + if (await isMaintainer(repo.owner, repo.repo, user)) { + maintainerParticipated = true; + core.info(`Maintainer ${user} participated in ${ref.owner}/${ref.repo}#${ref.number}.`); + break; + } + } + + if (maintainerParticipated) { + core.info(`Issue ${ref.owner}/${ref.repo}#${ref.number} has valid discussion. PR is allowed.`); + return; + } + core.info(`Issue ${ref.owner}/${ref.repo}#${ref.number} lacks maintainer participation.`); + } + + // --- Step 5: Validation failed — post one warm comment (idempotent) --- + // The bot's GitHub login is `${app.slug}[bot]`. We get the slug from the + // composite action via the APP_SLUG env var (sourced from + // create-github-app-token's `app-slug` output) — calling + // apps.getAuthenticated() here would fail because it requires JWT auth + // and the github client is authenticated with an installation token. + const appSlug = process.env.APP_SLUG; + if (!appSlug) { + core.setFailed( + 'APP_SLUG env var is not set. The validate-pr composite action must pass app-slug from create-github-app-token to the script.' + ); + return; + } + const botLogin = `${appSlug}[bot]`; + + const existing = await github.paginate(github.rest.issues.listComments, { + ...repo, + issue_number: pullRequest.number, + per_page: 100, + }); + if (existing.some((c) => c.user?.login === botLogin)) { + core.info(`Bot ${botLogin} already commented on this PR. Skipping.`); + return; + } + + const commentBody = [ + '👋 Thanks for sending this our way! Before a maintainer reviews the code, we ask community contributors to align with us on the approach first — it keeps your time pointed at changes we can land.', + '', + 'The easiest way is to open or find a GitHub issue and discuss the approach with a maintainer there, then link that issue from this PR. If the issue is already assigned to someone else, please check in with them (or with us) before continuing — otherwise two people may end up working on the same task.', + '', + `See our [contributing guidelines](${contributingUrl}) for the full picture.`, + ].join('\n'); + + await github.rest.issues.createComment({ + ...repo, + issue_number: pullRequest.number, + body: commentBody, + }); + core.info('Posted advisory comment. PR remains open.'); +};