diff --git a/.github/actions/pr-security-validation/action.yml b/.github/actions/pr-security-validation/action.yml new file mode 100644 index 0000000000..e598b0ba3a --- /dev/null +++ b/.github/actions/pr-security-validation/action.yml @@ -0,0 +1,148 @@ +name: PR Security Validation +description: Validates PR security before executing code with access to secrets + +inputs: + pr_number: + description: PR number to validate + required: true + github_token: + description: GitHub token with repo read permission + required: false + default: ${{ github.token }} + triggered_by: + description: User who triggered the workflow + required: false + default: ${{ github.actor }} + +outputs: + pr_number: + description: Validated PR number + value: ${{ steps.get-pr-data.outputs.pr_number }} + pr_sha: + description: PR commit SHA + value: ${{ steps.get-pr-data.outputs.pr_sha }} + pr_ref: + description: PR branch reference + value: ${{ steps.get-pr-data.outputs.pr_ref }} + pr_author: + description: PR author username + value: ${{ steps.get-pr-data.outputs.pr_author }} + +runs: + using: composite + steps: + - name: Get PR data + id: get-pr-data + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + PR_NUMBER: ${{ inputs.pr_number }} + with: + github-token: ${{ inputs.github_token }} + script: | + const prNumber = parseInt(process.env.PR_NUMBER, 10); + + try { + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + + const prData = { + pr_number: pr.number, + pr_sha: pr.head.sha, + pr_ref: pr.head.ref, + head_repo: pr.head.repo?.full_name || 'null', + base_repo: pr.base.repo?.full_name || 'null', + pr_state: pr.state, + pr_author: pr.user.login, + }; + + if (!prData.pr_number || !prData.pr_sha) { + throw new Error(`PR data is incomplete (number: ${prData.pr_number}, sha: ${prData.pr_sha})`); + } + + core.setOutput('pr_number', prData.pr_number); + core.setOutput('pr_sha', prData.pr_sha); + core.setOutput('pr_ref', prData.pr_ref); + core.setOutput('head_repo', prData.head_repo); + core.setOutput('base_repo', prData.base_repo); + core.setOutput('pr_state', prData.pr_state); + core.setOutput('pr_author', prData.pr_author); + + core.info(`PR #${prData.pr_number}`); + core.info(`State: ${prData.pr_state}`); + core.info(`Author: ${prData.pr_author}`); + core.info(`Commit: ${prData.pr_sha}`); + core.info(`Head repo: ${prData.head_repo}`); + core.info(`Base repo: ${prData.base_repo}`); + } catch (error) { + core.setFailed(`Failed to fetch PR data: ${error.message}`); + } + + - name: Validate PR and check for forks + shell: bash + env: + HEAD_REPO: ${{ steps.get-pr-data.outputs.head_repo }} + BASE_REPO: ${{ steps.get-pr-data.outputs.base_repo }} + PR_STATE: ${{ steps.get-pr-data.outputs.pr_state }} + run: | + echo "Running security checks..." + + if [ "$PR_STATE" != "open" ]; then + echo "::error::PR is not open (state: $PR_STATE)" + exit 1 + fi + + if [ -z "$HEAD_REPO" ] || [ "$HEAD_REPO" = "null" ]; then + echo "::error::Cannot determine head repository. Rejecting as a security precaution." + exit 1 + fi + + if [ -z "$BASE_REPO" ] || [ "$BASE_REPO" = "null" ]; then + echo "::error::Cannot determine base repository. Rejecting as a security precaution." + exit 1 + fi + + if [ "$HEAD_REPO" != "$BASE_REPO" ]; then + echo "::error::Dev releases are not permitted from forked repositories." + echo "" + echo "Head repo: $HEAD_REPO" + echo "Base repo: $BASE_REPO" + echo "" + echo "Create a branch directly in the main repository instead of using a fork." + exit 1 + fi + + echo "PR is from the same repository" + + - name: Check user authorisation + id: check-authorisation + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + TRIGGERED_BY: ${{ inputs.triggered_by }} + with: + github-token: ${{ inputs.github_token }} + script: | + const triggeredBy = process.env.TRIGGERED_BY; + core.info(`Checking permission for user: ${triggeredBy}`); + + try { + const { data: permission } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: triggeredBy, + }); + + const userPermission = permission.permission; + core.info(`User permission: ${userPermission}`); + + if (!userPermission || userPermission === 'none' || userPermission === 'read') { + core.setFailed(`User ${triggeredBy} does not have write access to this repository (permission: ${userPermission}).`); + return; + } + + core.info(`User is authorised (permission: ${userPermission})`); + } catch (error) { + core.setFailed(`Unable to determine user permission: ${error.message}`); + } diff --git a/.github/actions/upsert-pr-comment/action.yml b/.github/actions/upsert-pr-comment/action.yml new file mode 100644 index 0000000000..37f58250bf --- /dev/null +++ b/.github/actions/upsert-pr-comment/action.yml @@ -0,0 +1,59 @@ +name: Upsert PR Comment +description: Create or update a comment on a pull request, identified by a unique marker + +inputs: + pr_number: + description: The pull request number to comment on + required: true + marker: + description: HTML comment marker used to identify and update existing comments + required: true + body: + description: Full comment body (should include the marker) + required: true + github_token: + description: GitHub token for API authentication + required: false + default: ${{ github.token }} + +runs: + using: composite + steps: + - name: Create or update PR comment + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + PR_NUMBER: ${{ inputs.pr_number }} + COMMENT_MARKER: ${{ inputs.marker }} + COMMENT_BODY: ${{ inputs.body }} + with: + github-token: ${{ inputs.github_token }} + script: | + const prNumber = Number(process.env.PR_NUMBER); + const marker = process.env.COMMENT_MARKER; + const body = process.env.COMMENT_BODY; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + }); + + const existingComment = comments.find(comment => comment.body.includes(marker)); + + if (existingComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + body, + }); + core.info(`Updated existing comment ${existingComment.id}`); + } else { + const { data: created } = await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body, + }); + core.info(`Created new comment ${created.id}`); + } diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml new file mode 100644 index 0000000000..dcbbba5866 --- /dev/null +++ b/.github/workflows/dev-release.yml @@ -0,0 +1,198 @@ +name: Dev Release + +on: + workflow_dispatch: + inputs: + pr_number: + description: "Pull request number to publish a dev release from" + required: true + type: number + dry_run: + description: "Run the full build pipeline without publishing to npm" + required: false + type: boolean + default: false + +defaults: + run: + shell: bash -l {0} + +env: + CACHE_NAME: node-modules-cache + PR_COMMENT_MARKER: "" + +permissions: {} + +jobs: + SecurityCheck: + name: Validate PR and permissions + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + outputs: + pr_sha: ${{ steps.validate.outputs.pr_sha }} + steps: + - name: Checkout for actions + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + sparse-checkout: .github/actions + + - name: Validate PR security + id: validate + uses: ./.github/actions/pr-security-validation + with: + pr_number: ${{ inputs.pr_number }} + + - name: Add or update failure comment on PR + if: failure() + uses: ./.github/actions/upsert-pr-comment + with: + pr_number: ${{ inputs.pr_number }} + marker: ${{ env.PR_COMMENT_MARKER }} + body: | + ${{ env.PR_COMMENT_MARKER }} + ### Dev Release Failed + + The dev release workflow failed a security check. See the [workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details. + + Triggered by @${{ github.actor }}. + + DevRelease: + name: Build and publish dev release + runs-on: ubuntu-latest + environment: Publishing + needs: [SecurityCheck] + permissions: + contents: read + outputs: + dev_version: ${{ steps.version.outputs.dev_version }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ needs.SecurityCheck.outputs.pr_sha }} + persist-credentials: false + fetch-depth: 0 + + - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version-file: ".nvmrc" + registry-url: 'https://registry.npmjs.org' + + - name: Cache npm dependencies + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + id: npm-cache + with: + path: | + node_modules/ + packages/node_modules/ + key: ${{ env.CACHE_NAME }}-${{ hashFiles('package-lock.json', 'packages/package-lock.json') }} + + - name: Install dependencies + if: ${{ steps.npm-cache.outputs.cache-hit != 'true' }} + run: npm ci + + - name: Determine dev version + id: version + run: | + BASE_VERSION=$(git describe --tags --abbrev=0 --exclude='*-*' HEAD) + DEV_VERSION="${BASE_VERSION}-dev-v${GITHUB_RUN_ID}.${GITHUB_RUN_ATTEMPT}" + echo "dev_version=${DEV_VERSION}" >> "$GITHUB_OUTPUT" + echo "Dev version: ${DEV_VERSION}" + + - name: Transpile + run: npm run transpile + + - name: Publish dev release to npm + if: ${{ inputs.dry_run != true }} + run: | + cd dist + npm version $DEV_VERSION --no-git-tag-version + npm publish --tag dev + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + DEV_VERSION: ${{ steps.version.outputs.dev_version }} + + ReportResults: + name: Report results on PR + runs-on: ubuntu-latest + needs: [SecurityCheck, DevRelease] + if: always() && needs.SecurityCheck.result == 'success' + permissions: + contents: read + pull-requests: write + steps: + - name: Checkout for actions + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + sparse-checkout: .github/actions + + - name: Build comment body + id: build-body + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + DEV_VERSION: ${{ needs.DevRelease.outputs.dev_version }} + PR_SHA: ${{ needs.SecurityCheck.outputs.pr_sha }} + BUILD_RESULT: ${{ needs.DevRelease.result }} + DRY_RUN: ${{ inputs.dry_run }} + RUN_ID: ${{ github.run_id }} + REPO: ${{ github.repository }} + PR_COMMENT_MARKER: ${{ env.PR_COMMENT_MARKER }} + with: + script: | + const marker = process.env.PR_COMMENT_MARKER; + let body; + + if (process.env.BUILD_RESULT === 'success' && process.env.DRY_RUN === 'true') { + body = [ + marker, + `### Dev Release Dry Run Successful`, + '', + `**Version (not published):** \`${process.env.DEV_VERSION}\``, + '', + 'The build and transpilation completed successfully. No package was published to npm.', + '', + 'Run the workflow again without dry run to publish this version.', + '', + `Triggered from commit ${process.env.PR_SHA} by @${context.actor}.`, + ].join('\n'); + } else if (process.env.BUILD_RESULT === 'success') { + body = [ + marker, + `### Dev Release Published`, + '', + `**Version:** \`${process.env.DEV_VERSION}\``, + '', + '**Install with:**', + '```', + `npm install @skyscanner/backpack-web@${process.env.DEV_VERSION}`, + '```', + '', + `You can also use the dev tag to install the latest dev release:`, + '```', + 'npm install @skyscanner/backpack-web@dev', + '```', + '', + `Published from commit ${process.env.PR_SHA} by @${context.actor}.`, + ].join('\n'); + } else { + body = [ + marker, + `### Dev Release Failed`, + '', + `The dev release workflow failed. See the [workflow run](https://github.com/${process.env.REPO}/actions/runs/${process.env.RUN_ID}) for details.`, + '', + `Triggered by @${context.actor}.`, + ].join('\n'); + } + + core.setOutput('body', body); + + - name: Update PR comment with results + uses: ./.github/actions/upsert-pr-comment + with: + pr_number: ${{ inputs.pr_number }} + marker: ${{ env.PR_COMMENT_MARKER }} + body: ${{ steps.build-body.outputs.body }}