diff --git a/.github/workflows/scheduled-docker-image-refresh.yml b/.github/workflows/scheduled-docker-image-refresh.yml new file mode 100644 index 00000000000..3ac26561767 --- /dev/null +++ b/.github/workflows/scheduled-docker-image-refresh.yml @@ -0,0 +1,177 @@ +name: Scheduled Docker image refresh + +# Re-runs the Docker image build against the latest published release on a +# weekly cadence. The code being built doesn't change — but the base image +# layers (python:*-slim-trixie and its OS packages) DO get upstream +# security patches between Superset releases, and those patches don't +# reach our published images unless we rebuild. +# +# Without this workflow, `apache/superset:` lags behind upstream +# Debian/Python base patches by whatever interval falls between Superset +# releases (typically 3–6 weeks). With it, the lag drops to at most one +# week regardless of release cadence. +# +# This is a security-hygiene cron, not a release. It overwrites the +# existing tags for the most recent release (e.g. `apache/superset:5.0.0` +# and `apache/superset:latest`) with bit-for-bit-equivalent contents +# layered on a refreshed base. Image digests change; everything users +# actually pin against (image content, code, deps) does not. + +on: + schedule: + # Mondays at 06:00 UTC — gives the weekend for upstream patches to + # settle and surfaces failures at the start of the work week so a + # human can react. + - cron: "0 6 * * 1" + + # Manual trigger so operators can force a refresh on demand (e.g. + # immediately after a high-severity base-image CVE drops). + workflow_dispatch: {} + +permissions: + contents: read + +# Serialize with itself and with the release publisher (tag-release.yml) — +# both push to the same Docker Hub tags, so a race could end with stale +# layers winning. Both workflows must declare this group for the lock to work. +concurrency: + group: docker-publish-latest-release + cancel-in-progress: false + +jobs: + config: + runs-on: ubuntu-24.04 + outputs: + has-secrets: ${{ steps.check.outputs.has-secrets }} + latest-release: ${{ steps.latest.outputs.tag }} + force-latest: ${{ steps.latest.outputs.force-latest }} + steps: + - name: Check for Docker Hub secrets + id: check + shell: bash + run: | + if [ -n "${DOCKERHUB_USER}" ]; then + echo "has-secrets=1" >> "$GITHUB_OUTPUT" + fi + env: + DOCKERHUB_USER: ${{ (secrets.DOCKERHUB_USER != '' && secrets.DOCKERHUB_TOKEN != '') || '' }} + + - name: Look up latest published release + id: latest + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPOSITORY: ${{ github.repository }} + run: | + # `releases/latest` returns the latest non-prerelease, non-draft + # release — which is exactly what `apache/superset:latest` + # should reflect. + TAG=$(gh api "repos/${REPOSITORY}/releases/latest" --jq .tag_name) + if [ -z "$TAG" ] || [ "$TAG" = "null" ]; then + echo "::error::Could not determine latest release tag" + exit 1 + fi + echo "Latest release: $TAG" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + # Only move `:latest` when the release flagged "latest" is also the + # highest semver release. This guards against a mis-click leaving an + # older maintenance release (e.g. a 5.x patch shipped after 6.0 GA) + # marked latest, which would otherwise roll `:latest` back a major + # version on the next cron run. If it isn't the newest, we still + # refresh that release's own version tag but leave `:latest` alone. + HIGHEST=$(gh api --paginate "repos/${REPOSITORY}/releases" \ + --jq '.[] | select(.draft|not) | select(.prerelease|not) | .tag_name' \ + | sed 's/^v//' | sort -V | tail -n1) + if [ "${TAG#v}" = "$HIGHEST" ]; then + echo "force-latest=1" >> "$GITHUB_OUTPUT" + else + echo "::warning::Latest-flagged release $TAG is not the highest semver ($HIGHEST); refreshing its version tag but leaving :latest untouched" + fi + + docker-rebuild: + needs: config + if: needs.config.outputs.has-secrets == '1' + name: docker-rebuild + runs-on: ubuntu-24.04 + strategy: + # Mirror the same matrix the release publisher uses so every variant + # operators consume from Docker Hub gets the refreshed base. + matrix: + build_preset: ["dev", "lean", "py310", "websocket", "dockerize", "py311", "py312"] + fail-fast: false + steps: + - name: "Checkout release tag: ${{ needs.config.outputs.latest-release }}" + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ needs.config.outputs.latest-release }} + fetch-depth: 0 + persist-credentials: false + + - name: Setup Docker Environment + uses: ./.github/actions/setup-docker + with: + dockerhub-user: ${{ secrets.DOCKERHUB_USER }} + dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} + install-docker-compose: "false" + build: "true" + + - name: Use Node.js 20 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: 20 + + - name: Setup supersetbot + uses: ./.github/actions/setup-supersetbot/ + + - name: Rebuild and push + env: + DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_PRESET: ${{ matrix.build_preset }} + LATEST_RELEASE: ${{ needs.config.outputs.latest-release }} + FORCE_LATEST_FLAG: ${{ needs.config.outputs.force-latest == '1' && '--force-latest' || '' }} + run: | + # Reuses the same supersetbot invocation as the release + # publisher (`tag-release.yml`), so the resulting tags are + # identical to what a manual release dispatch would produce — + # just with a freshly-pulled base image layer underneath. + # `--force-latest` is only passed when the config job confirmed the + # fetched release is the newest one (see FORCE_LATEST_FLAG above). + supersetbot docker \ + --push \ + --preset "$BUILD_PRESET" \ + --context release \ + --context-ref "$LATEST_RELEASE" \ + $FORCE_LATEST_FLAG \ + --platform "linux/arm64" \ + --platform "linux/amd64" + + # The whole point of this cron is catching base-image CVEs, so a silent + # failure is the expensive case — a red X in the Actions tab nobody is + # watching on a Monday. File a tracked issue when any rebuild leg fails so + # a missed security refresh surfaces instead of sitting unnoticed. + notify-on-failure: + needs: [config, docker-rebuild] + if: failure() && needs.config.outputs.has-secrets == '1' + runs-on: ubuntu-24.04 + permissions: + contents: read + issues: write + steps: + - name: Open a tracking issue + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPOSITORY: ${{ github.repository }} + LATEST_RELEASE: ${{ needs.config.outputs.latest-release }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + gh issue create \ + --repo "$REPOSITORY" \ + --title "Scheduled Docker image refresh failed for ${LATEST_RELEASE}" \ + --label "infra:container" \ + --label "bug" \ + --body "The weekly Docker base-image refresh failed for release \`${LATEST_RELEASE}\`. Published images may be missing upstream base-layer security patches until this is resolved. + + Failed run: ${RUN_URL}" diff --git a/.github/workflows/tag-release.yml b/.github/workflows/tag-release.yml index 20c2213b01f..7488c555568 100644 --- a/.github/workflows/tag-release.yml +++ b/.github/workflows/tag-release.yml @@ -24,6 +24,12 @@ on: permissions: contents: read +# Serialize with the scheduled Docker image refresh — both workflows push +# to the same Docker Hub tags and must not race on apache/superset:latest. +concurrency: + group: docker-publish-latest-release + cancel-in-progress: false + jobs: config: runs-on: ubuntu-24.04