Skip to content

feat(startos): welcome a server with the release notes it updated to … #5720

feat(startos): welcome a server with the release notes it updated to …

feat(startos): welcome a server with the release notes it updated to … #5720

Workflow file for this run

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 }}"