SEP-1895: Refuse a syncer's second concurrent run under a per-syncer advisory lock #10199
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Main CI workflow | |
| name: CI | |
| on: | |
| pull_request: | |
| branches: | |
| - main | |
| types: | |
| - labeled | |
| - ready_for_review | |
| - synchronize | |
| - unlabeled | |
| workflow_call: | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| concurrency: | |
| group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| label-gate: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Block merge on disqualifying labels | |
| if: >- | |
| contains(github.event.pull_request.labels.*.name, 'do not merge') || | |
| contains(github.event.pull_request.labels.*.name, 'qa in progress') || | |
| contains(github.event.pull_request.labels.*.name, 'qa failed') | |
| run: | | |
| echo "::error::PR is blocked by label. Remove 'do not merge', 'qa in progress', or 'qa failed' to proceed." | |
| exit 1 | |
| - name: Require QA approval label | |
| if: >- | |
| !contains(github.event.pull_request.labels.*.name, 'qa passed') && | |
| !contains(github.event.pull_request.labels.*.name, 'qa not required') | |
| run: | | |
| echo "::error::PR requires 'qa passed' or 'qa not required' label to merge." | |
| exit 1 | |
| # Detect changed file types to gate downstream jobs. | |
| # Python/frontend patterns mirror .github/labeler.yml; CI also tracks | |
| # .pre-commit-config.yaml separately to trigger precommit-python / precommit-frontend. | |
| changes: | |
| if: ${{ github.event.pull_request.draft == false }} | |
| runs-on: ubuntu-latest | |
| outputs: | |
| python: ${{ steps.filter.outputs.python }} | |
| precommit: ${{ steps.filter.outputs.precommit }} | |
| frontend: ${{ steps.filter.outputs.frontend }} | |
| shell: ${{ steps.filter.outputs.shell }} | |
| sidecar: ${{ steps.filter.outputs.sidecar }} | |
| pipeline: ${{ steps.filter.outputs.pipeline }} | |
| steps: | |
| - name: Checkout codebase | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - name: Detect changed files | |
| id: filter | |
| uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 | |
| with: | |
| filters: | | |
| python: | |
| - '**/**.py' | |
| # Nomad payloads are extensionless, so they match no *.py glob and | |
| # would otherwise skip the pytest tier that guards their invariants. | |
| - 'app/sep/apps/**/*payload' | |
| - 'pyproject.toml' | |
| - 'alembic.ini' | |
| - 'poetry.lock' | |
| - 'poetry.toml' | |
| - 'settings.yaml' | |
| - 'sidecar/settings.yaml' | |
| - 'sidecar/settings-env.sh' | |
| - 'sidecar/supervisord.conf' | |
| - 'sidecar/healthcheck.sh' | |
| - 'sidecar/entrypoint.sh' | |
| - 'sidecar/wait_for_schema.sh' | |
| - 'sidecar/Containerfile.sidecar' | |
| - 'Makefile' | |
| - 'frontend/packages/api/specs/**' | |
| - '.github/labeler.yml' | |
| - '.github/workflows/ci.yml' | |
| precommit: | |
| - '.pre-commit-config.yaml' | |
| - '.github/workflows/ci.yml' | |
| shell: | |
| - '**/*.sh' | |
| frontend: | |
| - 'frontend/**' | |
| - 'package.json' | |
| - '.github/actions/setup-frontend-job/**' | |
| sidecar: | |
| - 'sidecar/**' | |
| - 'Containerfile.base' | |
| - 'alembic.ini' | |
| - 'Makefile' | |
| - 'app/**' | |
| - 'snippets/**' | |
| - 'pyproject.toml' | |
| - 'poetry.lock' | |
| pipeline: | |
| - 'build/**/*.pipeline' | |
| - 'scripts/lint_pipelines.groovy' | |
| precommit-light: | |
| if: ${{ github.event.pull_request.draft == false }} | |
| needs: | |
| - label-gate | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout codebase | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - name: Setup Python | |
| uses: actions/setup-python@28f2168f4d98ee0445e3c6321f6e6616c83dd5ec # v6.2.0 | |
| with: | |
| python-version: "3.11" | |
| - name: Install pre-commit | |
| run: python -m pip install pre-commit==4.5.1 | |
| - name: Cache pre-commit hook environments | |
| uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | |
| with: | |
| path: ~/.cache/pre-commit | |
| key: pre-commit-${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml') }} | |
| restore-keys: | | |
| pre-commit-${{ runner.os }}- | |
| - name: Write lightweight pre-commit config | |
| run: | | |
| cat > .pre-commit-light-config.yaml <<'EOF' | |
| repos: | |
| - repo: https://github.com/pre-commit/pre-commit-hooks | |
| rev: cef0300fd0fc4d2a87a85fa2093c6b283ea36f4b | |
| hooks: | |
| - id: check-yaml | |
| - id: check-json | |
| - id: check-toml | |
| - id: check-merge-conflict | |
| - id: check-case-conflict | |
| - id: check-added-large-files | |
| - id: end-of-file-fixer | |
| - id: trailing-whitespace | |
| - id: fix-byte-order-marker | |
| EOF | |
| - name: Perform lightweight pre-commit checks | |
| run: | | |
| pre-commit run --config .pre-commit-light-config.yaml --all-files | |
| precommit-python: | |
| if: >- | |
| ${{ | |
| github.event.pull_request.draft == false && | |
| ( | |
| needs.changes.outputs.python == 'true' || | |
| needs.changes.outputs.precommit == 'true' || | |
| needs.changes.outputs.sidecar == 'true' || | |
| needs.changes.outputs.frontend == 'true' || | |
| needs.changes.outputs.shell == 'true' | |
| ) | |
| }} | |
| needs: | |
| - label-gate | |
| - changes | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout codebase | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - name: Setup Python | |
| uses: actions/setup-python@28f2168f4d98ee0445e3c6321f6e6616c83dd5ec # v6.2.0 | |
| with: | |
| python-version: "3.11" | |
| - name: Install pre-commit | |
| run: python -m pip install pre-commit==4.5.1 | |
| - name: Cache pre-commit hook environments | |
| uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | |
| with: | |
| path: ~/.cache/pre-commit | |
| key: pre-commit-${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml') }} | |
| restore-keys: | | |
| pre-commit-${{ runner.os }}- | |
| - name: Perform Python and system pre-commit checks | |
| env: | |
| SKIP: oxfmt,oxlint | |
| run: pre-commit run --all-files | |
| precommit-frontend: | |
| if: >- | |
| ${{ | |
| github.event.pull_request.draft == false && | |
| ( | |
| needs.changes.outputs.frontend == 'true' || | |
| needs.changes.outputs.precommit == 'true' | |
| ) | |
| }} | |
| needs: | |
| - label-gate | |
| - changes | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout codebase | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - name: Setup frontend job | |
| uses: ./.github/actions/setup-frontend-job | |
| - name: Run oxfmt | |
| run: pnpm run format:check | |
| working-directory: frontend | |
| - name: Run oxlint | |
| run: pnpm run lint | |
| working-directory: frontend | |
| python: | |
| # `lint-fmt-only`: precommit-python already re-runs the bumped linters. | |
| if: >- | |
| ${{ | |
| github.event.pull_request.draft == false && | |
| needs.changes.outputs.python == 'true' && | |
| !contains(github.event.pull_request.labels.*.name, 'lint-fmt-only') | |
| }} | |
| uses: ./.github/workflows/python.yaml | |
| needs: | |
| - label-gate | |
| - changes | |
| frontend: | |
| if: >- | |
| ${{ | |
| github.event.pull_request.draft == false && | |
| needs.changes.outputs.frontend == 'true' && | |
| !contains(github.event.pull_request.labels.*.name, 'lint-fmt-only') | |
| }} | |
| uses: ./.github/workflows/frontend.yaml | |
| needs: | |
| - label-gate | |
| - changes | |
| build: | |
| # Runs concurrently with the python test matrix — the docker image build | |
| # consumes no test output, so it no longer gates on `python`. It starts as | |
| # soon as label-gate and changes succeed, while tests are still running. | |
| # Merge correctness is preserved by ci-success below, which | |
| # aggregates python, frontend, and build. | |
| # `lint-fmt-only`: audit-group deps not in the production image. | |
| if: >- | |
| ${{ | |
| github.event.pull_request.draft == false && | |
| needs.label-gate.result == 'success' && | |
| needs.changes.result == 'success' && | |
| needs.changes.outputs.sidecar == 'true' && | |
| !contains(github.event.pull_request.labels.*.name, 'lint-fmt-only') | |
| }} | |
| runs-on: ubuntu-latest | |
| needs: | |
| - label-gate | |
| - changes | |
| steps: | |
| - name: Checkout codebase | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@a0c73a10bb6782f082cd6714666c87911e4fc7d3 # v4.0.0 | |
| - name: Build base image | |
| uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 | |
| with: | |
| context: . | |
| file: Containerfile.base | |
| tags: sep:builder | |
| load: true | |
| cache-from: type=gha,scope=base | |
| cache-to: type=gha,mode=max,scope=base | |
| - name: Prepare app bundle | |
| run: make pack | |
| - name: Build app image | |
| run: >- | |
| docker build | |
| --build-context "localhost/sep:builder=docker-image://sep:builder" | |
| -f sidecar/Containerfile.sidecar | |
| --build-arg SEP_RESTRICT_APPS=1 | |
| -t "sep:HEAD" . | |
| - name: Smoke test - verify binaries and config files | |
| run: | | |
| docker run --rm --entrypoint /bin/sh "sep:HEAD" -c ' | |
| set -e | |
| command -v valkey-server | |
| command -v valkey-cli | |
| command -v supervisord | |
| test -f /home/sep/app/supervisord.conf | |
| test -f /home/sep/app/healthcheck.sh | |
| test -x /home/sep/app/entrypoint.sh | |
| test -x /home/sep/app/wait_for_schema.sh | |
| test -f /home/sep/app/settings-env.sh | |
| test -f /home/sep/app/settings.yaml | |
| ' | |
| - name: Smoke test - verify HEALTHCHECK instruction | |
| run: | | |
| healthcheck_test=$(docker inspect --format='{{json .Config.Healthcheck.Test}}' "sep:HEAD") | |
| if [ -z "$healthcheck_test" ] || [ "$healthcheck_test" = "null" ] || [ "$healthcheck_test" = "[\"NONE\"]" ]; then | |
| echo "::error::HEALTHCHECK instruction is missing or disabled (HEALTHCHECK NONE) in the side-car image" | |
| exit 1 | |
| fi | |
| healthcheck=$(docker inspect --format='{{json .Config.Healthcheck}}' "sep:HEAD") | |
| echo "HEALTHCHECK present: $healthcheck" | |
| - name: Smoke test - verify ENTRYPOINT is the side-car wrapper | |
| run: | | |
| entrypoint=$(docker inspect --format='{{json .Config.Entrypoint}}' "sep:HEAD") | |
| case "$entrypoint" in | |
| *entrypoint.sh*) ;; | |
| *) | |
| echo "::error::ENTRYPOINT is not the side-car wrapper, so the broker credential is never minted: $entrypoint" | |
| exit 1 | |
| ;; | |
| esac | |
| echo "ENTRYPOINT present: $entrypoint" | |
| - name: Smoke test - restricted image ships exactly the activated apps | |
| run: sidecar/verify_image_apps.sh "sep:HEAD" restricted | |
| - name: Verify the purge layer is the last package-manager operation | |
| run: python3 scripts/check_sidecar_purge.py --check-ordering | |
| - name: Smoke test - purged packages are absent from the built image | |
| run: | | |
| pkgs="$(python3 scripts/check_sidecar_purge.py --print-packages | tr '\n' ' ')" | |
| if [ -z "$pkgs" ]; then | |
| echo "::error::the purge checker named no packages, so this check cannot run" | |
| exit 1 | |
| fi | |
| docker run --rm --entrypoint /bin/sh -e PURGED="$pkgs" "sep:HEAD" -c ' | |
| command -v dpkg-query > /dev/null 2>&1 || { | |
| echo "::error::dpkg-query is absent, so package presence cannot be established" | |
| exit 1 | |
| } | |
| status=0 | |
| for p in $PURGED; do | |
| if dpkg-query -s "$p" 2>/dev/null | grep -q "^Status: install ok installed"; then | |
| echo "::error::$p is still installed in the built image" | |
| status=1 | |
| fi | |
| done | |
| if command -v perl > /dev/null 2>&1; then | |
| echo "::error::a perl binary resolves on PATH" | |
| status=1 | |
| fi | |
| exit $status | |
| ' | |
| pipeline-syntax: | |
| if: >- | |
| ${{ | |
| github.event.pull_request.draft == false && | |
| needs.changes.outputs.pipeline == 'true' | |
| }} | |
| needs: | |
| - label-gate | |
| - changes | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout codebase | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - name: Parse Jenkins pipeline Groovy syntax | |
| run: | | |
| pipelines=() | |
| while IFS= read -r pipeline; do | |
| pipelines+=("$pipeline") | |
| done < <(git ls-files -- ':(glob)build/**/*.pipeline') | |
| ((${#pipelines[@]})) || { | |
| echo "::error::No build/**/*.pipeline files found." | |
| exit 1 | |
| } | |
| docker run --rm -v "$PWD:/w" -w /w \ | |
| groovy@sha256:fcfaaa9eba20ccc3b438a70cb599e721996758ecf22de70dc0a85302c97822aa \ | |
| groovy scripts/lint_pipelines.groovy \ | |
| "${pipelines[@]}" | |
| ci-success: | |
| if: always() | |
| needs: | |
| - label-gate | |
| - changes | |
| - precommit-light | |
| - precommit-python | |
| - precommit-frontend | |
| - python | |
| - frontend | |
| - build | |
| - pipeline-syntax | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Fail if any dependency failed or was cancelled | |
| if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') | |
| run: exit 1 |