diff --git a/.github/actions/build-signed-apk/action.yml b/.github/actions/build-signed-apk/action.yml new file mode 100644 index 0000000000..34dc28eb99 --- /dev/null +++ b/.github/actions/build-signed-apk/action.yml @@ -0,0 +1,78 @@ +name: Build signed debug APK +description: >- + Decodes the debug keystore and assembles the signed Dhis2 debug APK. + Assumes the repository is checked out and a JDK is already set up. + +inputs: + keystore-base64: + description: Base64-encoded debug keystore. + required: true + keystore-alias: + description: Debug keystore alias. + required: true + key-pass: + description: Debug key password. + required: true + keystore-password: + description: Debug keystore password. + required: true + sentry-dsn: + description: Sentry DSN. + required: false + default: '' + open-id-auth-scheme: + required: false + default: '' + open-id-type: + required: false + default: '' + open-id-server: + required: false + default: '' + open-id-client: + required: false + default: '' + open-id-redirect-uri: + required: false + default: '' + open-id-discovery-uri: + required: false + default: '' + open-id-authorization-url: + required: false + default: '' + open-id-token-url: + required: false + default: '' + open-id-button-text: + required: false + default: '' + +runs: + using: composite + steps: + - name: Decode keystore + id: decode_keystore + uses: timheuer/base64-to-file@dfa5a5dd38803cb4f5c3a081eeb28e8362d43e9c + with: + fileName: 'debug.keystore' + encodedString: ${{ inputs.keystore-base64 }} + + - name: Assemble Dhis2 debug APK + shell: bash + env: + SENTRY_DSN: ${{ inputs.sentry-dsn }} + DEBUG_KEYSTORE_ALIAS: ${{ inputs.keystore-alias }} + DEBUG_KEY_PASS: ${{ inputs.key-pass }} + DEBUG_KEYSTORE_PASSWORD: ${{ inputs.keystore-password }} + DEBUG_KEYSTORE_PATH: ${{ steps.decode_keystore.outputs.filePath }} + OPEN_ID_AUTH_SCHEME: ${{ inputs.open-id-auth-scheme }} + OPEN_ID_TYPE: ${{ inputs.open-id-type }} + OPEN_ID_SERVER: ${{ inputs.open-id-server }} + OPEN_ID_CLIENT: ${{ inputs.open-id-client }} + OPEN_ID_REDIRECT_URI: ${{ inputs.open-id-redirect-uri }} + OPEN_ID_DISCOVERY_URI: ${{ inputs.open-id-discovery-uri }} + OPEN_ID_AUTHORIZATION_URL: ${{ inputs.open-id-authorization-url }} + OPEN_ID_TOKEN_URL: ${{ inputs.open-id-token-url }} + OPEN_ID_BUTTON_TEXT: ${{ inputs.open-id-button-text }} + run: ./gradlew assembleDhis2Debug diff --git a/.github/actions/run-form-tests/action.yml b/.github/actions/run-form-tests/action.yml new file mode 100644 index 0000000000..53d8d92660 --- /dev/null +++ b/.github/actions/run-form-tests/action.yml @@ -0,0 +1,31 @@ +name: Run form tests on BrowserStack +description: >- + Locates the built form androidTest APK and runs the BrowserStack form suite. + Assumes the form APK already exists under form/build/outputs. + +inputs: + browserstack-username: + description: BrowserStack username. + required: true + browserstack-password: + description: BrowserStack access key. + required: true + build-tag: + description: BrowserStack build tag. + required: true + +runs: + using: composite + steps: + - name: Run form tests + shell: bash + env: + BROWSERSTACK_USR: ${{ inputs.browserstack-username }} + BROWSERSTACK_PSW: ${{ inputs.browserstack-password }} + buildTag: ${{ inputs.build-tag }} + run: | + form_apk=$(find form/build/outputs -iname "*.apk" | sed -n 1p) + export form_apk_path="${GITHUB_WORKSPACE}/${form_apk}" + cd scripts + chmod +x browserstackJenkinsForm.sh + ./browserstackJenkinsForm.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e60ab26b7..6403ad0554 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,7 @@ name: CI on: pull_request: + types: [opened, synchronize, reopened, edited] push: branches: - main @@ -124,16 +125,11 @@ jobs: path: . - name: Run Form Tests - env: - BROWSERSTACK_USR: ${{ secrets.BROWSERSTACK_USERNAME }} - BROWSERSTACK_PSW: ${{ secrets.BROWSERSTACK_PASSWORD }} - buildTag: ${{ github.head_ref || github.ref_name }} - form - run: | - form_apk=$(find form/build/outputs -iname "*.apk" | sed -n 1p) - export form_apk_path="${GITHUB_WORKSPACE}/${form_apk}" - cd scripts - chmod +x browserstackJenkinsForm.sh - ./browserstackJenkinsForm.sh + uses: ./.github/actions/run-form-tests + with: + browserstack-username: ${{ secrets.BROWSERSTACK_USERNAME }} + browserstack-password: ${{ secrets.BROWSERSTACK_PASSWORD }} + build-tag: ${{ github.head_ref || github.ref_name }} - form run-ui-portrait: runs-on: ubuntu-latest diff --git a/.github/workflows/continuous-delivery.yml b/.github/workflows/continuous-delivery.yml index dd071ced06..8a2d01bd54 100644 --- a/.github/workflows/continuous-delivery.yml +++ b/.github/workflows/continuous-delivery.yml @@ -42,32 +42,25 @@ jobs: - name: Change wrapper permissions run: chmod +x ./gradlew - - name: Decode Keystore - id: decode_keystore - uses: timheuer/base64-to-file@dfa5a5dd38803cb4f5c3a081eeb28e8362d43e9c - with: - fileName: 'debug.keystore' - encodedString: ${{ secrets.DEBUG_KEYSTORE_BASE64 }} - # Create APK Debug - name: Build apk debug project (APK) - ${{ env.main_project_module }} module - run: ./gradlew assembleDhis2Debug - env: - SENTRY_DSN: ${{ secrets.SENTRY_DSN }} - DEBUG_KEYSTORE_ALIAS: ${{ secrets.DEBUG_KEYSTORE_ALIAS }} - DEBUG_KEY_PASS: ${{ secrets.DEBUG_KEY_PASS }} - DEBUG_KEYSTORE_PASSWORD: ${{ secrets.DEBUG_KEYSTORE_PASSWORD }} - DEBUG_KEYSTORE_PATH: ${{ steps.decode_keystore.outputs.filePath }} - OPEN_ID_AUTH_SCHEME: ${{ secrets.OPEN_ID_AUTH_SCHEME }} - OPEN_ID_TYPE: ${{ secrets.OPEN_ID_TYPE }} - OPEN_ID_SERVER: ${{ secrets.OPEN_ID_SERVER }} - OPEN_ID_CLIENT: ${{ secrets.OPEN_ID_CLIENT }} - OPEN_ID_REDIRECT_URI: ${{ secrets.OPEN_ID_REDIRECT_URI }} - OPEN_ID_DISCOVERY_URI: ${{ secrets.OPEN_ID_DISCOVERY_URI }} - OPEN_ID_AUTHORIZATION_URL: ${{ secrets.OPEN_ID_AUTHORIZATION_URL }} - OPEN_ID_TOKEN_URL: ${{ secrets.OPEN_ID_TOKEN_URL }} - OPEN_ID_BUTTON_TEXT: ${{ secrets.OPEN_ID_BUTTON_TEXT }} - + uses: ./.github/actions/build-signed-apk + with: + keystore-base64: ${{ secrets.DEBUG_KEYSTORE_BASE64 }} + keystore-alias: ${{ secrets.DEBUG_KEYSTORE_ALIAS }} + key-pass: ${{ secrets.DEBUG_KEY_PASS }} + keystore-password: ${{ secrets.DEBUG_KEYSTORE_PASSWORD }} + sentry-dsn: ${{ secrets.SENTRY_DSN }} + open-id-auth-scheme: ${{ secrets.OPEN_ID_AUTH_SCHEME }} + open-id-type: ${{ secrets.OPEN_ID_TYPE }} + open-id-server: ${{ secrets.OPEN_ID_SERVER }} + open-id-client: ${{ secrets.OPEN_ID_CLIENT }} + open-id-redirect-uri: ${{ secrets.OPEN_ID_REDIRECT_URI }} + open-id-discovery-uri: ${{ secrets.OPEN_ID_DISCOVERY_URI }} + open-id-authorization-url: ${{ secrets.OPEN_ID_AUTHORIZATION_URL }} + open-id-token-url: ${{ secrets.OPEN_ID_TOKEN_URL }} + open-id-button-text: ${{ secrets.OPEN_ID_BUTTON_TEXT }} + - name: Locate built APK id: apk-path run: | diff --git a/.github/workflows/retest-fork-pr.yml b/.github/workflows/retest-fork-pr.yml new file mode 100644 index 0000000000..735484fb1e --- /dev/null +++ b/.github/workflows/retest-fork-pr.yml @@ -0,0 +1,245 @@ +name: Retest Fork PR + +# Manually re-run the secret-dependent required checks on a fork PR. +# +# Fork pull_request runs do not receive repository secrets, so the +# code-quality / run-form-tests / deployment_job checks fail and a plain +# re-run cannot fix them. A maintainer commenting "/retest" on the PR runs +# this workflow in the BASE repo context (secrets available), checks out the +# PR head, runs those tasks, and reports check runs with the exact required +# names against the PR head SHA so the required checks can turn green. +# +# SECURITY: this executes the fork's code (Gradle build scripts, the built +# APK on BrowserStack) with secrets present. Only trigger it on PRs whose +# diff you have reviewed. Triggering is restricted to users with write/admin. + +on: + issue_comment: + types: [created] + +concurrency: + group: retest-${{ github.event.issue.number }} + cancel-in-progress: true + +jobs: + authorize: + # Only react to "/retest" comments on pull requests. + if: ${{ github.event.issue.pull_request && startsWith(github.event.comment.body, '/retest') }} + runs-on: ubuntu-latest + permissions: + issues: write # add a reaction on the PR conversation (issue) comment + pull-requests: read # read PR head SHA/ref + outputs: + authorized: ${{ steps.check.outputs.authorized }} + head_sha: ${{ steps.check.outputs.head_sha }} + head_ref: ${{ steps.check.outputs.head_ref }} + steps: + - name: Authorize and resolve PR head + id: check + uses: actions/github-script@v7 + with: + script: | + const body = (context.payload.comment.body || '').trim(); + if (body !== '/retest') { + core.info(`Comment is not exactly "/retest" (got: "${body}"). Ignoring.`); + core.setOutput('authorized', 'false'); + return; + } + + // Require the commenter to have write or admin permission. + const actor = context.payload.comment.user.login; + const perm = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: actor, + }); + const level = perm.data.permission; // admin | write | read | none + if (level !== 'admin' && level !== 'write') { + core.warning(`@${actor} has '${level}' permission; /retest requires write or admin.`); + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: 'confused', + }); + core.setOutput('authorized', 'false'); + return; + } + + const pr = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + }); + + core.setOutput('authorized', 'true'); + core.setOutput('head_sha', pr.data.head.sha); + core.setOutput('head_ref', pr.data.head.ref); + + // Acknowledge the request. + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: 'eyes', + }); + + retest: + needs: authorize + if: ${{ needs.authorize.outputs.authorized == 'true' }} + runs-on: ubuntu-latest + permissions: + checks: write # create/update check runs on the PR head SHA + contents: read # checkout the PR head + issues: write # add the result reaction on the comment + env: + # Required-check names — MUST match the ruleset's required status checks exactly. + CHECK_CODE_QUALITY: code-quality + CHECK_FORM_TESTS: run-form-tests + CHECK_DEPLOYMENT: deployment_job + steps: + - name: Create in-progress check runs + id: checks + uses: actions/github-script@v7 + with: + script: | + const headSha = '${{ needs.authorize.outputs.head_sha }}'; + const detailsUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const names = [ + process.env.CHECK_CODE_QUALITY, + process.env.CHECK_FORM_TESTS, + process.env.CHECK_DEPLOYMENT, + ]; + const ids = {}; + for (const name of names) { + const res = await github.rest.checks.create({ + owner: context.repo.owner, + repo: context.repo.repo, + name, + head_sha: headSha, + status: 'in_progress', + details_url: detailsUrl, + }); + ids[name] = res.data.id; + } + core.setOutput('ids', JSON.stringify(ids)); + + - name: Checkout PR head + uses: actions/checkout@v7 + with: + ref: ${{ needs.authorize.outputs.head_sha }} + fetch-depth: 0 + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: 'zulu' + java-version: '17' + cache: 'gradle' + + - name: Change wrapper permissions + run: chmod +x ./gradlew + + # ---- code-quality (Sonar) ---- + - name: Code quality (Sonar) + id: code_quality + continue-on-error: true + env: + GIT_BRANCH: ${{ needs.authorize.outputs.head_ref }} + GIT_BRANCH_DEST: ${{ github.event.repository.default_branch }} + PULL_REQUEST: ${{ github.event.issue.number }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: | + ./gradlew testDebugUnitTest testDhis2DebugUnitTest testAndroidHostTest jacocoReport --stacktrace --no-daemon + chmod +x ./scripts/sonarqube.sh + ./scripts/sonarqube.sh + + # ---- deployment_job (signed debug APK build) ---- + - name: Build signed debug APK + id: deployment + if: ${{ always() }} + continue-on-error: true + uses: ./.github/actions/build-signed-apk + with: + keystore-base64: ${{ secrets.DEBUG_KEYSTORE_BASE64 }} + keystore-alias: ${{ secrets.DEBUG_KEYSTORE_ALIAS }} + key-pass: ${{ secrets.DEBUG_KEY_PASS }} + keystore-password: ${{ secrets.DEBUG_KEYSTORE_PASSWORD }} + sentry-dsn: ${{ secrets.SENTRY_DSN }} + open-id-auth-scheme: ${{ secrets.OPEN_ID_AUTH_SCHEME }} + open-id-type: ${{ secrets.OPEN_ID_TYPE }} + open-id-server: ${{ secrets.OPEN_ID_SERVER }} + open-id-client: ${{ secrets.OPEN_ID_CLIENT }} + open-id-redirect-uri: ${{ secrets.OPEN_ID_REDIRECT_URI }} + open-id-discovery-uri: ${{ secrets.OPEN_ID_DISCOVERY_URI }} + open-id-authorization-url: ${{ secrets.OPEN_ID_AUTHORIZATION_URL }} + open-id-token-url: ${{ secrets.OPEN_ID_TOKEN_URL }} + open-id-button-text: ${{ secrets.OPEN_ID_BUTTON_TEXT }} + + # ---- run-form-tests (BrowserStack) ---- + - name: Build form test APK + id: build_form_apk + if: ${{ always() }} + continue-on-error: true + run: ./gradlew :form:assembleAndroidTest --no-daemon + + - name: Run form tests (BrowserStack) + id: form_tests + if: ${{ always() && steps.build_form_apk.outcome == 'success' }} + continue-on-error: true + uses: ./.github/actions/run-form-tests + with: + browserstack-username: ${{ secrets.BROWSERSTACK_USERNAME }} + browserstack-password: ${{ secrets.BROWSERSTACK_PASSWORD }} + build-tag: ${{ needs.authorize.outputs.head_ref }} - form (retest) + + # ---- Conclude check runs on the PR head SHA ---- + - name: Conclude check runs + if: ${{ always() }} + uses: actions/github-script@v7 + env: + CODE_QUALITY_OUTCOME: ${{ steps.code_quality.outcome }} + DEPLOYMENT_OUTCOME: ${{ steps.deployment.outcome }} + FORM_BUILD_OUTCOME: ${{ steps.build_form_apk.outcome }} + FORM_TESTS_OUTCOME: ${{ steps.form_tests.outcome }} + with: + script: | + const ids = JSON.parse('${{ steps.checks.outputs.ids }}' || '{}'); + const detailsUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const toConclusion = (o) => (o === 'success' ? 'success' : 'failure'); + + const formOutcome = + process.env.FORM_BUILD_OUTCOME === 'success' + ? process.env.FORM_TESTS_OUTCOME + : 'failure'; + + const results = { + [process.env.CHECK_CODE_QUALITY]: toConclusion(process.env.CODE_QUALITY_OUTCOME), + [process.env.CHECK_FORM_TESTS]: toConclusion(formOutcome), + [process.env.CHECK_DEPLOYMENT]: toConclusion(process.env.DEPLOYMENT_OUTCOME), + }; + + let allGreen = true; + for (const [name, conclusion] of Object.entries(results)) { + if (conclusion !== 'success') allGreen = false; + const id = ids[name]; + if (!id) { + core.warning(`No check run id recorded for "${name}"; skipping.`); + continue; + } + await github.rest.checks.update({ + owner: context.repo.owner, + repo: context.repo.repo, + check_run_id: id, + status: 'completed', + conclusion, + details_url: detailsUrl, + }); + } + + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: allGreen ? '+1' : '-1', + });