From cb79a7a1e2e936ac0b124fc07af29bd966b81df6 Mon Sep 17 00:00:00 2001 From: Jonathan Visser Date: Tue, 28 Jul 2026 13:32:54 +0200 Subject: [PATCH 1/4] Revise workflows based on correct security standards --- .github/workflows/cleanup_acceptance.yaml | 13 +- .github/workflows/deploy.yaml | 93 +++---------- .github/workflows/lint.yaml | 6 +- .github/workflows/pr-build.yaml | 27 ++++ .github/workflows/pr-deploy.yaml | 152 ++++++++++++++++++++++ .github/workflows/test.yaml | 8 +- deploy.php | 5 +- 7 files changed, 220 insertions(+), 84 deletions(-) create mode 100644 .github/workflows/pr-build.yaml create mode 100644 .github/workflows/pr-deploy.yaml diff --git a/.github/workflows/cleanup_acceptance.yaml b/.github/workflows/cleanup_acceptance.yaml index f4708852..ff47b330 100644 --- a/.github/workflows/cleanup_acceptance.yaml +++ b/.github/workflows/cleanup_acceptance.yaml @@ -1,18 +1,23 @@ name: Cleanup acceptance environments on: - pull_request: + pull_request_target: types: [closed] +permissions: + contents: read + jobs: cleanup: runs-on: ubuntu-latest container: quay.io/hypernode/deploy:latest-php8.4-node22 environment: acceptance + permissions: + contents: read steps: - - uses: actions/checkout@v2 - - name: Dump env - run: env + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.sha }} - name: Cleanup acceptance environments run: hypernode-deploy cleanup acceptance -vvv env: diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 5587d5fe..f0412b69 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -1,35 +1,32 @@ -name: Deploy application +name: Deploy to production on: - pull_request_target: push: branches: - 'master' -env: - COMPOSER_CACHE_DIR: /tmp/composer-cache +concurrency: + group: production + cancel-in-progress: false + +permissions: + contents: read jobs: build: runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read container: quay.io/hypernode/deploy:latest-php8.4-node22 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: + # updated_at.py reads the git log to render per-page timestamps. fetch-depth: 0 - ref: ${{ github.event.pull_request.head.sha || github.sha }} - # Declares the repository safe and not under dubious ownership. - name: Add repository to git safe directories run: git config --global --add safe.directory $GITHUB_WORKSPACE - - uses: actions/cache@v3 - with: - path: /tmp/composer-cache - key: ${{ runner.os }}-composer - - uses: webfactory/ssh-agent@v0.7.0 - with: - ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} - name: Set env for production build - if: github.ref == 'refs/heads/master' run: | echo "DOCS_BASE_URL=https://docs.hypernode.com/" >> $GITHUB_ENV echo "DOCS_INDEX_FOLLOW=1" >> $GITHUB_ENV @@ -41,78 +38,24 @@ jobs: path: build/build.tgz retention-days: 5 - deploy_acceptance: - needs: build - runs-on: ubuntu-latest - environment: - name: acceptance - url: ${{ steps.get_brancher_hostname.outputs.BRANCHER_URL }} - if: github.event_name == 'pull_request_target' - container: quay.io/hypernode/deploy:latest-php8.4-node22 - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - ref: ${{ github.event.pull_request.head.sha || github.sha }} - - name: download build artifact - uses: actions/download-artifact@v4 - with: - name: deployment-build - path: build/ - - uses: webfactory/ssh-agent@v0.5.4 - with: - ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} - - run: mkdir -p $HOME/.ssh - - name: deploy to acceptance - run: hypernode-deploy deploy acceptance -vvv --reuse-brancher - env: - HYPERNODE_API_TOKEN: ${{ secrets.HYPERNODE_API_TOKEN }} - - name: Get brancher hostname - id: get_brancher_hostname - run: echo "BRANCHER_URL=https://$(jq .hostnames[0] deployment-report.json -r)" >> $GITHUB_OUTPUT - - name: Get changed pages - id: changed_pages - run: | - git config --global --add safe.directory $(pwd) - commits=${{ github.event.pull_request.commits }} - if [[ -n "$commits" ]]; then - # Prepare enough depth for diffs with target branch - git fetch --depth="$(( commits + 1 ))" - fi - result="$(python3 ci/bin/get_changed_urls.py \ - ${{ github.event.pull_request.base.sha }} \ - ${{github.event.pull_request.head.sha}} \ - --base-url=${{ steps.get_brancher_hostname.outputs.BRANCHER_URL }} - )" - echo "$result" - echo "CHANGED_PAGES<> $GITHUB_OUTPUT - echo "$result" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - shell: bash - - name: Comment hostname on PR - uses: thollander/actions-comment-pull-request@v1 - with: - message: | - Acceptance server is available at ${{ steps.get_brancher_hostname.outputs.BRANCHER_URL }}. - ${{ steps.changed_pages.outputs.CHANGED_PAGES }} - deploy_production: needs: build runs-on: ubuntu-latest - concurrency: production + timeout-minutes: 30 environment: name: production - url: https://docs.hypernode.io - if: github.event_name == 'push' && github.ref == 'refs/heads/master' + url: https://docs.hypernode.com + permissions: + contents: read container: quay.io/hypernode/deploy:latest-php8.4-node22 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: download build artifact uses: actions/download-artifact@v4 with: name: deployment-build path: build/ - - uses: webfactory/ssh-agent@v0.5.4 + - uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0 with: ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} - run: mkdir -p $HOME/.ssh diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 8f482fb2..f473639a 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -5,11 +5,13 @@ on: permissions: contents: read - pull-requests: write jobs: test: runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write strategy: matrix: python-version: ['3.12'] @@ -26,6 +28,6 @@ jobs: pip install -r requirements/base.txt - name: Check markdown format run: mdformat docs/ - - uses: parkerbxyz/suggest-changes@v2 + - uses: parkerbxyz/suggest-changes@e24c62a5a3235e6090721c7b0355b825a3a4ba9a # v3.1.2 with: event: 'REQUEST_CHANGES' diff --git a/.github/workflows/pr-build.yaml b/.github/workflows/pr-build.yaml new file mode 100644 index 00000000..a3dc2d73 --- /dev/null +++ b/.github/workflows/pr-build.yaml @@ -0,0 +1,27 @@ +name: Build pull request + +on: + pull_request: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + container: quay.io/hypernode/deploy:latest-php8.4-node22 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Add repository to git safe directories + run: git config --global --add safe.directory $GITHUB_WORKSPACE + - run: hypernode-deploy build -vvv + - name: archive build artifact + uses: actions/upload-artifact@v4 + with: + name: deployment-build + path: build/build.tgz + retention-days: 5 diff --git a/.github/workflows/pr-deploy.yaml b/.github/workflows/pr-deploy.yaml new file mode 100644 index 00000000..3e9791ee --- /dev/null +++ b/.github/workflows/pr-deploy.yaml @@ -0,0 +1,152 @@ +name: Deploy pull request to acceptance + +on: + workflow_run: + workflows: ["Build pull request"] + types: [completed] + +permissions: + contents: read + +jobs: + deploy_acceptance: + if: >- + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + timeout-minutes: 30 + concurrency: + group: acceptance-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: false + environment: + name: acceptance + url: ${{ steps.get_brancher_hostname.outputs.BRANCHER_URL }} + permissions: + actions: read + contents: read + pull-requests: write + statuses: write + container: quay.io/hypernode/deploy:latest-php8.4-node22 + steps: + - name: Resolve the pull request behind this build + id: pr + uses: actions/github-script@v7 + with: + script: | + const run = context.payload.workflow_run; + + // Populated for same-repository branches, empty for forks. + let number = (run.pull_requests || []).map((p) => p.number)[0]; + if (!number) { + const { data: prs } = + await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: run.head_sha, + }); + number = prs.filter((p) => p.state === 'open').map((p) => p.number)[0]; + } + if (!number) { + core.setFailed(`No open pull request found for commit ${run.head_sha}`); + return; + } + + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: number, + }); + core.setOutput('number', pr.number); + core.setOutput('head_sha', run.head_sha); + core.setOutput('head_ref', pr.head.ref); + core.setOutput('base_sha', pr.base.sha); + + - name: Check out the trusted deploy configuration + uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + fetch-depth: 0 + - name: Add repository to git safe directories + run: git config --global --add safe.directory $GITHUB_WORKSPACE + + - name: download build artifact + uses: actions/download-artifact@v4 + with: + name: deployment-build + path: build/ + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0 + with: + ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} + - run: mkdir -p $HOME/.ssh + + - name: deploy to acceptance + run: hypernode-deploy deploy acceptance -vvv --reuse-brancher + env: + HYPERNODE_API_TOKEN: ${{ secrets.HYPERNODE_API_TOKEN }} + # workflow_run does not set GITHUB_HEAD_REF, which deploy.php uses to + # label the brancher. Without this every pull request shares one node. + CI_REF: ${{ steps.pr.outputs.head_ref }} + + - name: Get brancher hostname + id: get_brancher_hostname + run: echo "BRANCHER_URL=https://$(jq .hostnames[0] deployment-report.json -r)" >> $GITHUB_OUTPUT + + - name: Get changed pages + id: changed_pages + env: + BASE_SHA: ${{ steps.pr.outputs.base_sha }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + BRANCHER_URL: ${{ steps.get_brancher_hostname.outputs.BRANCHER_URL }} + run: | + # The built commit is fetched by SHA as data to diff against (the + # pull request ref may already point at a newer push); it is never + # checked out or executed. + git fetch --no-tags --quiet origin "$HEAD_SHA" + result="$(python3 ci/bin/get_changed_urls.py \ + "$BASE_SHA" \ + "$HEAD_SHA" \ + --base-url="$BRANCHER_URL" + )" + echo "$result" + { + echo "CHANGED_PAGES<> "$GITHUB_OUTPUT" + shell: bash + + - name: Comment hostname on PR + uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1 + with: + pr-number: ${{ steps.pr.outputs.number }} + comment-tag: acceptance-environment + message: | + Acceptance server is available at ${{ steps.get_brancher_hostname.outputs.BRANCHER_URL }}. + ${{ steps.changed_pages.outputs.CHANGED_PAGES }} + + # workflow_run runs do not appear in the pull request's check list. + - name: Mirror result onto the pull request head commit + if: always() && steps.pr.outputs.head_sha + uses: actions/github-script@v7 + env: + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + JOB_STATUS: ${{ job.status }} + BRANCHER_URL: ${{ steps.get_brancher_hostname.outputs.BRANCHER_URL }} + with: + script: | + const ok = process.env.JOB_STATUS === 'success'; + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + await github.rest.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: process.env.HEAD_SHA, + state: ok ? 'success' : 'failure', + context: 'Deploy pull request to acceptance', + target_url: ok ? (process.env.BRANCHER_URL || runUrl) : runUrl, + description: ok + ? 'Acceptance environment deployed' + : 'Acceptance deploy failed', + }); diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index ba5bc7a8..420653c0 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -3,16 +3,20 @@ name: Run tests on: pull_request: +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest + timeout-minutes: 30 strategy: matrix: python-version: ['3.11', '3.12'] steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies diff --git a/deploy.php b/deploy.php index 4e8d6813..11deadd5 100644 --- a/deploy.php +++ b/deploy.php @@ -142,6 +142,9 @@ $testingStage = $configuration->addStage("acceptance", "docs"); $testingStage->addBrancherServer("docs") - ->setLabels(['stage=acceptance', 'ci_ref=' . (\getenv('GITHUB_HEAD_REF') ?: 'none')]); + ->setLabels([ + 'stage=acceptance', + 'ci_ref=' . (\getenv('CI_REF') ?: \getenv('GITHUB_HEAD_REF') ?: 'none'), + ]); return $configuration; From 14e12e1ea3a940af78b1f557e8c68a152ddde362 Mon Sep 17 00:00:00 2001 From: Jonathan Visser Date: Tue, 28 Jul 2026 13:45:22 +0200 Subject: [PATCH 2/4] Install lint dependencies from the base branch under pull_request_target pull_request_target runs are not gated by the fork approval policy and carry a write token, so installing requirements from the pull request head let any fork execute arbitrary code in a privileged context. Check out the base branch for pip install first; the head is only checked out afterwards and processed as data by mdformat. --- .github/workflows/lint.yaml | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index f473639a..685ccfe1 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -1,5 +1,11 @@ name: Run linting and suggest changes +# `pull_request_target` because posting review suggestions requires a write +# token, which `pull_request` withholds from fork pull requests. Note that the +# fork approval policy does NOT gate `pull_request_target` runs, so nothing +# from the pull request head may ever be executed here: dependencies are +# installed from the trusted base branch BEFORE the head is checked out, and +# the head is only processed as data by mdformat. on: pull_request_target: @@ -12,20 +18,22 @@ jobs: permissions: contents: read pull-requests: write - strategy: - matrix: - python-version: ['3.12'] steps: - - uses: actions/checkout@v4 + - name: Check out trusted dependency manifests from the base branch + uses: actions/checkout@v4 with: - ref: ${{ github.event.pull_request.head.sha }} + ref: ${{ github.event.pull_request.base.sha }} - uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies + python-version: '3.12' + - name: Install dependencies from the base branch run: | pip install -r requirements/base.txt + - name: Check out the pull request head + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Check markdown format run: mdformat docs/ - uses: parkerbxyz/suggest-changes@e24c62a5a3235e6090721c7b0355b825a3a4ba9a # v3.1.2 From 7d3d079eb89430d559553aa1b71ff2af6ad008cf Mon Sep 17 00:00:00 2001 From: Jonathan Visser Date: Tue, 28 Jul 2026 13:49:01 +0200 Subject: [PATCH 3/4] Remove redundant comment --- .github/workflows/lint.yaml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 685ccfe1..8a24392d 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -1,11 +1,5 @@ name: Run linting and suggest changes -# `pull_request_target` because posting review suggestions requires a write -# token, which `pull_request` withholds from fork pull requests. Note that the -# fork approval policy does NOT gate `pull_request_target` runs, so nothing -# from the pull request head may ever be executed here: dependencies are -# installed from the trusted base branch BEFORE the head is checked out, and -# the head is only processed as data by mdformat. on: pull_request_target: From 6bc4b7f3a470591302360c88394ab36590ef061e Mon Sep 17 00:00:00 2001 From: Jonathan Visser Date: Tue, 28 Jul 2026 13:57:49 +0200 Subject: [PATCH 4/4] Satisfy prepare:ssh with a throwaway key in secretless build jobs hypernode-deploy's prepare:ssh task hard-fails when the ssh-agent has no identities (it lists loaded keys unconditionally), so builds without SSH_PRIVATE_KEY cannot pass even though building needs no server access. Generate a key that grants access to nothing instead of reintroducing the real deploy key into jobs that run untrusted pull request code. --- .github/workflows/deploy.yaml | 4 ++++ .github/workflows/pr-build.yaml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index f0412b69..2880ac35 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -30,6 +30,10 @@ jobs: run: | echo "DOCS_BASE_URL=https://docs.hypernode.com/" >> $GITHUB_ENV echo "DOCS_INDEX_FOLLOW=1" >> $GITHUB_ENV + # The prepare:ssh task fails on an empty ssh-agent, but building needs no + # server access, so satisfy it with a key that grants access to nothing. + - name: Generate a throwaway SSH key for the build + run: mkdir -p ~/.ssh && ssh-keygen -t ed25519 -N '' -q -f ~/.ssh/id_ed25519 - run: hypernode-deploy build -vvv - name: archive production artifacts uses: actions/upload-artifact@v4 diff --git a/.github/workflows/pr-build.yaml b/.github/workflows/pr-build.yaml index a3dc2d73..22edbd22 100644 --- a/.github/workflows/pr-build.yaml +++ b/.github/workflows/pr-build.yaml @@ -18,6 +18,10 @@ jobs: fetch-depth: 0 - name: Add repository to git safe directories run: git config --global --add safe.directory $GITHUB_WORKSPACE + # The prepare:ssh task fails on an empty ssh-agent, but building needs no + # server access, so satisfy it with a key that grants access to nothing. + - name: Generate a throwaway SSH key for the build + run: mkdir -p ~/.ssh && ssh-keygen -t ed25519 -N '' -q -f ~/.ssh/id_ed25519 - run: hypernode-deploy build -vvv - name: archive build artifact uses: actions/upload-artifact@v4