feat(startos): welcome a server with the release notes it updated to … #5720
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: Debian-based ISO and SquashFS | |
| on: | |
| workflow_call: | |
| workflow_dispatch: | |
| inputs: | |
| # `default:` is not decoration — the UI preselects the first option, but an API | |
| # dispatch (`gh workflow run`) that omits an input delivers it as the empty | |
| # string, and only a declared default fills it in. | |
| environment: | |
| type: choice | |
| description: Environment | |
| default: NONE | |
| options: | |
| - NONE | |
| - dev | |
| - unstable | |
| - dev-unstable | |
| runner: | |
| type: choice | |
| description: Runner | |
| default: standard | |
| options: | |
| - standard | |
| - fast | |
| platform: | |
| type: choice | |
| description: Platform | |
| default: ALL | |
| options: | |
| - ALL | |
| - x86_64 | |
| - x86_64-nonfree | |
| - x86_64-nvidia | |
| - aarch64 | |
| - aarch64-nonfree | |
| - aarch64-nvidia | |
| - raspberrypi | |
| - riscv64 | |
| - riscv64-nonfree | |
| deploy: | |
| type: choice | |
| description: Deploy | |
| default: NONE | |
| options: | |
| - NONE | |
| - alpha | |
| - beta | |
| force-overwrite: | |
| type: boolean | |
| description: Replace S3 objects that already exist for this commit | |
| default: false | |
| # `paths:` below mirror projects/start-os/build.mk's build inputs — keep both | |
| # in sync (see root AGENTS.md "Coupled changes"). | |
| push: | |
| branches: | |
| - master | |
| - integration/* | |
| paths: | |
| - 'projects/start-os/**' | |
| - 'shared-libs/**' | |
| - 'projects/start-sdk/**' | |
| - 'Cargo.toml' | |
| - 'Cargo.lock' | |
| - 'angular.json' | |
| - 'package.json' | |
| - 'package-lock.json' | |
| - 'tsconfig.json' | |
| - 'tsconfig.lib.json' | |
| - 'build/**' | |
| - 'debian/**' | |
| - 'apt/**' | |
| - '.github/workflows/startos-iso.yaml' | |
| - '.github/actions/setup-build/**' | |
| # Doc-only edits under the allowlisted project trees (e.g. | |
| # projects/start-os/docs/**) shouldn't trigger a build. | |
| - '!**/*.md' | |
| # Except the release notes, which are packaged into the OS image. | |
| - 'projects/start-os/release-notes/**' | |
| pull_request: | |
| # The jobs below skip drafts, and `ready_for_review` is not a default | |
| # activity type, so without it a draft marked ready is never checked. | |
| types: [opened, synchronize, reopened, ready_for_review] | |
| branches: | |
| - master | |
| - integration/* | |
| paths: | |
| - 'projects/start-os/**' | |
| - 'shared-libs/**' | |
| - 'projects/start-sdk/**' | |
| - 'Cargo.toml' | |
| - 'Cargo.lock' | |
| - 'angular.json' | |
| - 'package.json' | |
| - 'package-lock.json' | |
| - 'tsconfig.json' | |
| - 'tsconfig.lib.json' | |
| - 'build/**' | |
| - 'debian/**' | |
| - 'apt/**' | |
| - '.github/workflows/startos-iso.yaml' | |
| - '.github/actions/setup-build/**' | |
| # Doc-only edits under the allowlisted project trees (e.g. | |
| # projects/start-os/docs/**) shouldn't trigger a build. | |
| - '!**/*.md' | |
| # Except the release notes, which are packaged into the OS image. | |
| - 'projects/start-os/release-notes/**' | |
| # The group is scoped by event so a master push can't cancel an in-flight | |
| # dispatched release build. Only pull requests cancel: a push to master | |
| # publishes to alpha, so master pushes queue behind each other rather than | |
| # aborting a publish half-done. | |
| concurrency: | |
| group: startos-iso-${{ github.event_name }}-${{ github.head_ref || github.ref }} | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| env: | |
| NODEJS_VERSION: '24.17.0' | |
| # Empty (release-equivalent) on master, `dev` everywhere else a build happens | |
| # without an explicit input — PRs and integration/* branches, where password | |
| # SSH before setup and uncompressed frontends are what you want. | |
| # | |
| # master must NOT be dev: alpha is the source registry that beta and then | |
| # production are promoted from, and `os promote` copies each asset's URL and | |
| # commitment through verbatim. Whatever a master push builds is what ships. | |
| ENVIRONMENT: >- | |
| ${{ | |
| fromJson(format('["{0}", ""]', github.event.inputs.environment || 'dev'))[ | |
| github.event.inputs.environment == 'NONE' | |
| || (github.event_name == 'push' && github.ref == 'refs/heads/master') | |
| ] | |
| }} | |
| jobs: | |
| compile: | |
| name: Compile Base Binaries | |
| if: github.event.pull_request.draft != true | |
| strategy: | |
| fail-fast: true | |
| matrix: | |
| arch: >- | |
| ${{ | |
| fromJson('{ | |
| "x86_64": ["x86_64"], | |
| "x86_64-nonfree": ["x86_64"], | |
| "x86_64-nvidia": ["x86_64"], | |
| "aarch64": ["aarch64"], | |
| "aarch64-nonfree": ["aarch64"], | |
| "aarch64-nvidia": ["aarch64"], | |
| "raspberrypi": ["aarch64"], | |
| "riscv64": ["riscv64"], | |
| "riscv64-nonfree": ["riscv64"], | |
| "ALL": ["x86_64", "aarch64", "riscv64"] | |
| }')[github.event.inputs.platform || 'ALL'] | |
| }} | |
| runs-on: >- | |
| ${{ | |
| fromJson( | |
| format( | |
| '["{0}", "{1}"]', | |
| fromJson('{ | |
| "x86_64": "ubuntu-latest", | |
| "aarch64": "ubuntu-24.04-arm", | |
| "riscv64": "ubuntu-latest" | |
| }')[matrix.arch], | |
| fromJson('{ | |
| "x86_64": "amd64-fast", | |
| "aarch64": "aarch64-fast", | |
| "riscv64": "amd64-fast" | |
| }')[matrix.arch] | |
| ) | |
| )[github.event.inputs.runner == 'fast'] | |
| }} | |
| steps: | |
| - name: Mount tmpfs | |
| if: ${{ github.event.inputs.runner == 'fast' }} | |
| run: sudo mount -t tmpfs tmpfs . | |
| - uses: actions/checkout@v6 | |
| - uses: ./.github/actions/setup-build | |
| with: | |
| nodejs-version: ${{ env.NODEJS_VERSION }} | |
| setup-python: 'true' | |
| - name: Make | |
| run: make ARCH=${{ matrix.arch }} compiled-${{ matrix.arch }}.tar | |
| env: | |
| SCCACHE_GHA_ENABLED: on | |
| SCCACHE_GHA_VERSION: 0 | |
| - uses: actions/upload-artifact@v7 | |
| with: | |
| name: compiled-${{ matrix.arch }}.tar | |
| path: compiled-${{ matrix.arch }}.tar | |
| retention-days: 1 | |
| # On pull requests, only build the (expensive, ~2h with emulated riscv) image | |
| # matrix when the change actually affects image assembly — packaging, image | |
| # recipe, systemd units, apt, or shared build infra. Pure code/web/docs PRs are | |
| # already covered by `compile` (the binaries) and the Automated Tests workflow, | |
| # so they skip images here and get full image coverage on the push to master. | |
| # The exception is an integration/* branch merging into master: that PR is the | |
| # gate before the batch ships, so it always gets the full testable image set. | |
| # On push/dispatch the guard below is bypassed and images always build. | |
| changes: | |
| name: Detect image-affecting changes | |
| if: github.event_name == 'pull_request' && github.event.pull_request.draft != true | |
| runs-on: ubuntu-latest | |
| permissions: | |
| pull-requests: read | |
| outputs: | |
| image: ${{ steps.filter.outputs.image }} | |
| steps: | |
| - name: Check changed paths | |
| id: filter | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| BASE_REF: ${{ github.base_ref }} | |
| HEAD_REF: ${{ github.head_ref }} | |
| HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} | |
| run: | | |
| # An integration/* branch merging into master is the last gate before | |
| # the batch ships — build every platform image so reviewers can flash | |
| # and test it, whatever paths it happens to touch. | |
| # | |
| # head_ref is only a branch *name*, which a fork controls freely, so | |
| # the branch must also live in this repo. Otherwise anyone could spend | |
| # the full image matrix by naming a fork branch integration/anything. | |
| if [ "$BASE_REF" = master ] \ | |
| && [ "$HEAD_REPO" = "${{ github.repository }}" ] \ | |
| && [ "${HEAD_REF#integration/}" != "$HEAD_REF" ]; then | |
| echo "$HEAD_REF -> master: building the full image matrix." | |
| echo "image=true" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| files="$(gh api --paginate \ | |
| "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" \ | |
| --jq '.[].filename')" | |
| matched="$(printf '%s\n' "$files" | grep -E '^(build/|debian/|apt/|projects/start-os/build/|projects/start-os/build\.mk$|projects/start-os/debian/|projects/start-os/release-notes/|projects/start-os/[^/]+\.(service|slice)$|\.github/workflows/startos-iso\.yaml$|\.github/actions/setup-build/)' || true)" | |
| if [ -n "$matched" ]; then | |
| echo "Image-affecting files changed:"; printf ' %s\n' "$matched" | |
| echo "image=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "No image-affecting files changed; skipping image build on this PR." | |
| echo "image=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| image: | |
| name: Build Image | |
| needs: [compile, changes] | |
| # Build on push/dispatch always; on PRs only when packaging/image inputs changed. | |
| if: >- | |
| !cancelled() && needs.compile.result == 'success' | |
| && (github.event_name != 'pull_request' || needs.changes.outputs.image == 'true') | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| platform: >- | |
| ${{ | |
| fromJson( | |
| format( | |
| '[ | |
| ["{0}"], | |
| ["x86_64", "x86_64-nonfree", "x86_64-nvidia", "aarch64", "aarch64-nonfree", "aarch64-nvidia", "raspberrypi", "riscv64", "riscv64-nonfree"] | |
| ]', | |
| github.event.inputs.platform || 'ALL' | |
| ) | |
| )[(github.event.inputs.platform || 'ALL') == 'ALL'] | |
| }} | |
| runs-on: >- | |
| ${{ | |
| fromJson( | |
| format( | |
| '["{0}", "{1}"]', | |
| fromJson('{ | |
| "x86_64": "ubuntu-latest", | |
| "x86_64-nonfree": "ubuntu-latest", | |
| "x86_64-nvidia": "ubuntu-latest", | |
| "aarch64": "ubuntu-24.04-arm", | |
| "aarch64-nonfree": "ubuntu-24.04-arm", | |
| "aarch64-nvidia": "ubuntu-24.04-arm", | |
| "raspberrypi": "ubuntu-24.04-arm", | |
| "riscv64": "ubuntu-24.04-arm", | |
| "riscv64-nonfree": "ubuntu-24.04-arm", | |
| }')[matrix.platform], | |
| fromJson('{ | |
| "x86_64": "amd64-fast", | |
| "x86_64-nonfree": "amd64-fast", | |
| "x86_64-nvidia": "amd64-fast", | |
| "aarch64": "aarch64-fast", | |
| "aarch64-nonfree": "aarch64-fast", | |
| "aarch64-nvidia": "aarch64-fast", | |
| "raspberrypi": "aarch64-fast", | |
| "riscv64": "amd64-fast", | |
| "riscv64-nonfree": "amd64-fast", | |
| }')[matrix.platform] | |
| ) | |
| )[github.event.inputs.runner == 'fast'] | |
| }} | |
| env: | |
| ARCH: >- | |
| ${{ | |
| fromJson('{ | |
| "x86_64": "x86_64", | |
| "x86_64-nonfree": "x86_64", | |
| "x86_64-nvidia": "x86_64", | |
| "aarch64": "aarch64", | |
| "aarch64-nonfree": "aarch64", | |
| "aarch64-nvidia": "aarch64", | |
| "raspberrypi": "aarch64", | |
| "riscv64": "riscv64", | |
| "riscv64-nonfree": "riscv64", | |
| }')[matrix.platform] | |
| }} | |
| steps: | |
| - name: Free space | |
| run: | | |
| sudo apt-get remove --purge -y azure-cli || true | |
| sudo apt-get remove --purge -y firefox || true | |
| sudo apt-get remove --purge -y ghc-* || true | |
| sudo apt-get remove --purge -y google-cloud-sdk || true | |
| sudo apt-get remove --purge -y google-chrome-stable || true | |
| sudo apt-get remove --purge -y powershell || true | |
| sudo apt-get remove --purge -y php* || true | |
| sudo apt-get remove --purge -y ruby* || true | |
| sudo apt-get remove --purge -y mono-* || true | |
| sudo apt-get autoremove -y | |
| sudo apt-get clean | |
| sudo rm -rf /usr/lib/jvm # All JDKs | |
| sudo rm -rf /usr/local/.ghcup # Haskell toolchain | |
| sudo rm -rf /usr/local/lib/android # Android SDK/NDK, emulator | |
| sudo rm -rf /usr/share/dotnet # .NET SDKs | |
| sudo rm -rf /usr/share/swift # Swift toolchain (if present) | |
| sudo rm -rf "$AGENT_TOOLSDIRECTORY" # Pre-cached tool cache (Go, Node, etc.) | |
| if: ${{ github.event.inputs.runner != 'fast' }} | |
| # Some runners lack /opt/hostedtoolcache, which setup-qemu expects | |
| - name: Ensure hostedtoolcache exists | |
| run: sudo mkdir -p /opt/hostedtoolcache && sudo chown $USER:$USER /opt/hostedtoolcache | |
| - name: Set up docker QEMU | |
| uses: docker/setup-qemu-action@v4 | |
| - uses: actions/checkout@v6 | |
| - name: Download compiled artifacts | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: compiled-${{ env.ARCH }}.tar | |
| - name: Extract compiled artifacts | |
| run: tar -xvf compiled-${{ env.ARCH }}.tar | |
| - name: Prevent rebuild of compiled artifacts | |
| run: | | |
| mkdir -p node_modules | |
| mkdir -p projects/start-os/web/dist/raw | |
| mkdir -p projects/start-tunnel/web/dist/raw | |
| mkdir -p shared-libs/crates/start-core/bindings | |
| mkdir -p shared-libs/ts-modules/start-core/lib/osBindings | |
| mkdir -p projects/start-os/container-runtime/node_modules | |
| mkdir -p projects/start-os/container-runtime/dist | |
| mkdir -p projects/start-os/container-runtime/dist/node_modules | |
| mkdir -p projects/start-sdk/dist | |
| mkdir -p shared-libs/ts-modules/start-core/dist | |
| mkdir -p shared-libs/crates/patch-db/client/node_modules | |
| mkdir -p shared-libs/crates/patch-db/client/dist | |
| mkdir -p .angular | |
| mkdir -p projects/start-os/web/dist/raw/ui | |
| mkdir -p projects/start-os/web/dist/raw/setup-wizard | |
| mkdir -p projects/start-os/web/dist/static/ui | |
| mkdir -p projects/start-os/web/dist/static/setup-wizard | |
| PLATFORM=${{ matrix.platform }} make -t compiled-${{ env.ARCH }}.tar | |
| - run: git status | |
| - name: Run iso build | |
| run: PLATFORM=${{ matrix.platform }} make start-os-iso | |
| if: ${{ matrix.platform != 'raspberrypi' }} | |
| - name: Run img build | |
| run: PLATFORM=${{ matrix.platform }} make start-os-img | |
| if: ${{ matrix.platform == 'raspberrypi' }} | |
| - uses: actions/upload-artifact@v7 | |
| with: | |
| name: ${{ matrix.platform }}.squashfs | |
| path: results/*.squashfs | |
| retention-days: 14 | |
| - uses: actions/upload-artifact@v7 | |
| with: | |
| name: ${{ matrix.platform }}.iso | |
| path: results/*.iso | |
| retention-days: 14 | |
| if: ${{ matrix.platform != 'raspberrypi' }} | |
| - uses: actions/upload-artifact@v7 | |
| with: | |
| name: ${{ matrix.platform }}.img | |
| path: results/*.img | |
| retention-days: 14 | |
| if: ${{ matrix.platform == 'raspberrypi' }} | |
| # The OTA payload legacy (0.3.5.1) boxes pull via their frozen rsync updater: | |
| # the 0.4.0 base squashfs (built just above) repackaged onto the published | |
| # 0.3.5.1 rootfs. Runs automatically after every image build, in the same run, | |
| # consuming that run's <platform>.squashfs artifact. Pure repackaging (no OS | |
| # compile), so it runs on x86_64 for every platform. Only platforms that | |
| # shipped in 0.3.5.1 (no nvidia/riscv); raspberrypi requires a reflash (#3443). | |
| # The <platform>.migration.squashfs artifact is what ops publishes to the | |
| # frozen registry as the version's OTA image. See | |
| # projects/start-os/build/assemble-migration-payload.sh and PR #3399. | |
| migration-image: | |
| name: Migration Image | |
| needs: [image] | |
| if: ${{ !cancelled() && needs.image.result == 'success' }} | |
| runs-on: ubuntu-latest | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| platform: [x86_64, x86_64-nonfree, aarch64, aarch64-nonfree] | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| MIGRATION_FROM_TAG: v0.3.5.1 | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Fetch the 0.4.0 base squashfs (this run's image build) | |
| id: base | |
| continue-on-error: true | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: ${{ matrix.platform }}.squashfs | |
| path: base-new | |
| - name: Skip platforms not built this run | |
| id: check | |
| run: | | |
| if ls base-new/*.squashfs >/dev/null 2>&1; then | |
| echo "found=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "::warning::No ${{ matrix.platform }}.squashfs built this run — skipping." | |
| echo "found=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Fetch the legacy (0.3.5.1) base image | |
| if: steps.check.outputs.found == 'true' | |
| run: | | |
| mkdir -p base-old | |
| gh release download "$MIGRATION_FROM_TAG" --repo "${{ github.repository }}" \ | |
| --pattern "startos-*_${{ matrix.platform }}.iso" --dir base-old | |
| # Runs in start9/build-env (the canonical Start9 build image — already has | |
| # the squashfs toolchain + b3sum) so unsquashfs/mksquashfs can restore the | |
| # rootfs's ownership + device nodes. Only xorriso (to crack the squashfs out | |
| # of the 0.3.5.1 ISO) is missing, so we add just that. Not --privileged: | |
| # creating device nodes needs only CAP_MKNOD, in docker's default caps. | |
| - name: Assemble migration payload | |
| if: steps.check.outputs.found == 'true' | |
| run: | | |
| ARCH=$(printf '%s' "${{ matrix.platform }}" | sed 's/-nonfree$//') | |
| NEW=$(ls base-new/*.squashfs | head -n1) | |
| OLD=$(ls base-old/startos-*_${{ matrix.platform }}.iso | head -n1) | |
| mkdir -p results | |
| OUT="results/$(basename "${NEW%.squashfs}").migration.squashfs" | |
| docker run --rm -v "$PWD":/w -w /w \ | |
| -e ARCH="$ARCH" -e NEW="$NEW" -e OLD="$OLD" -e OUT="$OUT" \ | |
| -e OWNER_UID="$(id -u)" -e OWNER_GID="$(id -g)" \ | |
| start9/build-env bash -euc ' | |
| apt-get update -qq | |
| apt-get install -yq --no-install-recommends xorriso | |
| projects/start-os/build/assemble-migration-payload.sh \ | |
| --arch "$ARCH" --new-squashfs "$NEW" --old-image "$OLD" --out "$OUT" | |
| ' | |
| echo "Built $OUT" | |
| - uses: actions/upload-artifact@v7 | |
| if: steps.check.outputs.found == 'true' | |
| with: | |
| name: ${{ matrix.platform }}.migration.squashfs | |
| path: results/*.migration.squashfs | |
| retention-days: 14 | |
| deploy: | |
| name: Deploy | |
| needs: [image] | |
| # Explicit status check so the skipped PR-only `changes` job can't poison | |
| # the implicit success() through the needs chain (actions/runner#2205). | |
| # Two ways in: a push to master, which continuously deploys to alpha (master | |
| # is only ever merged to when a change is already tested and shippable), or a | |
| # dispatch naming a channel. | |
| # Allowlist the channels rather than excluding NONE: a `!= 'NONE'` gate also admits | |
| # an empty deploy input, which uploaded a branch build to the public bucket and then | |
| # died on the empty REGISTRY below. Keep in sync with the REGISTRY map. | |
| if: >- | |
| !cancelled() && needs.image.result == 'success' | |
| && ( | |
| (github.event_name == 'push' && github.ref == 'refs/heads/master') | |
| || ( | |
| github.event_name == 'workflow_dispatch' | |
| && contains(fromJson('["alpha", "beta"]'), github.event.inputs.deploy) | |
| ) | |
| ) | |
| runs-on: ubuntu-latest | |
| # One lock for every deploy, not one per channel. The workflow-level group is | |
| # scoped by event, so a dispatch could otherwise run alongside a master-push | |
| # deploy; and alpha and beta are not independent either — both write the same | |
| # `v$VERSION/` S3 keys for a given commit, so two channels deploying it at | |
| # once could both pass the overwrite check below and then race the same | |
| # object. | |
| concurrency: | |
| group: startos-deploy | |
| cancel-in-progress: false | |
| env: | |
| # A master push is always alpha; a dispatch deploys to the channel it named. | |
| CHANNEL: ${{ github.event_name == 'push' && 'alpha' || github.event.inputs.deploy }} | |
| REGISTRY: >- | |
| ${{ | |
| fromJson('{ | |
| "alpha": "https://alpha-registry-x.start9.com", | |
| "beta": "https://beta-registry.start9.com" | |
| }')[github.event_name == 'push' && 'alpha' || github.event.inputs.deploy] | |
| }} | |
| S3_BUCKET: s3://startos-images | |
| S3_CDN: https://startos-images.nyc3.cdn.digitaloceanspaces.com | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| sparse-checkout: | | |
| scripts | |
| projects/start-os/release-notes | |
| - name: Determine version | |
| id: version | |
| run: | | |
| VERSION=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' package.json | head -1) | |
| echo "version=$VERSION" >> "$GITHUB_OUTPUT" | |
| echo "Version: $VERSION" | |
| - name: Determine platforms | |
| id: platforms | |
| run: | | |
| echo "Deploying to ${{ env.CHANNEL }} (${{ env.REGISTRY }})" | |
| # A push carries no inputs; the image matrix defaults to ALL there too. | |
| INPUT="${{ github.event.inputs.platform || 'ALL' }}" | |
| if [ "$INPUT" = "ALL" ]; then | |
| PLATFORMS="x86_64 x86_64-nonfree x86_64-nvidia aarch64 aarch64-nonfree aarch64-nvidia raspberrypi riscv64 riscv64-nonfree" | |
| else | |
| PLATFORMS="$INPUT" | |
| fi | |
| echo "list=$PLATFORMS" >> "$GITHUB_OUTPUT" | |
| echo "Platforms: $PLATFORMS" | |
| - name: Download squashfs artifacts | |
| uses: actions/download-artifact@v8 | |
| with: | |
| pattern: '*.squashfs' | |
| path: artifacts/ | |
| merge-multiple: true | |
| - name: Download ISO artifacts | |
| uses: actions/download-artifact@v8 | |
| with: | |
| pattern: '*.iso' | |
| path: artifacts/ | |
| merge-multiple: true | |
| - name: Download IMG artifacts | |
| uses: actions/download-artifact@v8 | |
| with: | |
| pattern: '*.img' | |
| path: artifacts/ | |
| merge-multiple: true | |
| - name: Install start-cli | |
| run: | | |
| ARCH=$(uname -m) | |
| OS=$(uname -s | tr '[:upper:]' '[:lower:]') | |
| ASSET_NAME="start-cli_${ARCH}-${OS}" | |
| DOWNLOAD_URL=$(curl -fsS \ | |
| -H "Authorization: token ${{ github.token }}" \ | |
| https://api.github.com/repos/Start9Labs/start-technologies/releases \ | |
| | jq -r '[.[].assets[] | select(.name=="'"$ASSET_NAME"'")] | first | .browser_download_url') | |
| curl -fsSL \ | |
| -H "Authorization: token ${{ github.token }}" \ | |
| -H "Accept: application/octet-stream" \ | |
| "$DOWNLOAD_URL" -o /tmp/start-cli | |
| sudo install -m 755 /tmp/start-cli /usr/local/bin/start-cli | |
| echo "start-cli: $(start-cli --version)" | |
| - name: Configure S3 | |
| run: | | |
| sudo apt-get install -y -qq s3cmd > /dev/null | |
| cat > ~/.s3cfg <<EOF | |
| [default] | |
| access_key = ${{ secrets.S3_ACCESS_KEY }} | |
| secret_key = ${{ secrets.S3_SECRET_KEY }} | |
| host_base = nyc3.digitaloceanspaces.com | |
| host_bucket = %(bucket)s.nyc3.digitaloceanspaces.com | |
| use_https = True | |
| EOF | |
| - name: Set up developer key | |
| run: | | |
| mkdir -p ~/.startos | |
| printf '%s' "${{ secrets.DEV_KEY }}" > ~/.startos/developer.key.pem | |
| # An existing object under one of these keys is a hard stop. Basenames are | |
| # commit-derived, so a key that already exists means this commit was | |
| # already published — and since `os promote` copies asset URLs through | |
| # verbatim, beta or production commitments may already point at those | |
| # exact bytes. Replacing them would leave those signatures validating | |
| # against content that no longer matches. Re-dispatch with | |
| # force-overwrite when replacing is genuinely what you want, such as | |
| # re-running a deploy that failed partway through. | |
| - name: Upload to S3 | |
| env: | |
| FORCE_OVERWRITE: ${{ github.event.inputs.force-overwrite == 'true' }} | |
| run: | | |
| VERSION="${{ steps.version.outputs.version }}" | |
| cd artifacts | |
| conflicts=0 | |
| for PLATFORM in ${{ steps.platforms.outputs.list }}; do | |
| for file in *_${PLATFORM}.squashfs *_${PLATFORM}.iso *_${PLATFORM}.img; do | |
| [ -f "$file" ] || continue | |
| key="${{ env.S3_BUCKET }}/v${VERSION}/$file" | |
| if [ "$FORCE_OVERWRITE" != true ] && [ -n "$(s3cmd ls "$key")" ]; then | |
| echo "::error::$key already exists — this commit has been published before. Overwriting it would invalidate any beta or production commitment pointing at that URL. Re-dispatch with force-overwrite to replace it deliberately." | |
| conflicts=$((conflicts + 1)) | |
| continue | |
| fi | |
| echo "Uploading $file..." | |
| s3cmd put -P "$file" "$key" | |
| done | |
| done | |
| # Report every conflict before failing, rather than one per re-run. | |
| [ "$conflicts" -eq 0 ] || exit 1 | |
| # `registry os asset add` upserts the platform slot but rejects a second | |
| # write whose blake3 differs from the one already committed there | |
| # (asset/add.rs "commitment-mismatch"), so re-indexing a version that | |
| # already holds a build fails. Alpha deliberately rolls the same version | |
| # number on every master push, so the version entry has to be dropped | |
| # first. Beta never republishes in place and is left alone. | |
| # | |
| # Removing the version drops *every* platform indexed under it, so this is | |
| # gated to full-matrix deploys — which every master push is. A dispatch | |
| # naming a single platform deliberately skips it rather than silently | |
| # wiping the other eight; if that version already holds a build, the index | |
| # step below fails on the commitment mismatch, which is the honest outcome. | |
| - name: Drop the previous alpha build of this version | |
| if: ${{ env.CHANNEL == 'alpha' && (github.event.inputs.platform || 'ALL') == 'ALL' }} | |
| run: | | |
| VERSION="${{ steps.version.outputs.version }}" | |
| start-cli --registry="${{ env.REGISTRY }}" registry os version remove "$VERSION" \ | |
| || echo "::notice::v${VERSION} was not indexed in ${{ env.REGISTRY }} yet." | |
| # The notes are what the update screen shows before a server updates; | |
| # manage-release.sh composes them from this version's release-notes file | |
| # and appends the changelog link, pinned to the commit that built this. | |
| - name: Register OS version | |
| run: | | |
| VERSION="${{ steps.version.outputs.version }}" | |
| NOTES=$(CHANGELOG_REF="${{ github.sha }}" ./scripts/manage-release.sh notes start-os) \ | |
| || { echo "::warning::No release notes for v${VERSION}; registering without them."; NOTES=""; } | |
| start-cli --registry="${{ env.REGISTRY }}" registry os version add \ | |
| "$VERSION" "v${VERSION}" "$NOTES" ">=0.3.5 <=${VERSION}" | |
| - name: Index assets in registry | |
| run: | | |
| VERSION="${{ steps.version.outputs.version }}" | |
| cd artifacts | |
| for PLATFORM in ${{ steps.platforms.outputs.list }}; do | |
| for file in *_${PLATFORM}.squashfs *_${PLATFORM}.iso *_${PLATFORM}.img; do | |
| [ -f "$file" ] || continue | |
| echo "Indexing $file for platform $PLATFORM..." | |
| start-cli --registry="${{ env.REGISTRY }}" registry os asset add \ | |
| --platform="$PLATFORM" \ | |
| --version="$VERSION" \ | |
| "$file" \ | |
| "${{ env.S3_CDN }}/v${VERSION}/$file" | |
| done | |
| done | |
| # Alpha rolls the same version number on every master push, and dropping | |
| # the previous build from the index (above) leaves its objects in the | |
| # bucket forever — nothing in the registry crate touches object storage. | |
| # So reclaim them here, keyed on what the registries still reference. | |
| # | |
| # The direction matters: the registries supply the *candidates* for | |
| # deletion, never the exemptions. Deleting "everything not referenced" | |
| # would turn any registry that is unreachable, errored, or returns a shape | |
| # we no longer parse into an instruction to delete the images it serves. | |
| # Reading it the other way round makes every one of those failures a | |
| # no-op, which is why the whole step is a series of refusals. | |
| # | |
| # Runs last so this build is already indexed, and inside the deploy job's | |
| # `startos-deploy` lock so no other channel can be mid-upload. | |
| - name: Prune superseded images | |
| env: | |
| # Every registry that can reference this bucket, not just the channels | |
| # this workflow deploys to — production included, because `os promote` | |
| # copies asset URLs through verbatim, so a beta or production | |
| # commitment points at the very objects alpha stops referencing. Keep | |
| # in sync with the REGISTRY map above (root AGENTS.md, "Coupled | |
| # changes"). | |
| PRUNE_REGISTRIES: >- | |
| https://registry.start9.com | |
| https://beta-registry.start9.com | |
| https://alpha-registry-x.start9.com | |
| # CI's own upload-to-index gap is covered by the startos-deploy lock. | |
| # This gate is for the one writer outside it: a hand upload into a | |
| # version directory that is already indexed — manage-release.sh's | |
| # push-gz, or the per-platform hotfix that leaves one version | |
| # legitimately referencing two commits. That is an interactive | |
| # operation, so give it days. Orphans are permanent; waiting only | |
| # costs storage. | |
| MIN_AGE_DAYS: 14 | |
| run: | | |
| set -euo pipefail | |
| # comm(1) compares byte-wise; sort must agree with it. | |
| export LC_ALL=C | |
| # Reclaiming has no deadline — the next deploy does it. So anything | |
| # that stops us reading a trustworthy keep set skips this run rather | |
| # than failing it: by the time this step runs the images are uploaded | |
| # and indexed, and painting that red over someone else's outage | |
| # costs a re-run that `force-overwrite: false` will refuse anyway. | |
| # Only the assertions below, which mean this step itself is wrong, | |
| # still fail. | |
| skip() { | |
| echo "::warning::$1 — skipping this prune, the next deploy reclaims" | |
| exit 0 | |
| } | |
| rm -rf prune && mkdir prune | |
| for registry in $PRUNE_REGISTRIES; do | |
| idx="prune/$(printf '%s' "$registry" | tr -c 'A-Za-z0-9' '-').json" | |
| curl -sS --fail-with-body --retry 3 --retry-all-errors --max-time 120 \ | |
| -X POST "${registry%/}/rpc/v0" \ | |
| -H 'Content-Type: application/json' \ | |
| -d '{"jsonrpc":"2.0","id":1,"method":"os.index","params":{}}' -o "$idx" \ | |
| || skip "$registry did not answer os.index" | |
| [ -s "$idx" ] || skip "$registry returned an empty response to os.index" | |
| # A JSON-RPC error is an HTTP 200, and the shape has been migrated | |
| # before (urls was once a scalar url), so check each explicitly | |
| # rather than letting jq's `//` idioms read any of them as "no | |
| # versions" — and report them apart, since a registry with no | |
| # versions yet is a new channel, not a broken one. | |
| jq -e 'has("error") | not' "$idx" > /dev/null \ | |
| || skip "$registry: os.index returned $(jq -c '.error' "$idx")" | |
| jq -e '.result.versions | type == "object" and (length > 0)' "$idx" > /dev/null \ | |
| || skip "$registry publishes no OS versions" | |
| jq -e ' | |
| ([.result.versions[] | .iso, .squashfs, .img] | all(type == "object")) | |
| and ([.result.versions[] | (.iso, .squashfs, .img) | .[] | .urls] | |
| | all(type == "array" and (length > 0) and all(.[]; type == "string"))) | |
| ' "$idx" > /dev/null \ | |
| || skip "$registry: os.index answered in a shape this step does not recognise" | |
| jq -r ' | |
| .result.versions | to_entries[] | .key as $version | |
| | (.value.iso, .value.squashfs, .value.img) | .[] | .urls[] | |
| | sub("[?#].*$"; "") | sub("^.*/"; "") | |
| | [$version, .] | @tsv | |
| ' "$idx" >> prune/referenced.tsv | |
| done | |
| # Match on the asset name alone, so a URL served from a mirror, a | |
| # path-style host or a future CNAME still protects its object. That | |
| # only holds while a name is a bucket key verbatim. | |
| awk -F'\t' ' | |
| $2 ~ /[%[:space:]]/ { | |
| printf "%s is referenced as a name that is not a literal key\n", $2 > "/dev/stderr" | |
| exit 1 | |
| } | |
| { print $2 > "prune/referenced-names.txt" } | |
| { | |
| prefix = "startos-" $1 "-" | |
| if (index($2, prefix) == 1) { | |
| hash = substr($2, length(prefix) + 1, 7) | |
| if (hash ~ /^[0-9a-f]{7}$/) print $1 "\t" hash > "prune/referenced-builds.tsv" | |
| } | |
| } | |
| ' prune/referenced.tsv || skip "a referenced asset name needs decoding to compare against a key" | |
| touch prune/referenced-names.txt prune/referenced-builds.tsv prune/published.txt | |
| sort -u -o prune/referenced-names.txt prune/referenced-names.txt | |
| sort -u -o prune/referenced-builds.tsv prune/referenced-builds.tsv | |
| # What this run just indexed has to come back out of what we just | |
| # read. If it doesn't, the keep set isn't describing reality — and | |
| # since `os asset add` reported success, the index silently didn't | |
| # take, which is a broken deploy rather than someone else's outage. | |
| # This one fails. | |
| for PLATFORM in ${{ steps.platforms.outputs.list }}; do | |
| for file in artifacts/*_${PLATFORM}.squashfs artifacts/*_${PLATFORM}.iso artifacts/*_${PLATFORM}.img; do | |
| [ -f "$file" ] || continue | |
| basename "$file" >> prune/published.txt | |
| done | |
| done | |
| sort -u -o prune/published.txt prune/published.txt | |
| unindexed="$(comm -23 prune/published.txt prune/referenced-names.txt)" | |
| if [ -n "$unindexed" ]; then | |
| echo "::error::images published by this run are missing from the registries just read:" | |
| printf '%s\n' "$unindexed" | sed 's/^/ /' | |
| exit 1 | |
| fi | |
| s3cmd ls --recursive "${{ env.S3_BUCKET }}/" > prune/inventory.txt \ | |
| || skip "could not list ${{ env.S3_BUCKET }}" | |
| # We uploaded to it minutes ago, so an empty listing is not a state | |
| # this bucket can legitimately be in. | |
| [ -s prune/inventory.txt ] || { | |
| echo "::error::${{ env.S3_BUCKET }} listed as empty" | |
| exit 1 | |
| } | |
| # A candidate is a superseded build of a version the registries still | |
| # publish: same version prefix, a commit none of them reference any | |
| # more. Everything else — a version no registry knows about, a key | |
| # that doesn't parse, an upload too recent to have been indexed — is | |
| # reported and left alone. | |
| cutoff="$(date -u -d "$MIN_AGE_DAYS days ago" +'%Y-%m-%d %H:%M')" | |
| awk -v bucket="${{ env.S3_BUCKET }}/" -v cutoff="$cutoff" ' | |
| FILENAME == "prune/referenced-builds.tsv" { build[$1 "\t" $2] = 1; published[$1] = 1; next } | |
| FILENAME == "prune/referenced-names.txt" { referenced[$1] = 1; next } | |
| { | |
| at = $1 " " $2 | |
| size = $3 | |
| start = index($0, bucket) | |
| if (start == 0) next | |
| key = substr($0, start + length(bucket)) | |
| slash = index(key, "/") | |
| name = substr(key, slash + 1) | |
| version = substr(key, 2, slash - 2) | |
| prefix = "startos-" version "-" | |
| hash = substr(name, length(prefix) + 1, 7) | |
| suffix = substr(name, length(prefix) + 8) | |
| if (substr(key, 1, 1) != "v" || slash < 3 || index(name, "/") > 0) keep = "unrecognised key" | |
| else if (index(name, prefix) != 1 || hash !~ /^[0-9a-f]{7}$/ || | |
| suffix !~ /^[A-Za-z0-9._~-]*_[A-Za-z0-9_-]+\.(iso|squashfs|img)(\.gz)?$/) keep = "unrecognised name" | |
| else if (!(version in published)) keep = "version not in any registry" | |
| else if ((version "\t" hash) in build) keep = "referenced" | |
| else if (name in referenced) keep = "referenced" | |
| else if (at >= cutoff) keep = "newer than " cutoff | |
| else keep = "" | |
| if (keep == "") print size "\t" key > "prune/candidates.tsv" | |
| else print keep "\t" size "\t" key > "prune/kept.tsv" | |
| } | |
| ' prune/referenced-builds.tsv prune/referenced-names.txt prune/inventory.txt | |
| touch prune/candidates.tsv prune/kept.tsv | |
| cut -f2 prune/candidates.tsv | sort > prune/candidate-keys.txt | |
| # The requirement, re-derived by asset name instead of by (version, | |
| # commit): nothing a registry references is in the delete set. And a | |
| # bucket-side check that the keep set describes this bucket at all. | |
| # Both mean this step is wrong, so both fail rather than skip. | |
| awk -F'\t' '{ print $2 }' prune/candidates.tsv | sed 's|^v[^/]*/||' | sort -u > prune/candidate-names.txt | |
| if [ -n "$(comm -12 prune/candidate-names.txt prune/referenced-names.txt)" ]; then | |
| echo "::error::refusing to prune — these are referenced by a registry:" | |
| comm -12 prune/candidate-names.txt prune/referenced-names.txt | sed 's/^/ /' | |
| exit 1 | |
| fi | |
| sed 's|^.*/||' prune/inventory.txt | sort -u > prune/inventory-names.txt | |
| if [ -z "$(comm -12 prune/inventory-names.txt prune/referenced-names.txt)" ]; then | |
| echo "::error::no referenced image was found in ${{ env.S3_BUCKET }} — refusing to prune" | |
| exit 1 | |
| fi | |
| # Totals, not the listing: the referenced tail grows without bound, | |
| # and the number worth reading is how much sits outside every index. | |
| echo "Keeping $(wc -l < prune/kept.tsv) objects:" | |
| awk -F'\t' ' | |
| { objects[$1]++; bytes[$1] += $2 } | |
| END { | |
| for (reason in objects) | |
| printf "%7d %10.1f GiB %s\n", objects[reason], bytes[reason] / 1073741824, reason | |
| } | |
| ' prune/kept.tsv | sort -rn | |
| count="$(wc -l < prune/candidate-keys.txt)" | |
| bytes="$(awk -F'\t' '{ total += $1 } END { print total + 0 }' prune/candidates.tsv)" | |
| if [ "$count" -eq 0 ]; then | |
| echo "::notice::nothing to prune" | |
| exit 0 | |
| fi | |
| echo "Pruning $count superseded images ($(numfmt --to=iec "$bytes")):" | |
| sed 's/^/ /' prune/candidate-keys.txt | |
| sed "s|^|${{ env.S3_BUCKET }}/|" prune/candidate-keys.txt | xargs -r -d '\n' -n 100 s3cmd del | |
| # s3cmd's batch delete prints the keys it sent rather than the ones the | |
| # server accepted, and returns 0 either way — so confirm by listing. | |
| s3cmd ls --recursive "${{ env.S3_BUCKET }}/" | sed "s|^.*${{ env.S3_BUCKET }}/||" | sort > prune/remaining.txt | |
| if [ -n "$(comm -12 prune/candidate-keys.txt prune/remaining.txt)" ]; then | |
| echo "::error::these were not deleted:" | |
| comm -12 prune/candidate-keys.txt prune/remaining.txt | sed 's/^/ /' | |
| exit 1 | |
| fi | |
| echo "::notice::pruned $count superseded images ($(numfmt --to=iec "$bytes")) from ${{ env.S3_BUCKET }}" |