Repository navigation
Rotki Development Builds #1889
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
| name: Rotki Development Builds | |
| on: | |
| schedule: | |
| - cron: "0 0 * * *" | |
| push: | |
| branches: | |
| - build | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} | |
| cancel-in-progress: true | |
| env: | |
| PYTHON_MACOS: 11 | |
| permissions: { } | |
| jobs: | |
| build-linux: | |
| name: Build linux binary | |
| if: ${{ github.repository == 'rotki/rotki' || github.event_name != 'schedule' }} | |
| env: | |
| CI: true | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| runs-on: ubuntu-22.04 | |
| timeout-minutes: 30 # observed max 13m | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| - name: Load env | |
| uses: rotki/action-env@49f59e650cfdc8796c4ac6533ec044b190d83f9a # v4.0.0 | |
| with: | |
| env_file: .github/.env.ci | |
| - name: Setup python | |
| uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 | |
| with: | |
| python-version: ${{ env.PYTHON_VERSION }} | |
| freethreaded: true | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 | |
| with: | |
| enable-cache: false | |
| version: ${{ env.UV_VERSION }} | |
| cache-dependency-glob: "uv.lock" | |
| - name: Setup pnpm | |
| uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 | |
| with: | |
| package_json_file: frontend/package.json | |
| - name: Setup node | |
| uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 | |
| with: | |
| node-version-file: 'frontend/.nvmrc' | |
| - name: Package | |
| id: packaging | |
| run: | | |
| echo "::group::packaging dependencies" | |
| uv sync --locked --no-dev --group packaging | |
| echo "::endgroup::" | |
| uv run --no-sync package.py --build full | |
| - name: Upload files | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: linux-app | |
| path: | | |
| dist/*.AppImage | |
| dist/*.AppImage.sha512 | |
| build-macos: | |
| name: 'Build macOS binary ${{ matrix.os.arch }}' | |
| if: ${{ github.repository == 'rotki/rotki' || github.event_name != 'schedule' }} | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| os: | |
| - runner: macos-15 | |
| arch: 'arm64' | |
| target: 11 | |
| platform: macosx-11.0-arm64 | |
| uv-platform: aarch64-apple-darwin | |
| - runner: macos-15-intel | |
| arch: 'x86_64' | |
| target: 11 | |
| platform: macosx-11.0-x86_64 | |
| uv-platform: x86_64-apple-darwin | |
| env: | |
| CI: true | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| ARCHFLAGS: -arch ${{ matrix.os.arch }} | |
| _PYTHON_HOST_PLATFORM: ${{ matrix.os.platform }} | |
| MACOSX_DEPLOYMENT_TARGET: ${{ matrix.os.target }} | |
| # uv otherwise picks wheels for the runner's macOS, e.g. a numpy that needs macOS 14 | |
| UV_TARGET_PLATFORM: ${{ matrix.os.uv-platform }} | |
| # pyyaml builds from source and would link Homebrew's libyaml, built for the runner's macOS | |
| PYYAML_FORCE_LIBYAML: 0 | |
| CMAKE_OSX_ARCHITECTURES: ${{ matrix.os.arch }} | |
| NODE_OPTIONS: --max-old-space-size=4096 | |
| environment: macos_sign | |
| runs-on: ${{ matrix.os.runner }} | |
| # observed max: arm64 18m, x86_64 29m. x86_64 used to hang forever in | |
| # PyInstaller's codesign when the build keychain auto-locked after 300 s; | |
| # package.py now raises that limit. | |
| timeout-minutes: 45 | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| - name: Load env | |
| uses: rotki/action-env@49f59e650cfdc8796c4ac6533ec044b190d83f9a # v4.0.0 | |
| with: | |
| env_file: .github/.env.ci | |
| - name: Add rust target for building deps | |
| if: ${{ matrix.os.arch == 'arm64' }} | |
| run: rustup target add aarch64-apple-darwin | |
| - name: Cache python pkg | |
| uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/python*.pkg | |
| key: ${{ runner.os }}-python-${{ env.PYTHON_VERSION }}-${{ env.PYTHON_MACOS }}-${{ matrix.os.arch }} | |
| - name: Set up python | |
| run: packaging/setup-macos-python.sh "${PYTHON_VERSION}" "${PYTHON_MACOS}" | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 | |
| with: | |
| enable-cache: false | |
| version: ${{ env.UV_VERSION }} | |
| cache-dependency-glob: "uv.lock" | |
| - name: Setup pnpm | |
| uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 | |
| with: | |
| package_json_file: frontend/package.json | |
| - name: Setup node | |
| uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 | |
| with: | |
| node-version-file: 'frontend/.nvmrc' | |
| - name: Package | |
| id: packaging | |
| run: | | |
| echo "::group::packaging dependencies" | |
| uv sync --locked --no-dev --group packaging --python-platform "${UV_TARGET_PLATFORM}" | |
| echo "::endgroup::" | |
| uv run --no-sync ./package.py --build full | |
| env: | |
| CERTIFICATE_OSX_APPLICATION: ${{ secrets.CERTIFICATE_OSX_APPLICATION }} | |
| CSC_KEY_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }} | |
| IDENTITY: ${{ secrets.IDENTITY }} | |
| APPLEID: ${{ secrets.APPLEID }} | |
| APPLEIDPASS: ${{ secrets.APPLEIDPASS }} | |
| - name: Upload files (${{ matrix.os.arch }}) | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: macos-app-${{ matrix.os.arch }} | |
| path: | | |
| dist/rotki-darwin_*.dmg | |
| dist/rotki-darwin_*.dmg.sha512 | |
| - name: Upload colibri files | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: macos-colibri-${{ matrix.os.arch }} | |
| path: | | |
| dist/colibri | |
| build-windows: | |
| name: Build windows binary | |
| if: ${{ github.repository == 'rotki/rotki' || github.event_name != 'schedule' }} | |
| env: | |
| CI: true | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| runs-on: windows-latest | |
| timeout-minutes: 45 # observed max 24m | |
| environment: windows_sign | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| - name: Load env | |
| uses: rotki/action-env@49f59e650cfdc8796c4ac6533ec044b190d83f9a # v4.0.0 | |
| with: | |
| env_file: .github/.env.ci | |
| - name: Set up python | |
| uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 | |
| with: | |
| python-version: ${{ env.PYTHON_VERSION }} | |
| freethreaded: true | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 | |
| with: | |
| enable-cache: false | |
| version: ${{ env.UV_VERSION }} | |
| cache-dependency-glob: "uv.lock" | |
| - name: Setup pnpm | |
| uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 | |
| with: | |
| package_json_file: frontend/package.json | |
| - name: Setup node | |
| uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 | |
| with: | |
| node-version-file: 'frontend/.nvmrc' | |
| - name: Build rotki | |
| id: packaging | |
| run: | | |
| echo ::group::"packaging dependencies" | |
| uv sync --locked --no-dev --group packaging | |
| echo ::endgroup:: | |
| uv run --no-sync package.py --build full | |
| shell: powershell | |
| - name: Upload files | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: windows-app | |
| path: | | |
| dist\rotki-win32*.exe | |
| dist\rotki-win32*.exe.sha512 | |
| build-docker: | |
| name: Build docker images | |
| if: ${{ github.repository == 'rotki/rotki' || github.event_name != 'schedule' }} | |
| runs-on: ${{ matrix.platform.runner }} | |
| timeout-minutes: 30 # observed max 9m warm; extra air for a cold build cache | |
| environment: docker | |
| permissions: | |
| contents: read | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| platform: | |
| - name: linux/amd64 | |
| runner: ubuntu-24.04 | |
| - name: linux/arm64 | |
| runner: ubuntu-24.04-arm | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| - name: Prepare | |
| id: prepare | |
| run: | | |
| platform=${{ matrix.platform.name }} | |
| echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV" | |
| - name: Docker meta | |
| id: meta | |
| uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 | |
| with: | |
| images: | | |
| ${{ github.repository }} | |
| - name: Login to DockerHub | |
| uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 | |
| with: | |
| username: ${{ secrets.DOCKERHUB_USER }} | |
| password: ${{ secrets.DOCKERHUB_TOKEN }} | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 | |
| - name: Rotki Version | |
| id: rotki_version | |
| run: | | |
| ROTKI_VERSION=$(cat .bumpversion.cfg | grep 'current_version = ' | sed -n -e 's/current_version = //p') | |
| POSTFIX=$(if git describe --tags --exact-match "$REVISION" &>/dev/null; then echo ''; else echo '.dev'; fi) | |
| ROTKI_VERSION=${ROTKI_VERSION}${POSTFIX}$(date +'%Y%m%d') | |
| echo "version=${ROTKI_VERSION}" >> "$GITHUB_OUTPUT" | |
| - name: Build and push by digest | |
| id: build | |
| uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 | |
| with: | |
| context: . | |
| file: ./Dockerfile | |
| platforms: ${{ matrix.platform.name }} | |
| provenance: mode=max | |
| sbom: true | |
| build-args: | | |
| REVISION=${{ github.sha }} | |
| ROTKI_VERSION=${{ steps.rotki_version.outputs.version }} | |
| labels: ${{ steps.meta.outputs.labels }} | |
| tags: ${{ github.repository }} | |
| outputs: type=image,"name=${{ github.repository }}",push-by-digest=true,name-canonical=true,push=true | |
| - name: Export digest | |
| env: | |
| TEMP: ${{ runner.temp }} | |
| DIGEST: ${{ steps.build.outputs.digest }} | |
| run: | | |
| mkdir -p "${TEMP}/digests" | |
| digest="${DIGEST}" | |
| touch "${TEMP}/digests/${digest#sha256:}" | |
| - name: Upload digest | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: digests-${{ env.PLATFORM_PAIR }} | |
| path: ${{ runner.temp }}/digests/* | |
| if-no-files-found: error | |
| retention-days: 1 | |
| docker-merge: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 # observed max 36s; the prune step lives here | |
| needs: | |
| - build-docker | |
| environment: docker | |
| steps: | |
| - name: Download digests | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| path: ${{ runner.temp }}/digests | |
| pattern: digests-* | |
| merge-multiple: true | |
| - name: Login to Docker Hub | |
| uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 | |
| with: | |
| username: ${{ secrets.DOCKERHUB_USER }} | |
| password: ${{ secrets.DOCKERHUB_TOKEN }} | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 | |
| - name: Docker meta | |
| id: meta | |
| uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 | |
| with: | |
| images: | | |
| ${{ github.repository }} | |
| tags: | | |
| type=schedule,pattern=nightly | |
| type=schedule,pattern=nightly-{{date 'YYYYMMDD'}} | |
| type=edge,branch=build | |
| type=sha,prefix=edge-,format=short,enable=${{ github.ref == 'refs/heads/build' }} | |
| - name: Record the digests these tags are about to replace | |
| if: ${{ github.repository == 'rotki/rotki' }} | |
| env: | |
| REPOSITORY: ${{ github.repository }} | |
| TAGS: ${{ steps.meta.outputs.tags }} | |
| run: | | |
| set -euo pipefail | |
| # Pushing a tag that already exists leaves whatever it pointed at | |
| # before untagged, and an untagged manifest can no longer be listed, | |
| # so the prune step would never see it again. Record what each tag | |
| # resolves to now, while it is still reachable; the prune step then | |
| # deletes the ones nothing references any more. This is the re-run | |
| # case: a second scheduled run on the same day reuses | |
| # nightly-YYYYMMDD, and a rebuild of the same commit reuses | |
| # edge-<sha>. The repo is public, so resolving a tag needs no auth. | |
| # "<tag> <index-digest> <child,child,...>" per line. The children are | |
| # recorded too: a rebuild can produce a new index over byte-identical | |
| # arch manifests, and deleting the old index must not take a child the | |
| # newly pushed one still references. | |
| : > "${RUNNER_TEMP}/superseded.txt" | |
| while read -r ref; do | |
| [ -n "$ref" ] || continue | |
| tag="${ref##*:}" | |
| # Only a 404 is benign here (the tag does not exist yet). Anything | |
| # else -- a 429 from this unauthenticated endpoint, a 5xx, a DNS | |
| # failure -- would silently lose the record of a manifest the push | |
| # is about to orphan, which is the exact leak this step prevents. | |
| code="$(curl -sS -o "${RUNNER_TEMP}/tag.json" -w '%{http_code}' \ | |
| "https://hub.docker.com/v2/repositories/${REPOSITORY}/tags/${tag}/" || true)" | |
| case "$code" in | |
| 200) ;; | |
| 404) echo "no current ${tag}, nothing it can supersede"; continue ;; | |
| *) echo "cannot resolve ${tag}: HTTP ${code}" >&2; exit 1 ;; | |
| esac | |
| jq -r --arg tag "$tag" ' | |
| (.digest // empty) as $d | |
| | select($d != "") | |
| | "\($tag) \($d) \([.images[]?.digest // empty] | join(","))" | |
| ' "${RUNNER_TEMP}/tag.json" >> "${RUNNER_TEMP}/superseded.txt" | |
| done <<< "$TAGS" | |
| cat "${RUNNER_TEMP}/superseded.txt" | |
| - name: Create manifest list and push | |
| working-directory: ${{ runner.temp }}/digests | |
| env: | |
| REPOSITORY: ${{ github.repository }} | |
| run: | | |
| docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ | |
| $(printf "${REPOSITORY}@sha256:%s " *) | |
| - name: Inspect image | |
| env: | |
| REPOSITORY: ${{ github.repository }} | |
| VERSION: ${{ steps.meta.outputs.version }} | |
| run: | | |
| docker buildx imagetools inspect "${REPOSITORY}:${VERSION}" | |
| - name: Prune old dated dev-build images | |
| if: ${{ github.repository == 'rotki/rotki' }} | |
| # Backstop for anything the script does not anticipate. Every failure it | |
| # does anticipate is already a warning with an explanation, so reaching | |
| # this is itself a sign the script needs a case adding. | |
| continue-on-error: true | |
| env: | |
| DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }} | |
| DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} | |
| REPOSITORY: ${{ github.repository }} | |
| KEEP: 2 | |
| run: | | |
| set -euo pipefail | |
| # Keep only the newest $KEEP dated dev-build images per family | |
| # (nightly-YYYYMMDD, edge-<short-sha>) and delete the rest, image and | |
| # all. A tag delete via the hub.docker.com API only drops the tag and | |
| # leaves the multi-arch index + its arch/attestation children as | |
| # untagged leftovers (Docker Hub does not garbage-collect them, and its | |
| # image-management API is unreachable with a token on this account). | |
| # The OCI registry manifest DELETE, however, works: deleting an index | |
| # digest removes the index, every child manifest it references, and the | |
| # tag pointing at it. So we list tags via the Hub API but reclaim via | |
| # the registry. | |
| # | |
| # Token hygiene throughout: secrets are read from the environment (never | |
| # on an argv), derived tokens are masked in the log, and every auth | |
| # header/credential is handed to curl through a config on a file | |
| # descriptor (printf is a shell builtin) so it never lands on argv or | |
| # disk. The rolling `nightly`/`edge` tags and all release tags do not | |
| # match the dated patterns and are never selected. | |
| # Reclaiming storage is housekeeping, not part of publishing the image: | |
| # the push has already succeeded by the time we get here. So every | |
| # failure below is reported as a warning and the step still exits 0 -- | |
| # a stuck prune must not turn the nightly red. The cost is that it can | |
| # go unnoticed, so each warning says plainly that storage keeps growing | |
| # until it is fixed. | |
| # Every deliberate stop below is an `exit 0`, so a non-zero exit means | |
| # the script hit something it does not handle. `continue-on-error` | |
| # would leave that green and silent, which is how a prune stops working | |
| # without anyone noticing. | |
| report_crash() { | |
| local rc=$? | |
| [ "$rc" -eq 0 ] && return 0 | |
| echo "::warning title=Docker prune crashed::The prune script exited unexpectedly with status ${rc}, so it did not finish and nothing after that point ran. This is a bug in the step itself, not a Docker Hub problem." | |
| } | |
| trap report_crash EXIT | |
| # Decode a JWT payload (base64url, padding stripped) to stdout. | |
| jwt_payload() { | |
| local p="${1#*.}" | |
| p="${p%%.*}" | |
| case $(( ${#p} % 4 )) in | |
| 2) p="${p}==" ;; | |
| 3) p="${p}=" ;; | |
| esac | |
| printf '%s' "$p" | tr '_-' '/+' | base64 -d 2>/dev/null | |
| } | |
| # --- Hub JWT: lists the tags and their index digests --- | |
| hub_jwt="$(jq -nc '{username: env.DOCKERHUB_USER, password: env.DOCKERHUB_TOKEN}' \ | |
| | curl -fsS -H 'Content-Type: application/json' -d @- \ | |
| https://hub.docker.com/v2/users/login \ | |
| | jq -r '.token')" || true | |
| if [ -z "$hub_jwt" ] || [ "$hub_jwt" = 'null' ]; then | |
| echo '::warning title=Docker prune skipped::Docker Hub login failed, so nothing was pruned this run and untagged images keep accumulating. Check the DOCKERHUB_USER/DOCKERHUB_TOKEN secrets.' | |
| exit 0 | |
| fi | |
| echo "::add-mask::${hub_jwt}" | |
| # EVERY tag in the repo, as "<last_updated> <name> <index-digest>" per | |
| # line. All of them, not just the first page: the collateral guard | |
| # below can only protect a tag it has seen, so a release tag left off | |
| # by pagination would be one we could delete by accident. NOTE: Docker | |
| # Hub inverts the usual convention -- ordering=last_updated returns | |
| # NEWEST first (a leading '-' gives oldest); do not "fix" it to | |
| # -last_updated. The ISO 8601 timestamp sorts lexically the same as | |
| # chronologically. | |
| # Two record kinds per page: | |
| # T <last_updated> <name> <index-digest> -- candidates come from these | |
| # R <digest> <name> -- <digest> is referenced by <name> | |
| # An R row is emitted for a tag's index AND for every child manifest it | |
| # lists, because deleting an index takes its children with it. Roughly | |
| # a third of this repo's tags carry a null top-level digest (older | |
| # single-arch releases); those are skipped as candidates but still | |
| # contribute their children as references. | |
| declare -a lines=() refs=() kids=() | |
| url="https://hub.docker.com/v2/repositories/${REPOSITORY}/tags/?page_size=100&ordering=last_updated" | |
| for _page in $(seq 20); do | |
| [ -n "$url" ] || break | |
| body="$(curl -fsS \ | |
| --config <(printf 'header = "Authorization: JWT %s"\n' "$hub_jwt") "$url")" | |
| # jq's status is checked explicitly: inside <(...) it would be | |
| # discarded, and a page that silently parsed to nothing is a tag the | |
| # guard cannot see. | |
| rows="$(jq -r ' | |
| .results[] as $t | |
| | ( "T \($t.last_updated) \($t.name) \($t.digest // "-")" ), | |
| ( select($t.digest != null) | "R \($t.digest) \($t.name)" ), | |
| ( $t.images[]? | select(.digest != null) | "R \(.digest) \($t.name)" ), | |
| ( $t.images[]? | select(.digest != null) | "C \($t.name) \(.digest)" ) | |
| ' <<< "$body")" | |
| mapfile -t -O "${#lines[@]}" lines < <(grep '^T ' <<< "$rows" | cut -d' ' -f2-) | |
| mapfile -t -O "${#refs[@]}" refs < <(grep '^R ' <<< "$rows" | cut -d' ' -f2-) | |
| mapfile -t -O "${#kids[@]}" kids < <(grep '^C ' <<< "$rows" | cut -d' ' -f2-) | |
| url="$(jq -r '.next // empty' <<< "$body")" | |
| done | |
| # Both of these mean the collateral guard below is working from an | |
| # incomplete picture, and a tag it cannot see is a tag it could delete | |
| # by accident. Stop, but without failing the job. | |
| if [ -n "$url" ]; then | |
| echo '::warning title=Docker prune skipped::The tag listing did not terminate within 20 pages, so the guard that protects release tags is incomplete and nothing was pruned this run.' | |
| exit 0 | |
| fi | |
| if [ "${#lines[@]}" -eq 0 ]; then | |
| echo '::warning title=Docker prune skipped::The tag listing came back empty, so nothing was pruned this run.' | |
| exit 0 | |
| fi | |
| echo "listed ${#lines[@]} tags, ${#refs[@]} digest references" | |
| # digest -> " name name ", covering indexes and children alike. | |
| declare -A referenced_by=() | |
| for r in "${refs[@]}"; do | |
| referenced_by["${r%% *}"]="${referenced_by[${r%% *}]:-} ${r#* }" | |
| done | |
| # tag name -> " child child ", so a candidate's children can be checked | |
| # against the references above. | |
| declare -A children_of=() | |
| for c in "${kids[@]}"; do | |
| children_of["${c%% *}"]="${children_of[${c%% *}]:-} ${c#* }" | |
| done | |
| # Select stale dated tags (all but the newest $KEEP per family), keeping | |
| # each tag paired with its index digest. Patterns are anchored on the | |
| # name field (single spaces around it) so the timestamp/digest cannot | |
| # match. | |
| declare -a stale_names=() stale_digests=() stale_kinds=() stale_kids=() | |
| # Names whose references do not count as protection. A superseded | |
| # candidate is deliberately NOT in here: its name now belongs to the | |
| # freshly pushed tag, which is very much being kept. | |
| own_names='' | |
| for pattern in ' nightly-[0-9]{8} ' ' edge-[0-9a-f]{7,} '; do | |
| while read -r _ts name digest; do | |
| [ -n "$name" ] || continue | |
| [ "$digest" != '-' ] || { echo "skip ${name}: no index digest listed"; continue; } | |
| stale_names+=("$name"); stale_digests+=("$digest") | |
| stale_kinds+=('tag'); stale_kids+=("${children_of[$name]:-}") | |
| # Only a live tag being pruned may discount its own references. | |
| own_names="${own_names} ${name}" | |
| done < <(printf '%s\n' "${lines[@]}" | grep -E "$pattern" | sort -r | tail -n "+$((KEEP + 1))") | |
| done | |
| # Plus the digests this run's own push superseded, recorded before the | |
| # push because they are unlistable afterwards. Only the dated families | |
| # qualify: a rolling tag's predecessor is always still held by its own | |
| # dated tag, so taking rolling tags here would add a candidate that no | |
| # pattern ever vouched for. | |
| if [ -f "${RUNNER_TEMP}/superseded.txt" ]; then | |
| while read -r tag digest kids; do | |
| [ -n "$digest" ] || continue | |
| case " ${tag} " in | |
| ' nightly-'[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]' ') ;; | |
| ' edge-'*) [[ ${tag#edge-} =~ ^[0-9a-f]{7,}$ ]] || continue ;; | |
| *) continue ;; | |
| esac | |
| stale_names+=("$tag"); stale_digests+=("$digest") | |
| stale_kinds+=('superseded'); stale_kids+=("${kids//,/ }") | |
| done < "${RUNNER_TEMP}/superseded.txt" | |
| fi | |
| if [ "${#stale_digests[@]}" -eq 0 ]; then | |
| echo 'No stale dated dev-build images to prune.' | |
| exit 0 | |
| fi | |
| # Registry token with delete scope, minted only now that there is work | |
| # to do: these expire in about five minutes, and the tag listing above | |
| # can take several requests. | |
| reg_token="$(curl -fsSL \ | |
| --config <(printf 'user = "%s:%s"\n' "$DOCKERHUB_USER" "$DOCKERHUB_TOKEN") \ | |
| "https://auth.docker.io/token?service=registry.docker.io&scope=repository:${REPOSITORY}:pull,push,delete" \ | |
| | jq -r '.token')" | |
| if [ -z "$reg_token" ] || [ "$reg_token" = 'null' ]; then | |
| echo '::warning title=Docker prune skipped::Could not mint a registry token, so nothing was pruned this run.' | |
| exit 0 | |
| fi | |
| echo "::add-mask::${reg_token}" | |
| # What the registry ACTUALLY granted, which is not what we asked for. | |
| # auth.docker.io answers 200 with a valid token even when it cannot | |
| # authenticate the caller at all: it silently narrows the scope rather | |
| # than failing, so neither curl's -f nor the emptiness check above can | |
| # see a credential or permission problem. Left unreported, the only | |
| # symptom is a bare 401 on the first DELETE, which looks like a | |
| # transient registry error and is not. | |
| reg_access="$(jwt_payload "$reg_token" \ | |
| | jq -c '[.access[]? | select(.type == "repository" and .name == env.REPOSITORY)] | first // {}' \ | |
| 2>/dev/null || true)" | |
| [ -n "$reg_access" ] || reg_access='{}' | |
| reg_actions="$(jq -r '[.actions[]?] | sort | join(",")' <<< "$reg_access" 2>/dev/null || true)" | |
| echo "registry token grants [${reg_actions:-unreadable}] on ${REPOSITORY}" | |
| # A token issued to nobody carries the anonymous pull-rate parameters. | |
| # That is the fingerprint that separates "our credentials never | |
| # arrived" from "our credentials arrived and delete was refused" -- | |
| # the two produce an identical 401 at the DELETE. | |
| if [ -z "$reg_actions" ]; then | |
| echo '::warning title=Docker prune: token scope unreadable::Could not read the grant out of the registry token, so this run cannot say in advance whether deletes will work. That is a defect in this check rather than evidence of a credential problem; the deletes below may well succeed.' | |
| elif [ -n "$(jq -r '.parameters.pull_limit // empty' <<< "$reg_access" 2>/dev/null || true)" ]; then | |
| echo "::warning title=Docker prune: credentials did not authenticate::The registry token came back anonymous (it carries the anonymous pull-rate parameters and grants [${reg_actions:-none}]). DOCKERHUB_USER/DOCKERHUB_TOKEN did not reach the auth service in usable form, so every delete below will fail with 401. Note the Hub login earlier in this step passes its credentials as a JSON body while this call passes them through a curl config, so a secret containing a quote or backslash breaks here and only here." | |
| elif [[ ",${reg_actions}," != *,delete,* ]]; then | |
| echo "::warning title=Docker prune: delete not granted::Authentication succeeded but the registry granted only [${reg_actions:-none}], not delete, so every delete below will fail with 401. The PAT's own scopes are not the whole story: on an organisation repository the account's role caps the grant, and deleting manifests needs admin on the repository -- a read/write member gets [pull,push] no matter what the PAT says." | |
| fi | |
| # Three independent layers stand between a candidate and an | |
| # irreversible delete, so that no single one has to be perfect: | |
| # 1. the name matched a dated pattern (already enforced above) | |
| # 2. the tag still resolves the way we think it does (per candidate) | |
| # 3. neither the index nor any child it carries is referenced by a | |
| # tag we are keeping (the listing) | |
| # Layer 3 is the only one that catches a release cut from the same | |
| # commit as a dev build, where v<x.y.z> and edge-<sha> land on one | |
| # index. That is a permanent skip, and correctly so. | |
| declare -a done_digests=() failed_names=() | |
| first_reason='' | |
| for i in "${!stale_digests[@]}"; do | |
| name="${stale_names[$i]}" | |
| digest="${stale_digests[$i]}" | |
| kind="${stale_kinds[$i]}" | |
| # Two candidates can share one index. The first delete already took | |
| # it, and a second attempt would 404 and fail the job. | |
| case " ${done_digests[*]} " in | |
| *" ${digest} "*) | |
| echo "skip ${name} (${digest}): already deleted" | |
| continue | |
| ;; | |
| esac | |
| # Layer 2. A live stale tag must still point where the listing said; | |
| # a superseded one must NOT, since the push moved it. Either way a | |
| # mismatch means something changed under us, so we bail rather than | |
| # guess. | |
| code="$(curl -sS -o "${RUNNER_TEMP}/now.json" -w '%{http_code}' \ | |
| --config <(printf 'header = "Authorization: JWT %s"\n' "$hub_jwt") \ | |
| "https://hub.docker.com/v2/repositories/${REPOSITORY}/tags/${name}/" || true)" | |
| case "$code" in | |
| 200) now="$(jq -r '.digest // "-"' "${RUNNER_TEMP}/now.json")" ;; | |
| 404) now='-' ;; | |
| *) | |
| echo "cannot re-resolve ${name}: HTTP ${code}, left alone (without a confirmed digest the safety check cannot run)" | |
| failed_names+=("$name") | |
| [ -n "$first_reason" ] || first_reason="HTTP ${code} re-resolving the tag on Docker Hub, so its digest could not be confirmed." | |
| continue | |
| ;; | |
| esac | |
| if [ "$kind" = 'tag' ] && [ "$now" != "$digest" ]; then | |
| echo "skip ${name} (${digest}): tag now resolves to ${now}" | |
| continue | |
| fi | |
| if [ "$kind" = 'superseded' ] && [ "$now" = "$digest" ]; then | |
| echo "skip ${name} (${digest}): still the current digest, not superseded" | |
| continue | |
| fi | |
| # Layer 3, over the index and every child it would take with it. | |
| protected='' | |
| for d in "$digest" ${stale_kids[$i]}; do | |
| for other in ${referenced_by[$d]:-}; do | |
| case " ${own_names} " in *" ${other} "*) continue ;; esac | |
| case " ${protected} " in *" ${other} "*) continue ;; esac | |
| protected="${protected} ${other}" | |
| done | |
| done | |
| if [ -n "$protected" ]; then | |
| echo "skip ${name} (${digest}): shared with kept tag(s)${protected}" | |
| continue | |
| fi | |
| # Tolerate curl's own exit status so a transport failure reports as | |
| # code 000 through the message below, rather than killing the step | |
| # with no indication of which digest it died on. The body and the | |
| # headers are kept: the registry names the reason in an OCI error | |
| # object and in the WWW-Authenticate challenge, and without them a | |
| # 401 is indistinguishable from any other kind of refusal. | |
| # Truncated first: curl may write neither file when it never gets a | |
| # response, and stale content from the previous candidate would then | |
| # annotate this one with someone else's error. | |
| : > "${RUNNER_TEMP}/delete-body.json" | |
| : > "${RUNNER_TEMP}/delete-head.txt" | |
| code="$(curl -sS -o "${RUNNER_TEMP}/delete-body.json" -D "${RUNNER_TEMP}/delete-head.txt" \ | |
| -w '%{http_code}' -X DELETE \ | |
| --config <(printf 'header = "Authorization: Bearer %s"\n' "$reg_token") \ | |
| "https://registry-1.docker.io/v2/${REPOSITORY}/manifests/${digest}" || true)" | |
| if [ "$code" = '202' ]; then | |
| done_digests+=("$digest") | |
| echo "deleted ${REPOSITORY}:${name} (${digest})" | |
| continue | |
| fi | |
| if [ "$code" = '404' ]; then | |
| # Already reclaimed, by an earlier run or by a candidate in this one | |
| # that shared the index. Nothing is left behind, so this is not a | |
| # failure and must not be counted as one. | |
| done_digests+=("$digest") | |
| echo "skip ${name} (${digest}): already gone from the registry" | |
| continue | |
| fi | |
| detail="$(jq -r '[.errors[]? | "\(.code // "?"): \(.message // "?")"] | join("; ")' \ | |
| "${RUNNER_TEMP}/delete-body.json" 2>/dev/null || true)" | |
| challenge="$(sed -n 's/^[Ww][Ww][Ww]-[Aa]uthenticate: *//p' \ | |
| "${RUNNER_TEMP}/delete-head.txt" 2>/dev/null | tr -d '\r' || true)" | |
| case "$code" in | |
| 401) | |
| reason="the registry rejected the token. It was granted [${reg_actions:-unreadable}] on ${REPOSITORY}, and a delete needs 'delete' in that list. Either the credentials did not authenticate (see the token warning earlier in this step) or the account lacks delete rights on the repository -- on an org repo that means admin, which a read/write member does not have regardless of the PAT's own scopes." | |
| ;; | |
| 403) | |
| reason='authenticated, but this account is not allowed to delete in this repository. Check its role on the organisation and on the repository.' | |
| ;; | |
| 405|501) | |
| reason='the registry says manifest deletion is not allowed here, which would mean Docker Hub withdrew the registry DELETE this whole step depends on. That needs a different approach, not a retry.' | |
| ;; | |
| 429) | |
| reason='rate limited by Docker Hub. Harmless on its own; the next run retries.' | |
| ;; | |
| 000) | |
| reason='no HTTP response at all (DNS, TLS or connection failure). Transient, the next run retries.' | |
| ;; | |
| 5*) | |
| reason='Docker Hub server error. Transient, the next run retries.' | |
| ;; | |
| *) | |
| reason='unexpected status.' | |
| ;; | |
| esac | |
| # Plain log line, not an annotation: GitHub keeps only the first ten | |
| # warnings per step, and when every candidate fails -- the case this | |
| # reporting exists for -- the per-image ones would crowd out the | |
| # summary below, which is the only place the cause is named. | |
| failed_names+=("$name") | |
| [ -n "$first_reason" ] || first_reason="HTTP ${code}: ${reason}" | |
| echo "FAILED to delete ${REPOSITORY}:${name} (${digest}): HTTP ${code}: ${reason}${detail:+ Registry said: ${detail}.}${challenge:+ Challenge: ${challenge}.}" | |
| done | |
| if [ "${#failed_names[@]}" -gt 0 ]; then | |
| echo "::warning title=Docker prune incomplete::${#failed_names[@]} of ${#stale_digests[@]} stale dev-build image(s) could not be reclaimed (${failed_names[*]}). First cause: ${first_reason} The images themselves published fine, and the nightly is deliberately not failed for this, so nothing else will flag it: registry storage keeps growing every night until this is resolved." | |
| fi | |
| notify: | |
| name: 'Success check' | |
| if: ${{ always() && (github.repository == 'rotki/rotki' || github.event_name != 'schedule') }} | |
| needs: [ 'build-linux', 'build-windows', 'build-macos', 'docker-merge' ] | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 # observed max 42s | |
| environment: nightly_notify | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| fetch-depth: 1 | |
| persist-credentials: false | |
| - name: Check if any task failed | |
| run: | | |
| data=($(echo "${RESULT}" | sed 's/[][,"]//g')) | |
| echo "Processing ${#data[@]} entries" | |
| for idx in "${!data[@]}" | |
| do | |
| echo "Processing entry $idx: ${data[$idx]}" | |
| if [[ ${data[$idx]} == "failure" ]]; then | |
| ./.github/scripts/notifier.py --webhook "$DISCORD_WEBHOOK" --run-id "$GITHUB_RUN_ID" | |
| exit 1; | |
| fi | |
| done | |
| env: | |
| RESULT: ${{ toJSON(needs.*.result) }} | |
| DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} |