From 7fe5d9d1cbbe6c9007facaa9914cba8d5a339c1c Mon Sep 17 00:00:00 2001 From: Jon Fairbanks Date: Tue, 29 Sep 2026 22:56:20 -0700 Subject: [PATCH 1/2] Enforce Production Security and Release Gates --- .github/dependabot.yml | 3 + .github/workflows/ci.yaml | 1 + .github/workflows/codeql.yml | 1 + .github/workflows/dependabot-auto-merge.yml | 31 ++----- .github/workflows/publish.yaml | 93 ++++++++++++++++++--- .github/workflows/snyk.yml | 20 ++--- 6 files changed, 100 insertions(+), 49 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ea3a0c0..5a5c3de 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -2,6 +2,7 @@ version: 2 updates: - package-ecosystem: npm directory: / + target-branch: develop schedule: interval: weekly groups: @@ -9,9 +10,11 @@ updates: patterns: ["*"] - package-ecosystem: github-actions directory: / + target-branch: develop schedule: interval: weekly - package-ecosystem: docker directory: / + target-branch: develop schedule: interval: weekly diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b438fb0..1ceb589 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,6 +1,7 @@ name: CI on: + workflow_dispatch: push: branches: [develop, master] pull_request: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8f5ac55..eff8fbe 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,6 +1,7 @@ name: CodeQL on: + workflow_dispatch: push: branches: [develop, master] pull_request: diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 75b15fc..cdeddab 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -2,7 +2,7 @@ name: Dependabot Auto Merge on: pull_request_target: - types: [opened, synchronize, reopened, ready_for_review, closed] + types: [opened, synchronize, reopened, ready_for_review] permissions: contents: write @@ -10,43 +10,26 @@ permissions: jobs: enable-automerge: - if: > - github.event.action != 'closed' && + if: >- + github.event.pull_request.base.ref == 'develop' && github.event.pull_request.user.login == 'dependabot[bot]' && startsWith(github.event.pull_request.head.ref, 'dependabot/') && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.draft == false runs-on: ubuntu-latest + timeout-minutes: 5 steps: - - name: Fetch Dependabot metadata + - name: Fetch Dependabot Metadata id: metadata uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} - - name: Enable auto-merge - if: > + - name: Enable Auto-Merge + if: >- steps.metadata.outputs.update-type == 'version-update:semver-patch' || steps.metadata.outputs.update-type == 'version-update:semver-minor' run: gh pr merge --auto --squash "$PR_URL" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_URL: ${{ github.event.pull_request.html_url }} - - publish-after-merged-dependabot: - if: > - github.event.action == 'closed' && - github.event.pull_request.merged == true && - github.event.pull_request.base.ref == 'master' && - github.event.pull_request.user.login == 'dependabot[bot]' && - startsWith(github.event.pull_request.head.ref, 'dependabot/') && - github.event.pull_request.head.repo.full_name == github.repository - permissions: - actions: write - contents: read - runs-on: ubuntu-latest - steps: - - name: Dispatch publication from the merged master revision - run: gh workflow run publish.yaml --repo "$GITHUB_REPOSITORY" --ref master - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 1b10ae5..d82e3e4 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -11,20 +11,90 @@ permissions: contents: read concurrency: - group: publish-${{ github.event.workflow_run.head_branch }} + group: publish-${{ github.event_name == 'workflow_dispatch' && github.ref_name || github.event.workflow_run.head_branch }} cancel-in-progress: false jobs: - container: - name: Publish container - if: > + verify: + name: Verify Release Checks + if: >- github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push') runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + actions: write + checks: read + contents: read + outputs: + sha: ${{ steps.release.outputs.sha }} + branch: ${{ steps.release.outputs.branch }} + steps: + - name: Verify Exact Release Commit + id: release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_SHA: ${{ github.event_name == 'workflow_dispatch' && github.sha || github.event.workflow_run.head_sha }} + RELEASE_BRANCH: ${{ github.event_name == 'workflow_dispatch' && github.ref_name || github.event.workflow_run.head_branch }} + MANUAL_RELEASE: ${{ github.event_name == 'workflow_dispatch' }} + run: | + case "$RELEASE_BRANCH" in + develop|master) ;; + *) echo "Unsupported release branch: $RELEASE_BRANCH" >&2; exit 1 ;; + esac + current_sha="$(gh api "repos/$GH_REPO/commits/$RELEASE_BRANCH" --jq .sha)" + if [ "$current_sha" != "$RELEASE_SHA" ]; then + echo "Release commit is no longer the branch head." >&2 + exit 1 + fi + + # Manual releases rerun all checks, including the current vulnerability data. + check_after="$(date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%SZ)" + if [ "$MANUAL_RELEASE" = true ]; then + check_after="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + for workflow in ci.yaml snyk.yml codeql.yml; do + gh workflow run "$workflow" --ref "$RELEASE_BRANCH" + done + fi + + if [ "$RELEASE_BRANCH" = master ]; then + required='["Test (Node 26)","Lint and audit","Helm lint and render","Build container","Open Source and Container","Analyze JavaScript"]' + else + required='["Test (Node 22)","Test (Node 24)","Lint and audit","Helm lint and render","Build container","Open Source","Analyze JavaScript"]' + fi + + for attempt in {1..60}; do + checks="$(gh api --paginate --slurp "repos/$GH_REPO/commits/$RELEASE_SHA/check-runs?per_page=100")" + missing="$(jq -r --argjson required "$required" --arg after "$check_after" ' + [.[].check_runs[] | select(.app.slug == "github-actions")] as $runs + | $required[] as $name + | ([$runs[] | select(.name == $name)] | max_by(.id)) as $latest + | select($latest == null or $latest.status != "completed" + or $latest.conclusion != "success" or $latest.started_at < $after) + | $name + ' <<< "$checks")" + if [ -z "$missing" ]; then + current_sha="$(gh api "repos/$GH_REPO/commits/$RELEASE_BRANCH" --jq .sha)" + test "$current_sha" = "$RELEASE_SHA" + echo "sha=$RELEASE_SHA" >> "$GITHUB_OUTPUT" + echo "branch=$RELEASE_BRANCH" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "Waiting for successful checks on $RELEASE_SHA: $missing" + sleep 10 + done + echo "Release blocked by missing, stale, skipped, or failed checks: $missing" >&2 + exit 1 + + container: + name: Publish container + needs: verify + runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.event_name == 'workflow_dispatch' && github.sha || github.event.workflow_run.head_sha }} + ref: ${{ needs.verify.outputs.sha }} - name: Set up QEMU uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4 - name: Set up Docker Buildx @@ -44,9 +114,9 @@ jobs: images: jonfairbanks/docker-node-app tags: | type=sha - type=raw,value=develop,enable=${{ (github.event_name == 'workflow_dispatch' && github.ref_name == 'develop') || github.event.workflow_run.head_branch == 'develop' }} - type=raw,value=latest,enable=${{ (github.event_name == 'workflow_dispatch' && github.ref_name == 'master') || github.event.workflow_run.head_branch == 'master' }} - type=raw,value=${{ steps.package.outputs.version }},enable=${{ (github.event_name == 'workflow_dispatch' && github.ref_name == 'master') || github.event.workflow_run.head_branch == 'master' }} + type=raw,value=develop,enable=${{ needs.verify.outputs.branch == 'develop' }} + type=raw,value=latest,enable=${{ needs.verify.outputs.branch == 'master' }} + type=raw,value=${{ steps.package.outputs.version }},enable=${{ needs.verify.outputs.branch == 'master' }} - name: Build and push uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7 with: @@ -61,9 +131,8 @@ jobs: helm: name: Publish Helm chart - if: > - (github.event_name == 'workflow_dispatch' && github.ref_name == 'master') || - (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'master') + needs: verify + if: needs.verify.outputs.branch == 'master' runs-on: ubuntu-latest permissions: contents: read @@ -71,7 +140,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.event_name == 'workflow_dispatch' && github.sha || github.event.workflow_run.head_sha }} + ref: ${{ needs.verify.outputs.sha }} fetch-depth: 2 - name: Set up Helm uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1 diff --git a/.github/workflows/snyk.yml b/.github/workflows/snyk.yml index a5f19a5..8bc65bc 100644 --- a/.github/workflows/snyk.yml +++ b/.github/workflows/snyk.yml @@ -19,36 +19,32 @@ permissions: jobs: open-source: name: Open Source and Container - if: >- - github.actor != 'dependabot[bot]' && - (github.event_name != 'pull_request' || - github.event.pull_request.head.repo.full_name == github.repository) runs-on: ubuntu-latest + timeout-minutes: 15 env: SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} steps: - - name: Check Snyk configuration - if: env.SNYK_TOKEN == '' - run: echo "::warning::SNYK_TOKEN is not configured; skipping Snyk scan." + - name: Require Snyk Token + run: | + if [ -z "$SNYK_TOKEN" ]; then + echo "::error::SNYK_TOKEN is required. Configure it in Actions and Dependabot secrets." + exit 1 + fi - name: Checkout - if: env.SNYK_TOKEN != '' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Use Node.js 24.x - if: env.SNYK_TOKEN != '' uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24.x cache: npm - name: Install dependencies - if: env.SNYK_TOKEN != '' run: npm ci - name: Scan dependencies - if: env.SNYK_TOKEN != '' uses: snyk/actions/node@9adf32b1121593767fc3c057af55b55db032dc04 # v1.0.0 with: args: >- @@ -57,11 +53,9 @@ jobs: --severity-threshold=high - name: Build production image - if: env.SNYK_TOKEN != '' run: docker build --target production --tag docker-node-app:snyk . - name: Scan production image - if: env.SNYK_TOKEN != '' run: | # snyk/actions/docker invokes the legacy `snyk test --docker` path. # Use Snyk's current container command with the current immutable From 2c110059293de23b30610170557a0f0f8182fab4 Mon Sep 17 00:00:00 2001 From: Jon Fairbanks Date: Tue, 29 Sep 2026 22:58:54 -0700 Subject: [PATCH 2/2] Explain Release Blocking When the Branch Advances --- .github/workflows/publish.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index d82e3e4..caae37c 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -76,7 +76,10 @@ jobs: ' <<< "$checks")" if [ -z "$missing" ]; then current_sha="$(gh api "repos/$GH_REPO/commits/$RELEASE_BRANCH" --jq .sha)" - test "$current_sha" = "$RELEASE_SHA" + if [ "$current_sha" != "$RELEASE_SHA" ]; then + echo "Branch advanced while checks were running; release blocked." >&2 + exit 1 + fi echo "sha=$RELEASE_SHA" >> "$GITHUB_OUTPUT" echo "branch=$RELEASE_BRANCH" >> "$GITHUB_OUTPUT" exit 0