mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(app-proxy): share one asset tree across architectures (#2325)
This commit is contained in:
@@ -15,6 +15,13 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: publish-fluxer-app-proxy-self-hosted
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
GHCR_OWNER: ${{ github.repository_owner }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
approve:
|
approve:
|
||||||
name: approve build release
|
name: approve build release
|
||||||
@@ -26,13 +33,208 @@ jobs:
|
|||||||
- name: approved
|
- name: approved
|
||||||
run: echo "Build release approved."
|
run: echo "Build release approved."
|
||||||
|
|
||||||
build:
|
meta:
|
||||||
|
name: resolve metadata
|
||||||
needs: approve
|
needs: approve
|
||||||
uses: ./.github/workflows/_build-image.yaml
|
runs-on: ubuntu-24.04
|
||||||
secrets: inherit
|
timeout-minutes: 5
|
||||||
with:
|
permissions:
|
||||||
image: fluxer-app-proxy-self-hosted
|
contents: read
|
||||||
dockerfile: fluxer_app_proxy/Dockerfile
|
outputs:
|
||||||
build-version: ${{ inputs['build-version'] }}
|
build_version: ${{ steps.vars.outputs.build_version }}
|
||||||
extra-build-args: |
|
steps:
|
||||||
FLUXER_APP_PROXY_TIME_FREEZE_ENABLED=false
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||||
|
env:
|
||||||
|
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||||
|
- name: Set up Rust toolchain (CI helpers)
|
||||||
|
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||||
|
with:
|
||||||
|
toolchain: "1.93.0"
|
||||||
|
- name: set variables
|
||||||
|
id: vars
|
||||||
|
run: >-
|
||||||
|
tools/ci/run.sh build-app-proxy
|
||||||
|
--step set_metadata
|
||||||
|
--build-version "${{ inputs['build-version'] }}"
|
||||||
|
|
||||||
|
dist:
|
||||||
|
name: build the canonical asset tree
|
||||||
|
needs: meta
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 60
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
env:
|
||||||
|
IMAGE_REPO: ghcr.io/${{ github.repository_owner }}/fluxer-app-proxy-self-hosted
|
||||||
|
BUILD_VERSION: ${{ needs.meta.outputs.build_version }}
|
||||||
|
PUBLIC_ASSET_BASE_URL: ""
|
||||||
|
BUNDLE_LOCAL_ASSETS: "true"
|
||||||
|
FLUXER_APP_PROXY_TIME_FREEZE_ENABLED: "false"
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||||
|
env:
|
||||||
|
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||||
|
- name: Set up Rust toolchain (CI helpers)
|
||||||
|
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||||
|
with:
|
||||||
|
toolchain: "1.93.0"
|
||||||
|
- name: prepare docker config
|
||||||
|
run: >-
|
||||||
|
tools/ci/run.sh build-app-proxy
|
||||||
|
--step prepare_docker_config
|
||||||
|
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
||||||
|
- name: configure ghcr auth
|
||||||
|
env:
|
||||||
|
GHCR_USERNAME: ${{ github.actor }}
|
||||||
|
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: >-
|
||||||
|
tools/ci/run.sh build-app-proxy
|
||||||
|
--step configure_ghcr_auth
|
||||||
|
|
||||||
|
- name: build the dist once and publish it as the canonical asset image
|
||||||
|
env:
|
||||||
|
CACHE_FROM: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-dist
|
||||||
|
CACHE_TO: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-dist,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
|
||||||
|
DOCKER_BUILD_SUMMARY: false
|
||||||
|
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||||
|
run: >-
|
||||||
|
tools/ci/run.sh build-app-proxy
|
||||||
|
--step build_dist
|
||||||
|
|
||||||
|
- name: generate asset manifest
|
||||||
|
run: >-
|
||||||
|
tools/ci/run.sh build-app-proxy
|
||||||
|
--step generate_asset_manifest
|
||||||
|
|
||||||
|
- name: verify every manifest asset ships in the image
|
||||||
|
run: >-
|
||||||
|
tools/ci/run.sh build-app-proxy
|
||||||
|
--step verify_published_assets
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: build ${{ matrix.platform }}
|
||||||
|
needs: [meta, dist]
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
timeout-minutes: 75
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: amd64
|
||||||
|
runner: ubuntu-24.04
|
||||||
|
- platform: arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||||
|
env:
|
||||||
|
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||||
|
- name: resolve source date
|
||||||
|
id: source
|
||||||
|
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||||
|
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
||||||
|
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: fluxer_app_proxy/Dockerfile
|
||||||
|
push: true
|
||||||
|
provenance: false
|
||||||
|
platforms: linux/${{ matrix.platform }}
|
||||||
|
tags: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-${{ matrix.platform }}
|
||||||
|
build-args: |
|
||||||
|
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
|
||||||
|
SOURCE_SHA=${{ github.sha }}
|
||||||
|
SOURCE_DATE=${{ steps.source.outputs.date }}
|
||||||
|
FLUXER_APP_PROXY_TIME_FREEZE_ENABLED=false
|
||||||
|
APP_ASSETS_REF=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-assets
|
||||||
|
APP_ASSETS_PLATFORM=linux/amd64
|
||||||
|
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-${{ matrix.platform }}
|
||||||
|
cache-to: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-${{ matrix.platform }},mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
|
||||||
|
env:
|
||||||
|
DOCKER_BUILD_SUMMARY: false
|
||||||
|
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||||
|
|
||||||
|
merge:
|
||||||
|
name: merge multi-arch manifest
|
||||||
|
needs: [meta, build]
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 20
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||||
|
env:
|
||||||
|
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||||
|
- name: Set up Rust toolchain (CI helpers)
|
||||||
|
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||||
|
with:
|
||||||
|
toolchain: "1.93.0"
|
||||||
|
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
||||||
|
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: verify cross-architecture asset parity
|
||||||
|
env:
|
||||||
|
APP_PROXY_ASSETS_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-assets
|
||||||
|
APP_PROXY_AMD64_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-amd64
|
||||||
|
APP_PROXY_ARM64_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-arm64
|
||||||
|
run: >-
|
||||||
|
tools/ci/run.sh build-app-proxy
|
||||||
|
--step verify_asset_parity
|
||||||
|
|
||||||
|
- name: create and push multi-arch manifest
|
||||||
|
env:
|
||||||
|
IMAGE: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted
|
||||||
|
VERSION: ${{ needs.meta.outputs.build_version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
docker buildx imagetools create -t "${IMAGE}:${VERSION}" \
|
||||||
|
"${IMAGE}:${VERSION}-amd64" \
|
||||||
|
"${IMAGE}:${VERSION}-arm64"
|
||||||
|
docker buildx imagetools inspect "${IMAGE}:${VERSION}"
|
||||||
|
|
||||||
|
- name: Create token
|
||||||
|
id: create-token
|
||||||
|
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||||
|
with:
|
||||||
|
client-id: ${{ vars.FLUXER_CI_APP_ID }}
|
||||||
|
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
|
||||||
|
owner: fluxerapp
|
||||||
|
repositories: fluxer
|
||||||
|
permission-contents: write
|
||||||
|
- name: Publish GitHub release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ steps.create-token.outputs.token }}
|
||||||
|
SOURCE_SHA: ${{ github.sha }}
|
||||||
|
VERSION: ${{ needs.meta.outputs.build_version }}
|
||||||
|
RELEASE_BASELINE_SHA: ${{ vars.RELEASE_BASELINE_SHA }}
|
||||||
|
run: >-
|
||||||
|
tools/ci/run.sh release
|
||||||
|
publish
|
||||||
|
--component fluxer-app-proxy-self-hosted
|
||||||
|
--build-version "${VERSION}"
|
||||||
|
--source-sha "${SOURCE_SHA}"
|
||||||
|
--previous-sha "${RELEASE_BASELINE_SHA}"
|
||||||
|
|
||||||
|
- name: Advance moving image tags
|
||||||
|
env:
|
||||||
|
IMAGE: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted
|
||||||
|
VERSION: ${{ needs.meta.outputs.build_version }}
|
||||||
|
run: >-
|
||||||
|
docker buildx imagetools create
|
||||||
|
-t "${IMAGE}:v1"
|
||||||
|
-t "${IMAGE}:latest"
|
||||||
|
"${IMAGE}:${VERSION}"
|
||||||
|
|||||||
@@ -57,11 +57,11 @@ jobs:
|
|||||||
--step set_metadata
|
--step set_metadata
|
||||||
--build-version "${{ inputs['build-version'] }}"
|
--build-version "${{ inputs['build-version'] }}"
|
||||||
|
|
||||||
build:
|
dist:
|
||||||
name: build app-proxy (amd64)
|
name: build and publish the canonical asset tree
|
||||||
needs: meta
|
needs: meta
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 45
|
timeout-minutes: 60
|
||||||
permissions:
|
permissions:
|
||||||
actions: read
|
actions: read
|
||||||
contents: read
|
contents: read
|
||||||
@@ -87,17 +87,17 @@ jobs:
|
|||||||
tools/ci/run.sh build-app-proxy
|
tools/ci/run.sh build-app-proxy
|
||||||
--step configure_ghcr_auth
|
--step configure_ghcr_auth
|
||||||
|
|
||||||
- name: build and push image + extract assets
|
- name: build the dist once and publish it as the canonical asset image
|
||||||
env:
|
env:
|
||||||
BUILD_VERSION: ${{ needs.meta.outputs.build_version }}
|
BUILD_VERSION: ${{ needs.meta.outputs.build_version }}
|
||||||
PUBLIC_ASSET_BASE_URL: https://fluxerstatic.com
|
PUBLIC_ASSET_BASE_URL: https://fluxerstatic.com
|
||||||
CACHE_FROM: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-amd64
|
CACHE_FROM: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-dist
|
||||||
CACHE_TO: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-amd64,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
|
CACHE_TO: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-dist,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
|
||||||
DOCKER_BUILD_SUMMARY: false
|
DOCKER_BUILD_SUMMARY: false
|
||||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||||
run: >-
|
run: >-
|
||||||
tools/ci/run.sh build-app-proxy
|
tools/ci/run.sh build-app-proxy
|
||||||
--step build_and_extract
|
--step build_dist
|
||||||
|
|
||||||
- name: generate asset manifest
|
- name: generate asset manifest
|
||||||
run: >-
|
run: >-
|
||||||
@@ -114,9 +114,63 @@ jobs:
|
|||||||
tools/ci/run.sh build-app-proxy
|
tools/ci/run.sh build-app-proxy
|
||||||
--step upload_assets
|
--step upload_assets
|
||||||
|
|
||||||
|
- name: verify every uploaded asset is readable
|
||||||
|
env:
|
||||||
|
PUBLIC_ASSET_BASE_URL: https://fluxerstatic.com
|
||||||
|
run: >-
|
||||||
|
tools/ci/run.sh build-app-proxy
|
||||||
|
--step verify_published_assets
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: build app-proxy (amd64)
|
||||||
|
needs: [meta, dist]
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 45
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||||
|
env:
|
||||||
|
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||||
|
- name: Set up Rust toolchain (CI helpers)
|
||||||
|
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||||
|
with:
|
||||||
|
toolchain: "1.93.0"
|
||||||
|
- name: resolve source date
|
||||||
|
id: source
|
||||||
|
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: prepare docker config
|
||||||
|
run: >-
|
||||||
|
tools/ci/run.sh build-app-proxy
|
||||||
|
--step prepare_docker_config
|
||||||
|
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
||||||
|
- name: configure ghcr auth
|
||||||
|
env:
|
||||||
|
GHCR_USERNAME: ${{ github.actor }}
|
||||||
|
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: >-
|
||||||
|
tools/ci/run.sh build-app-proxy
|
||||||
|
--step configure_ghcr_auth
|
||||||
|
|
||||||
|
- name: build and push image
|
||||||
|
env:
|
||||||
|
BUILD_VERSION: ${{ needs.meta.outputs.build_version }}
|
||||||
|
SOURCE_SHA: ${{ github.sha }}
|
||||||
|
SOURCE_DATE: ${{ steps.source.outputs.date }}
|
||||||
|
PUBLIC_ASSET_BASE_URL: https://fluxerstatic.com
|
||||||
|
CACHE_FROM: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-amd64
|
||||||
|
CACHE_TO: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-amd64,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
|
||||||
|
DOCKER_BUILD_SUMMARY: false
|
||||||
|
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||||
|
run: >-
|
||||||
|
tools/ci/run.sh build-app-proxy
|
||||||
|
--step build_image
|
||||||
|
|
||||||
build-arm64:
|
build-arm64:
|
||||||
name: build app-proxy (arm64)
|
name: build app-proxy (arm64)
|
||||||
needs: meta
|
needs: [meta, dist]
|
||||||
runs-on: ubuntu-24.04-arm
|
runs-on: ubuntu-24.04-arm
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
permissions:
|
permissions:
|
||||||
@@ -127,6 +181,9 @@ jobs:
|
|||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||||
env:
|
env:
|
||||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||||
|
- name: resolve source date
|
||||||
|
id: source
|
||||||
|
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
||||||
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
|
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
|
||||||
with:
|
with:
|
||||||
@@ -143,8 +200,10 @@ jobs:
|
|||||||
tags: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-arm64
|
tags: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-arm64
|
||||||
build-args: |
|
build-args: |
|
||||||
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
|
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
|
||||||
PUBLIC_ASSET_BASE_URL=https://fluxerstatic.com
|
SOURCE_SHA=${{ github.sha }}
|
||||||
BUNDLE_LOCAL_ASSETS=false
|
SOURCE_DATE=${{ steps.source.outputs.date }}
|
||||||
|
APP_ASSETS_REF=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-assets
|
||||||
|
APP_ASSETS_PLATFORM=linux/amd64
|
||||||
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-arm64
|
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-arm64
|
||||||
cache-to: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-arm64,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
|
cache-to: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-arm64,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
|
||||||
env:
|
env:
|
||||||
@@ -155,7 +214,7 @@ jobs:
|
|||||||
name: merge multi-arch manifest
|
name: merge multi-arch manifest
|
||||||
needs: [meta, build, build-arm64]
|
needs: [meta, build, build-arm64]
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 10
|
timeout-minutes: 20
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
@@ -173,6 +232,15 @@ jobs:
|
|||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: verify cross-architecture asset parity
|
||||||
|
env:
|
||||||
|
APP_PROXY_ASSETS_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-assets
|
||||||
|
APP_PROXY_AMD64_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}
|
||||||
|
APP_PROXY_ARM64_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-arm64
|
||||||
|
run: >-
|
||||||
|
tools/ci/run.sh build-app-proxy
|
||||||
|
--step verify_asset_parity
|
||||||
|
|
||||||
- name: fuse amd64 + arm64 into a multi-arch manifest
|
- name: fuse amd64 + arm64 into a multi-arch manifest
|
||||||
env:
|
env:
|
||||||
IMAGE: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy
|
IMAGE: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy
|
||||||
|
|||||||
+16
-11
@@ -4,6 +4,8 @@ ARG BUILD_VERSION=""
|
|||||||
ARG PUBLIC_ASSET_BASE_URL=""
|
ARG PUBLIC_ASSET_BASE_URL=""
|
||||||
ARG FLUXER_APP_PROXY_TIME_FREEZE_ENABLED="true"
|
ARG FLUXER_APP_PROXY_TIME_FREEZE_ENABLED="true"
|
||||||
ARG BUNDLE_LOCAL_ASSETS="true"
|
ARG BUNDLE_LOCAL_ASSETS="true"
|
||||||
|
ARG APP_ASSETS_REF=app-assets
|
||||||
|
ARG APP_ASSETS_PLATFORM=$BUILDPLATFORM
|
||||||
|
|
||||||
# ---------- Stage 1: Build the SPA (fluxer_app) ----------
|
# ---------- Stage 1: Build the SPA (fluxer_app) ----------
|
||||||
FROM node:24-bookworm-slim AS app-build
|
FROM node:24-bookworm-slim AS app-build
|
||||||
@@ -79,13 +81,19 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
|||||||
--mount=type=cache,target=/usr/local/cargo/git \
|
--mount=type=cache,target=/usr/local/cargo/git \
|
||||||
cd fluxer_app && pnpm build
|
cd fluxer_app && pnpm build
|
||||||
|
|
||||||
# Extract the full SPA asset tree for local serving by the app proxy.
|
# ---------- Stage 2: The one canonical asset tree every architecture serves ----------
|
||||||
|
# Stage for CI to extract the full dist (docker bake app-dist target)
|
||||||
|
FROM alpine:3.21 AS app-dist
|
||||||
|
COPY --from=app-build /usr/src/app/fluxer_app/dist /dist
|
||||||
|
|
||||||
FROM alpine:3.21 AS app-assets
|
FROM alpine:3.21 AS app-assets
|
||||||
ARG BUNDLE_LOCAL_ASSETS=true
|
ARG BUNDLE_LOCAL_ASSETS=true
|
||||||
ARG PUBLIC_ASSET_BASE_URL=""
|
ARG PUBLIC_ASSET_BASE_URL=""
|
||||||
COPY --from=app-build /usr/src/app/fluxer_app/dist /assets
|
|
||||||
COPY fluxer_app_proxy/scripts/precompress_assets.sh /usr/local/bin/precompress_assets.sh
|
COPY fluxer_app_proxy/scripts/precompress_assets.sh /usr/local/bin/precompress_assets.sh
|
||||||
RUN if [ "${BUNDLE_LOCAL_ASSETS}" != "true" ]; then \
|
RUN apk add --no-cache brotli
|
||||||
|
RUN --mount=type=bind,from=app-dist,source=/dist,target=/dist \
|
||||||
|
cp -a /dist /assets \
|
||||||
|
&& if [ "${BUNDLE_LOCAL_ASSETS}" != "true" ]; then \
|
||||||
case "${PUBLIC_ASSET_BASE_URL}" in \
|
case "${PUBLIC_ASSET_BASE_URL}" in \
|
||||||
http://* | https://*) ;; \
|
http://* | https://*) ;; \
|
||||||
*) \
|
*) \
|
||||||
@@ -95,15 +103,12 @@ RUN if [ "${BUNDLE_LOCAL_ASSETS}" != "true" ]; then \
|
|||||||
;; \
|
;; \
|
||||||
esac; \
|
esac; \
|
||||||
find /assets/assets -type f ! -name 'fonts-*.txt' -delete; \
|
find /assets/assets -type f ! -name 'fonts-*.txt' -delete; \
|
||||||
fi
|
fi \
|
||||||
RUN apk add --no-cache brotli \
|
|
||||||
&& /usr/local/bin/precompress_assets.sh /assets
|
&& /usr/local/bin/precompress_assets.sh /assets
|
||||||
|
|
||||||
# Stage for CI to extract the full dist (docker bake app-dist target)
|
FROM --platform=${APP_ASSETS_PLATFORM} ${APP_ASSETS_REF} AS app-static
|
||||||
FROM alpine:3.21 AS app-dist
|
|
||||||
COPY --from=app-build /usr/src/app/fluxer_app/dist /dist
|
|
||||||
|
|
||||||
# ---------- Stage 2: Build the Rust proxy binary ----------
|
# ---------- Stage 3: Build the Rust proxy binary ----------
|
||||||
FROM rust:1-bookworm AS rust-builder
|
FROM rust:1-bookworm AS rust-builder
|
||||||
|
|
||||||
ARG FLUXER_APP_PROXY_TIME_FREEZE_ENABLED="true"
|
ARG FLUXER_APP_PROXY_TIME_FREEZE_ENABLED="true"
|
||||||
@@ -141,7 +146,7 @@ RUN if [ "${FLUXER_APP_PROXY_TIME_FREEZE_ENABLED}" = "false" ]; then \
|
|||||||
fi \
|
fi \
|
||||||
&& cp target/release/fluxer_app_proxy /usr/local/bin/fluxer-app-proxy
|
&& cp target/release/fluxer_app_proxy /usr/local/bin/fluxer-app-proxy
|
||||||
|
|
||||||
# ---------- Stage 3: Runtime ----------
|
# ---------- Stage 4: Runtime ----------
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
ARG BUILD_VERSION=""
|
ARG BUILD_VERSION=""
|
||||||
@@ -167,7 +172,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=rust-builder /usr/local/bin/fluxer-app-proxy /usr/local/bin/fluxer-app-proxy
|
COPY --from=rust-builder /usr/local/bin/fluxer-app-proxy /usr/local/bin/fluxer-app-proxy
|
||||||
COPY --from=app-assets /assets ./static/
|
COPY --from=app-static /assets ./static/
|
||||||
|
|
||||||
ENV BUILD_VERSION="${BUILD_VERSION}"
|
ENV BUILD_VERSION="${BUILD_VERSION}"
|
||||||
ENV FLUXER_APP_PROXY_HOST="0.0.0.0"
|
ENV FLUXER_APP_PROXY_HOST="0.0.0.0"
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ variable "BUNDLE_LOCAL_ASSETS" { default = "true" }
|
|||||||
variable "IMAGE_REPO" { default = "" }
|
variable "IMAGE_REPO" { default = "" }
|
||||||
variable "CACHE_FROM" { default = "" }
|
variable "CACHE_FROM" { default = "" }
|
||||||
variable "CACHE_TO" { default = "" }
|
variable "CACHE_TO" { default = "" }
|
||||||
|
variable "APP_ASSETS_REF" { default = "app-assets" }
|
||||||
|
variable "APP_ASSETS_PLATFORM" { default = "linux/amd64" }
|
||||||
|
variable "SOURCE_SHA" { default = "" }
|
||||||
|
variable "SOURCE_DATE" { default = "" }
|
||||||
|
|
||||||
group "default" {
|
group "default" {
|
||||||
targets = ["app-proxy", "app-dist"]
|
targets = ["app-proxy", "app-dist"]
|
||||||
@@ -24,6 +28,10 @@ target "app-proxy" {
|
|||||||
PUBLIC_ASSET_BASE_URL = PUBLIC_ASSET_BASE_URL
|
PUBLIC_ASSET_BASE_URL = PUBLIC_ASSET_BASE_URL
|
||||||
FLUXER_APP_PROXY_TIME_FREEZE_ENABLED = FLUXER_APP_PROXY_TIME_FREEZE_ENABLED
|
FLUXER_APP_PROXY_TIME_FREEZE_ENABLED = FLUXER_APP_PROXY_TIME_FREEZE_ENABLED
|
||||||
BUNDLE_LOCAL_ASSETS = BUNDLE_LOCAL_ASSETS
|
BUNDLE_LOCAL_ASSETS = BUNDLE_LOCAL_ASSETS
|
||||||
|
APP_ASSETS_REF = APP_ASSETS_REF
|
||||||
|
APP_ASSETS_PLATFORM = APP_ASSETS_PLATFORM
|
||||||
|
SOURCE_SHA = SOURCE_SHA
|
||||||
|
SOURCE_DATE = SOURCE_DATE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,3 +41,10 @@ target "app-dist" {
|
|||||||
tags = []
|
tags = []
|
||||||
output = ["type=local,dest=app-dist-output"]
|
output = ["type=local,dest=app-dist-output"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
target "app-assets-image" {
|
||||||
|
inherits = ["app-proxy"]
|
||||||
|
target = "app-assets"
|
||||||
|
tags = IMAGE_REPO != "" ? ["${IMAGE_REPO}:${BUILD_VERSION}-assets"] : []
|
||||||
|
output = ["type=registry"]
|
||||||
|
}
|
||||||
|
|||||||
+591
-13
@@ -2,18 +2,28 @@
|
|||||||
|
|
||||||
use crate::common::{
|
use crate::common::{
|
||||||
CALVER_SCHEME, CalverEnv, CommandSpec, S3UploadPlanItem, append_github_env,
|
CALVER_SCHEME, CalverEnv, CommandSpec, S3UploadPlanItem, append_github_env,
|
||||||
append_github_output, collect_files, path_to_s3_key, require_env, resolve_calver, run_command,
|
append_github_output, collect_files, env_string, output_text, path_to_s3_key,
|
||||||
runner_temp, s3_client, trim_option, upload_s3_plan_append_only,
|
remove_dir_if_exists, require_env, resolve_calver, run_command, runner_temp, s3_client,
|
||||||
|
trim_option, upload_s3_plan_append_only,
|
||||||
};
|
};
|
||||||
use anyhow::{Context, Result, anyhow, ensure};
|
use anyhow::{Context, Result, anyhow, ensure};
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use clap::{Args, ValueEnum};
|
use clap::{Args, ValueEnum};
|
||||||
|
use reqwest::Client;
|
||||||
use serde_json::{Map, Value, json};
|
use serde_json::{Map, Value, json};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::fs;
|
use std::fs::{self, File};
|
||||||
|
use std::io::Read;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::Semaphore;
|
||||||
|
use tokio::task::JoinSet;
|
||||||
|
use tokio::time::sleep;
|
||||||
|
|
||||||
const DEFAULT_PUBLIC_ASSET_BASE_URL: &str = "https://fluxerstatic.com";
|
const DEFAULT_PUBLIC_ASSET_BASE_URL: &str = "https://fluxerstatic.com";
|
||||||
const DEFAULT_APP_PROXY_TIME_FREEZE_ENABLED: &str = "true";
|
const DEFAULT_APP_PROXY_TIME_FREEZE_ENABLED: &str = "true";
|
||||||
@@ -21,6 +31,15 @@ const DEFAULT_APP_PROXY_BUNDLE_LOCAL_ASSETS: &str = "false";
|
|||||||
const DEFAULT_STATIC_BUCKET: &str = "fluxer-static";
|
const DEFAULT_STATIC_BUCKET: &str = "fluxer-static";
|
||||||
const DEFAULT_S3_ENDPOINT: &str = "https://ewr1.vultrobjects.com";
|
const DEFAULT_S3_ENDPOINT: &str = "https://ewr1.vultrobjects.com";
|
||||||
const IMMUTABLE_ASSET_CACHE_CONTROL: &str = "public, max-age=31536000, immutable";
|
const IMMUTABLE_ASSET_CACHE_CONTROL: &str = "public, max-age=31536000, immutable";
|
||||||
|
const RUNTIME_STATIC_DIR: &str = "/srv/app/static";
|
||||||
|
const CANONICAL_ASSETS_DIR: &str = "/assets";
|
||||||
|
const AMD64_PLATFORM: &str = "linux/amd64";
|
||||||
|
const ARM64_PLATFORM: &str = "linux/arm64";
|
||||||
|
const PARITY_DIFF_LIMIT: usize = 20;
|
||||||
|
const ASSET_READ_CONCURRENCY: usize = 16;
|
||||||
|
const ASSET_READ_ATTEMPTS: u32 = 3;
|
||||||
|
const ASSET_READ_RETRY_DELAY: Duration = Duration::from_secs(2);
|
||||||
|
const ASSET_READ_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
#[derive(Debug, Args, Clone)]
|
#[derive(Debug, Args, Clone)]
|
||||||
pub struct BuildAppProxyArgs {
|
pub struct BuildAppProxyArgs {
|
||||||
@@ -36,9 +55,12 @@ enum AppProxyStep {
|
|||||||
SetMetadata,
|
SetMetadata,
|
||||||
PrepareDockerConfig,
|
PrepareDockerConfig,
|
||||||
ConfigureGhcrAuth,
|
ConfigureGhcrAuth,
|
||||||
BuildAndExtract,
|
BuildDist,
|
||||||
|
BuildImage,
|
||||||
GenerateAssetManifest,
|
GenerateAssetManifest,
|
||||||
UploadAssets,
|
UploadAssets,
|
||||||
|
VerifyPublishedAssets,
|
||||||
|
VerifyAssetParity,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run(args: BuildAppProxyArgs) -> Result<()> {
|
pub async fn run(args: BuildAppProxyArgs) -> Result<()> {
|
||||||
@@ -46,9 +68,12 @@ pub async fn run(args: BuildAppProxyArgs) -> Result<()> {
|
|||||||
AppProxyStep::SetMetadata => set_metadata_step(args.build_version.as_deref()),
|
AppProxyStep::SetMetadata => set_metadata_step(args.build_version.as_deref()),
|
||||||
AppProxyStep::PrepareDockerConfig => prepare_docker_config_step(),
|
AppProxyStep::PrepareDockerConfig => prepare_docker_config_step(),
|
||||||
AppProxyStep::ConfigureGhcrAuth => configure_ghcr_auth_step(),
|
AppProxyStep::ConfigureGhcrAuth => configure_ghcr_auth_step(),
|
||||||
AppProxyStep::BuildAndExtract => build_and_extract_step(),
|
AppProxyStep::BuildDist => build_dist_step(),
|
||||||
|
AppProxyStep::BuildImage => build_image_step(),
|
||||||
AppProxyStep::GenerateAssetManifest => generate_asset_manifest_step(),
|
AppProxyStep::GenerateAssetManifest => generate_asset_manifest_step(),
|
||||||
AppProxyStep::UploadAssets => upload_assets_step().await,
|
AppProxyStep::UploadAssets => upload_assets_step().await,
|
||||||
|
AppProxyStep::VerifyPublishedAssets => verify_published_assets_step().await,
|
||||||
|
AppProxyStep::VerifyAssetParity => verify_asset_parity_step(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,21 +143,50 @@ fn ghcr_auth_value(username: &str, token: &str) -> String {
|
|||||||
BASE64.encode(format!("{username}:{token}"))
|
BASE64.encode(format!("{username}:{token}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_and_extract_step() -> Result<()> {
|
fn build_dist_step() -> Result<()> {
|
||||||
run_command(build_and_extract_command()?)
|
run_command(bake_command(&["app-dist", "app-assets-image"])?)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_and_extract_command() -> Result<CommandSpec> {
|
fn build_image_step() -> Result<()> {
|
||||||
|
let command = bake_command(&["app-proxy"])?
|
||||||
|
.env("APP_ASSETS_REF", assets_ref()?)
|
||||||
|
.env("APP_ASSETS_PLATFORM", canonical_assets_platform());
|
||||||
|
run_command(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canonical_assets_platform() -> String {
|
||||||
|
env_string("APP_ASSETS_PLATFORM").unwrap_or_else(|| AMD64_PLATFORM.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assets_ref() -> Result<String> {
|
||||||
|
match env_string("APP_ASSETS_REF") {
|
||||||
|
Some(reference) => Ok(reference),
|
||||||
|
None => Ok(assets_image_ref(
|
||||||
|
&image_repo()?,
|
||||||
|
&require_env("BUILD_VERSION")?,
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assets_image_ref(image_repo: &str, build_version: &str) -> String {
|
||||||
|
format!("{image_repo}:{build_version}-assets")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn image_repo() -> Result<String> {
|
||||||
|
match env::var("IMAGE_REPO") {
|
||||||
|
Ok(value) => Ok(value),
|
||||||
|
Err(_) => Ok(format!("ghcr.io/{}/fluxer-app-proxy", ghcr_owner()?)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bake_command(targets: &[&str]) -> Result<CommandSpec> {
|
||||||
let build_version = require_env("BUILD_VERSION")?;
|
let build_version = require_env("BUILD_VERSION")?;
|
||||||
let public_asset_base_url = env::var("PUBLIC_ASSET_BASE_URL")
|
let public_asset_base_url = env::var("PUBLIC_ASSET_BASE_URL")
|
||||||
.unwrap_or_else(|_| DEFAULT_PUBLIC_ASSET_BASE_URL.to_string());
|
.unwrap_or_else(|_| DEFAULT_PUBLIC_ASSET_BASE_URL.to_string());
|
||||||
let image_repo = match env::var("IMAGE_REPO") {
|
|
||||||
Ok(value) => value,
|
|
||||||
Err(_) => format!("ghcr.io/{}/fluxer-app-proxy", ghcr_owner()?),
|
|
||||||
};
|
|
||||||
Ok(CommandSpec::new("docker")
|
Ok(CommandSpec::new("docker")
|
||||||
.args(["buildx", "bake", "-f", "fluxer_app_proxy/docker-bake.hcl"])
|
.args(["buildx", "bake", "-f", "fluxer_app_proxy/docker-bake.hcl"])
|
||||||
.env("IMAGE_REPO", image_repo)
|
.args(targets.iter().copied())
|
||||||
|
.env("IMAGE_REPO", image_repo()?)
|
||||||
.env("BUILD_VERSION", build_version)
|
.env("BUILD_VERSION", build_version)
|
||||||
.env("PUBLIC_ASSET_BASE_URL", public_asset_base_url)
|
.env("PUBLIC_ASSET_BASE_URL", public_asset_base_url)
|
||||||
.env(
|
.env(
|
||||||
@@ -303,6 +357,285 @@ fn validate_manifest_asset(asset: &str) -> Result<String> {
|
|||||||
Ok(asset.to_string())
|
Ok(asset.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn verify_published_assets_step() -> Result<()> {
|
||||||
|
let dist = app_dist_dir();
|
||||||
|
let manifest_path = dist.join("assets-manifest.txt");
|
||||||
|
let assets = read_asset_manifest(&manifest_path)?;
|
||||||
|
ensure!(!assets.is_empty(), "{} is empty", manifest_path.display());
|
||||||
|
|
||||||
|
let index_path = dist.join("index.html");
|
||||||
|
let index = fs::read_to_string(&index_path)
|
||||||
|
.with_context(|| format!("Failed to read {}", index_path.display()))?;
|
||||||
|
let unproduced = unproduced_references(&referenced_assets(&index), &assets);
|
||||||
|
ensure!(
|
||||||
|
unproduced.is_empty(),
|
||||||
|
"index.html references assets this build did not produce: {}",
|
||||||
|
unproduced.join(", ")
|
||||||
|
);
|
||||||
|
|
||||||
|
match env_string("PUBLIC_ASSET_BASE_URL") {
|
||||||
|
Some(base) => verify_remote_assets(&base, &assets).await,
|
||||||
|
None => verify_local_assets(&dist, &assets),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_local_assets(dist: &Path, assets: &[String]) -> Result<()> {
|
||||||
|
let missing = assets
|
||||||
|
.iter()
|
||||||
|
.filter(|asset| !dist.join(asset).is_file())
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
ensure!(
|
||||||
|
missing.is_empty(),
|
||||||
|
"Manifest assets are missing from {}: {}",
|
||||||
|
dist.display(),
|
||||||
|
missing.join(", ")
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"published asset verification passed - {} assets present in {}",
|
||||||
|
assets.len(),
|
||||||
|
dist.display()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify_remote_assets(base: &str, assets: &[String]) -> Result<()> {
|
||||||
|
let client = Client::builder()
|
||||||
|
.timeout(ASSET_READ_TIMEOUT)
|
||||||
|
.build()
|
||||||
|
.context("Failed to build the asset verification HTTP client")?;
|
||||||
|
let semaphore = Arc::new(Semaphore::new(ASSET_READ_CONCURRENCY));
|
||||||
|
let mut tasks = JoinSet::new();
|
||||||
|
for asset in assets {
|
||||||
|
let permit = semaphore
|
||||||
|
.clone()
|
||||||
|
.acquire_owned()
|
||||||
|
.await
|
||||||
|
.context("Asset verification semaphore closed")?;
|
||||||
|
let client = client.clone();
|
||||||
|
let url = asset_url(base, asset);
|
||||||
|
let asset = asset.clone();
|
||||||
|
tasks.spawn(async move {
|
||||||
|
let _permit = permit;
|
||||||
|
read_published_asset(&client, &url)
|
||||||
|
.await
|
||||||
|
.err()
|
||||||
|
.map(|error| format!("{asset}: {error}"))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut failures = Vec::new();
|
||||||
|
while let Some(result) = tasks.join_next().await {
|
||||||
|
if let Some(failure) = result.context("Asset verification task failed")? {
|
||||||
|
failures.push(failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
failures.sort();
|
||||||
|
ensure!(
|
||||||
|
failures.is_empty(),
|
||||||
|
"{} of {} published assets are not readable at {base}:\n{}",
|
||||||
|
failures.len(),
|
||||||
|
assets.len(),
|
||||||
|
failures.join("\n")
|
||||||
|
);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"published asset verification passed - {} assets readable at {base}",
|
||||||
|
assets.len()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_published_asset(client: &Client, url: &str) -> Result<()> {
|
||||||
|
let mut last_error = None;
|
||||||
|
for attempt in 1..=ASSET_READ_ATTEMPTS {
|
||||||
|
match client.get(url).header("range", "bytes=0-0").send().await {
|
||||||
|
Ok(response) if response.status().is_success() => return Ok(()),
|
||||||
|
Ok(response) => {
|
||||||
|
last_error = Some(anyhow!("{url} responded {}", response.status()));
|
||||||
|
}
|
||||||
|
Err(error) => last_error = Some(anyhow!("{url} request failed: {error}")),
|
||||||
|
}
|
||||||
|
if attempt < ASSET_READ_ATTEMPTS {
|
||||||
|
sleep(ASSET_READ_RETRY_DELAY).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(last_error.unwrap_or_else(|| anyhow!("{url} could not be read")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn asset_url(base: &str, key: &str) -> String {
|
||||||
|
format!("{}/{}", base.trim_end_matches('/'), key)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn referenced_assets(source: &str) -> Vec<String> {
|
||||||
|
const PREFIX: &str = "assets/";
|
||||||
|
let bytes = source.as_bytes();
|
||||||
|
let mut names = BTreeSet::new();
|
||||||
|
let mut cursor = 0;
|
||||||
|
while let Some(offset) = source[cursor..].find(PREFIX) {
|
||||||
|
let start = cursor + offset;
|
||||||
|
cursor = start + PREFIX.len();
|
||||||
|
if start > 0
|
||||||
|
&& !matches!(
|
||||||
|
bytes[start - 1],
|
||||||
|
b'/' | b'"' | b'\'' | b'=' | b'(' | b' ' | b'>'
|
||||||
|
)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = source[cursor..]
|
||||||
|
.chars()
|
||||||
|
.take_while(|value| {
|
||||||
|
value.is_ascii_alphanumeric() || matches!(value, '.' | '_' | '-' | '/')
|
||||||
|
})
|
||||||
|
.collect::<String>();
|
||||||
|
if !name.is_empty() && !name.ends_with('/') {
|
||||||
|
names.insert(format!("{PREFIX}{name}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names.into_iter().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unproduced_references(referenced: &[String], produced: &[String]) -> Vec<String> {
|
||||||
|
let produced = produced.iter().map(String::as_str).collect::<BTreeSet<_>>();
|
||||||
|
referenced
|
||||||
|
.iter()
|
||||||
|
.filter(|asset| !produced.contains(asset.as_str()))
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_asset_parity_step() -> Result<()> {
|
||||||
|
let root = runner_temp().join("app-proxy-asset-parity");
|
||||||
|
remove_dir_if_exists(&root)?;
|
||||||
|
|
||||||
|
let canonical_dir = extract_image_dir(
|
||||||
|
&require_env("APP_PROXY_ASSETS_REF")?,
|
||||||
|
&canonical_assets_platform(),
|
||||||
|
CANONICAL_ASSETS_DIR,
|
||||||
|
&root.join("canonical"),
|
||||||
|
)?;
|
||||||
|
let canonical = asset_tree_digests(&canonical_dir)?;
|
||||||
|
ensure!(
|
||||||
|
!canonical.is_empty(),
|
||||||
|
"Canonical asset tree is empty: {}",
|
||||||
|
canonical_dir.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
for (label, reference_env, platform) in [
|
||||||
|
("amd64", "APP_PROXY_AMD64_REF", AMD64_PLATFORM),
|
||||||
|
("arm64", "APP_PROXY_ARM64_REF", ARM64_PLATFORM),
|
||||||
|
] {
|
||||||
|
let runtime_dir = extract_image_dir(
|
||||||
|
&require_env(reference_env)?,
|
||||||
|
platform,
|
||||||
|
RUNTIME_STATIC_DIR,
|
||||||
|
&root.join(label),
|
||||||
|
)?;
|
||||||
|
let differences = tree_differences(&canonical, &asset_tree_digests(&runtime_dir)?);
|
||||||
|
ensure!(
|
||||||
|
differences.is_empty(),
|
||||||
|
"{label} app-proxy asset tree does not match the canonical dist:\n{}",
|
||||||
|
differences.join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"asset parity verified - {} files in every architecture",
|
||||||
|
canonical.len()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_image_dir(reference: &str, platform: &str, path: &str, dest: &Path) -> Result<PathBuf> {
|
||||||
|
run_command(pull_command(reference, platform))?;
|
||||||
|
let container = output_text(create_command(reference, platform))?;
|
||||||
|
ensure!(
|
||||||
|
!container.is_empty(),
|
||||||
|
"docker create returned no container id for {reference}"
|
||||||
|
);
|
||||||
|
if let Some(parent) = dest.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.with_context(|| format!("Failed to create {}", parent.display()))?;
|
||||||
|
}
|
||||||
|
let copied = run_command(copy_command(&container, path, dest));
|
||||||
|
let removed = run_command(CommandSpec::new("docker").args(["rm", "-f", container.as_str()]));
|
||||||
|
copied?;
|
||||||
|
removed?;
|
||||||
|
Ok(dest.to_path_buf())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pull_command(reference: &str, platform: &str) -> CommandSpec {
|
||||||
|
CommandSpec::new("docker").args(["pull", "--platform", platform, reference])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_command(reference: &str, platform: &str) -> CommandSpec {
|
||||||
|
CommandSpec::new("docker").args(["create", "--platform", platform, reference])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_command(container: &str, path: &str, dest: &Path) -> CommandSpec {
|
||||||
|
CommandSpec::new("docker")
|
||||||
|
.arg("cp")
|
||||||
|
.arg(format!("{container}:{path}"))
|
||||||
|
.arg(dest.as_os_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn asset_tree_digests(root: &Path) -> Result<BTreeMap<String, String>> {
|
||||||
|
collect_files(root)?
|
||||||
|
.into_iter()
|
||||||
|
.map(|path| {
|
||||||
|
let relative = path
|
||||||
|
.strip_prefix(root)
|
||||||
|
.with_context(|| format!("Failed to relativize {}", path.display()))?;
|
||||||
|
Ok((path_to_s3_key(relative), sha256_file(&path)?))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sha256_file(path: &Path) -> Result<String> {
|
||||||
|
let mut file =
|
||||||
|
File::open(path).with_context(|| format!("Failed to open {}", path.display()))?;
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
let mut buffer = [0u8; 64 * 1024];
|
||||||
|
loop {
|
||||||
|
let read = file
|
||||||
|
.read(&mut buffer)
|
||||||
|
.with_context(|| format!("Failed to read {}", path.display()))?;
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
hasher.update(&buffer[..read]);
|
||||||
|
}
|
||||||
|
Ok(hex::encode(hasher.finalize()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tree_differences(
|
||||||
|
canonical: &BTreeMap<String, String>,
|
||||||
|
other: &BTreeMap<String, String>,
|
||||||
|
) -> Vec<String> {
|
||||||
|
let mut differences = Vec::new();
|
||||||
|
for (path, digest) in canonical {
|
||||||
|
match other.get(path) {
|
||||||
|
None => differences.push(format!("missing: {path}")),
|
||||||
|
Some(actual) if actual != digest => {
|
||||||
|
differences.push(format!("content differs: {path}"))
|
||||||
|
}
|
||||||
|
Some(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for path in other.keys() {
|
||||||
|
if !canonical.contains_key(path) {
|
||||||
|
differences.push(format!("unexpected: {path}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if differences.len() > PARITY_DIFF_LIMIT {
|
||||||
|
let remaining = differences.len() - PARITY_DIFF_LIMIT;
|
||||||
|
differences.truncate(PARITY_DIFF_LIMIT);
|
||||||
|
differences.push(format!("...and {remaining} more differences"));
|
||||||
|
}
|
||||||
|
differences
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -589,6 +922,251 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn referenced_assets_collects_absolute_and_root_relative_urls() {
|
||||||
|
let index = concat!(
|
||||||
|
"<script src=\"https://fluxerstatic.com/assets/a.js\"></script>",
|
||||||
|
"<link rel=\"stylesheet\" href=\"/assets/b.css\">",
|
||||||
|
"<a href=\"assets/fonts-NOTICE.txt\">notice</a>",
|
||||||
|
"<script src=\"https://fluxerstatic.com/assets/a.js\"></script>",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
referenced_assets(index),
|
||||||
|
vec!["assets/a.js", "assets/b.css", "assets/fonts-NOTICE.txt"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn referenced_assets_ignores_lookalike_paths() {
|
||||||
|
assert!(
|
||||||
|
referenced_assets(
|
||||||
|
"<script src=\"/myassets/x.js\"></script><img src=\"/other/notassets/y.js\">"
|
||||||
|
)
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn referenced_assets_keeps_nested_asset_paths() {
|
||||||
|
assert_eq!(
|
||||||
|
referenced_assets("<script src=\"/assets/chunks/a.js\"></script>"),
|
||||||
|
vec!["assets/chunks/a.js"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unproduced_references_flags_assets_the_build_did_not_produce() {
|
||||||
|
let referenced = vec!["assets/a.js".to_string(), "assets/gone.js".to_string()];
|
||||||
|
let produced = vec!["assets/a.js".to_string()];
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
unproduced_references(&referenced, &produced),
|
||||||
|
vec!["assets/gone.js"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_differences_is_empty_for_identical_trees() {
|
||||||
|
let tree = BTreeMap::from([
|
||||||
|
("index.html".to_string(), "aa".to_string()),
|
||||||
|
("assets/a.js".to_string(), "bb".to_string()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert!(tree_differences(&tree, &tree.clone()).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_differences_reports_missing_unexpected_and_changed() {
|
||||||
|
let canonical = BTreeMap::from([
|
||||||
|
("assets/a.js".to_string(), "aa".to_string()),
|
||||||
|
("assets/b.js".to_string(), "bb".to_string()),
|
||||||
|
]);
|
||||||
|
let other = BTreeMap::from([
|
||||||
|
("assets/a.js".to_string(), "changed".to_string()),
|
||||||
|
("assets/c.js".to_string(), "cc".to_string()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
tree_differences(&canonical, &other),
|
||||||
|
vec![
|
||||||
|
"content differs: assets/a.js",
|
||||||
|
"missing: assets/b.js",
|
||||||
|
"unexpected: assets/c.js",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_differences_caps_the_reported_lines() {
|
||||||
|
let canonical = (0..50)
|
||||||
|
.map(|index| (format!("assets/{index}.js"), "aa".to_string()))
|
||||||
|
.collect::<BTreeMap<_, _>>();
|
||||||
|
let other = BTreeMap::new();
|
||||||
|
|
||||||
|
let differences = tree_differences(&canonical, &other);
|
||||||
|
|
||||||
|
assert_eq!(differences.len(), PARITY_DIFF_LIMIT + 1);
|
||||||
|
assert_eq!(
|
||||||
|
differences.last().unwrap(),
|
||||||
|
&format!("...and {} more differences", 50 - PARITY_DIFF_LIMIT)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn asset_tree_digests_hashes_every_file_relative_to_the_root() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let root = temp.path().join("static");
|
||||||
|
write_file(&root.join("index.html"), "index");
|
||||||
|
write_file(&root.join("assets/chunks/a.js"), "a");
|
||||||
|
|
||||||
|
let digests = asset_tree_digests(&root).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
digests.keys().cloned().collect::<Vec<_>>(),
|
||||||
|
vec!["assets/chunks/a.js", "index.html"]
|
||||||
|
);
|
||||||
|
|
||||||
|
write_file(&root.join("assets/chunks/a.js"), "b");
|
||||||
|
let changed = asset_tree_digests(&root).unwrap();
|
||||||
|
assert_ne!(digests["assets/chunks/a.js"], changed["assets/chunks/a.js"]);
|
||||||
|
assert_eq!(digests["index.html"], changed["index.html"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn asset_url_joins_the_base_and_key_once() {
|
||||||
|
assert_eq!(
|
||||||
|
asset_url("https://fluxerstatic.com", "assets/a.js"),
|
||||||
|
"https://fluxerstatic.com/assets/a.js"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
asset_url("https://fluxerstatic.com/", "assets/a.js"),
|
||||||
|
"https://fluxerstatic.com/assets/a.js"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn assets_image_ref_names_the_canonical_image() {
|
||||||
|
assert_eq!(
|
||||||
|
assets_image_ref("ghcr.io/example/fluxer-app-proxy", "2026.520.1"),
|
||||||
|
"ghcr.io/example/fluxer-app-proxy:2026.520.1-assets"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_commands_pin_the_image_platform() {
|
||||||
|
let pull = pull_command("ghcr.io/example/fluxer-app-proxy:1-assets", AMD64_PLATFORM);
|
||||||
|
assert_eq!(pull.program, OsString::from("docker"));
|
||||||
|
assert_eq!(
|
||||||
|
pull.args,
|
||||||
|
vec![
|
||||||
|
OsString::from("pull"),
|
||||||
|
OsString::from("--platform"),
|
||||||
|
OsString::from(AMD64_PLATFORM),
|
||||||
|
OsString::from("ghcr.io/example/fluxer-app-proxy:1-assets"),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
let create = create_command("ghcr.io/example/fluxer-app-proxy:1-arm64", ARM64_PLATFORM);
|
||||||
|
assert_eq!(
|
||||||
|
create.args,
|
||||||
|
vec![
|
||||||
|
OsString::from("create"),
|
||||||
|
OsString::from("--platform"),
|
||||||
|
OsString::from(ARM64_PLATFORM),
|
||||||
|
OsString::from("ghcr.io/example/fluxer-app-proxy:1-arm64"),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
let copy = copy_command("cafe1234", RUNTIME_STATIC_DIR, Path::new("/tmp/arm64"));
|
||||||
|
assert_eq!(
|
||||||
|
copy.args,
|
||||||
|
vec![
|
||||||
|
OsString::from("cp"),
|
||||||
|
OsString::from("cafe1234:/srv/app/static"),
|
||||||
|
OsString::from("/tmp/arm64"),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dockerfile_injects_one_canonical_asset_tree_into_every_architecture() {
|
||||||
|
let dockerfile = include_str!("../../../fluxer_app_proxy/Dockerfile");
|
||||||
|
for entry in [
|
||||||
|
"ARG APP_ASSETS_REF=app-assets",
|
||||||
|
"ARG APP_ASSETS_PLATFORM=$BUILDPLATFORM",
|
||||||
|
"FROM --platform=${APP_ASSETS_PLATFORM} ${APP_ASSETS_REF} AS app-static",
|
||||||
|
"COPY --from=app-static /assets ./static/",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
dockerfile.contains(entry),
|
||||||
|
"every architecture must serve the injected canonical tree, so the Dockerfile must carry {entry}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dockerfile_prepares_the_asset_tree_once_before_the_architecture_stages() {
|
||||||
|
let dockerfile = include_str!("../../../fluxer_app_proxy/Dockerfile");
|
||||||
|
let canonical = dockerfile
|
||||||
|
.split("FROM alpine:3.21 AS app-assets")
|
||||||
|
.nth(1)
|
||||||
|
.expect("app-assets stage");
|
||||||
|
let (canonical, per_architecture) = canonical
|
||||||
|
.split_once("AS app-static")
|
||||||
|
.expect("app-static stage");
|
||||||
|
|
||||||
|
for entry in ["apk add --no-cache brotli", "precompress_assets.sh"] {
|
||||||
|
assert!(
|
||||||
|
canonical.contains(entry),
|
||||||
|
"the canonical asset tree is prepared once, so {entry} must run in the app-assets stage"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!per_architecture.contains(entry),
|
||||||
|
"{entry} must not run again per architecture or the trees stop being byte-identical"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bake_publishes_the_canonical_asset_image() {
|
||||||
|
let bake = include_str!("../../../fluxer_app_proxy/docker-bake.hcl");
|
||||||
|
for entry in [
|
||||||
|
"target \"app-assets-image\"",
|
||||||
|
"${BUILD_VERSION}-assets",
|
||||||
|
"type=registry",
|
||||||
|
"APP_ASSETS_REF = APP_ASSETS_REF",
|
||||||
|
"APP_ASSETS_PLATFORM = APP_ASSETS_PLATFORM",
|
||||||
|
] {
|
||||||
|
assert!(bake.contains(entry), "docker-bake.hcl must carry {entry}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canonical_assets_platform_falls_back_to_the_bake_default() {
|
||||||
|
let bake = include_str!("../../../fluxer_app_proxy/docker-bake.hcl");
|
||||||
|
let declared =
|
||||||
|
format!("variable \"APP_ASSETS_PLATFORM\" {{ default = \"{AMD64_PLATFORM}\" }}");
|
||||||
|
assert!(
|
||||||
|
bake.contains(&declared),
|
||||||
|
"the parity gate reads APP_ASSETS_PLATFORM and falls back to {AMD64_PLATFORM}, so docker-bake.hcl must declare the same default"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn self_hosted_workflow_keeps_assets_same_origin() {
|
||||||
|
let workflow = include_str!("../../../.github/workflows/build-app-proxy-self-hosted.yaml");
|
||||||
|
for entry in [
|
||||||
|
"PUBLIC_ASSET_BASE_URL: \"\"",
|
||||||
|
"BUNDLE_LOCAL_ASSETS: \"true\"",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
workflow.contains(entry),
|
||||||
|
"a self-hosted dist that inherits the hosted CDN default would ship index.html pointing at fluxerstatic.com, so the workflow must set {entry}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn path_to_s3_key_uses_forward_slashes() {
|
fn path_to_s3_key_uses_forward_slashes() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
Reference in New Issue
Block a user