Skip to content
159 changes: 63 additions & 96 deletions .github/workflows/artifact-cleanup.yml
Original file line number Diff line number Diff line change
@@ -1,117 +1,84 @@
name: Artifact Cleanup

on:
workflow_run:
workflows: ["Build NixOS ISOs"]
types:
- completed
schedule:
- cron: "0 4 * * *"
workflow_call:

permissions:
contents: write
actions: write

jobs:
cleanup:
name: Rotate ISO pre-release artifacts and tags
name: Rotate pre-release artifacts and tags
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
steps:
- name: Rotate releases and artifacts
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
- name: Delete old pre-release releases and tags (keep 2 per branch)
run: |
set -euo pipefail

gh release list --repo "$REPO" --limit 200 \
--json tagName,body,createdAt,isPrerelease \
> /tmp/releases.json

gh api "repos/${REPO}/actions/artifacts?per_page=100" \
--paginate \
| jq -r '.artifacts[] | select(.expired == false) | [.id, .name, .created_at, (.workflow_run.head_branch // "unknown")] | @tsv' \
> /tmp/artifacts.tsv

python3 - <<'EOF'
import json, subprocess, re, sys, os
from datetime import datetime, timezone
from collections import defaultdict
PROTECTED="iso-latest"

GRACE_SECS = 86400
MAX_PER_BRANCH = 2
NOW = datetime.now(timezone.utc)
REPO = os.environ["REPO"]

ISO_PRERELEASE_RE = re.compile(r'^iso-v\d+\.\d+\.\d+-(alpha|beta|rc)\.(\d+)$')
STABLE_RE = re.compile(r'^v\d+\.\d+\.\d+$')
PROTECTED_TAGS = {"iso-latest"}
gh release list --repo "$REPO" --limit 200 \
--json tagName,isPrerelease,createdAt \
| jq -r '.[] | select(.isPrerelease == true) | [.tagName, .createdAt] | @tsv' \
| sort -t$'\t' -k2,2r \
| awk -F'\t' -v protected="$PROTECTED" '
{
tag = $1
if (tag == protected) next
if (match(tag, /--(.+)$/, m)) branch = m[1]
else branch = "unknown"
count[branch]++
if (count[branch] > 2) print tag
}
' \
| while read -r tag; do
echo "[INFO] deleting release tag=${tag}"
gh release delete "$tag" --repo "$REPO" --yes --cleanup-tag || \
echo "[WARN] delete failed tag=${tag}"
done

with open("/tmp/releases.json") as f:
releases = json.load(f)
- name: Delete orphaned tags without release
run: |
set -euo pipefail

branch_releases = {}
for r in releases:
tag = r["tagName"]
if tag in PROTECTED_TAGS or STABLE_RE.match(tag):
continue
if not r["isPrerelease"]:
continue
m = ISO_PRERELEASE_RE.match(tag)
if not m:
continue
body = r.get("body") or ""
branch = None
for line in body.splitlines():
if line.startswith("branch: "):
branch = line[len("branch: "):].strip()
break
if not branch:
continue
count = int(m.group(2))
created = datetime.fromisoformat(r["createdAt"].replace("Z", "+00:00"))
branch_releases.setdefault(branch, []).append((count, tag, created))
PROTECTED="iso-latest"

for branch, entries in branch_releases.items():
entries.sort(key=lambda x: x[0])
for count, tag, created in entries[:-MAX_PER_BRANCH]:
age = (NOW - created).total_seconds()
if age < GRACE_SECS:
print(f"[INFO] skip tag={tag} age={int(age)}s below grace period")
continue
print(f"[INFO] deleting tag={tag} branch={branch} age={int(age)}s")
result = subprocess.run(
["gh", "release", "delete", tag, "--repo", REPO, "--yes", "--cleanup-tag"],
capture_output=True, text=True
)
if result.returncode != 0:
print(f"[WARN] delete failed tag={tag} err={result.stderr.strip()}", file=sys.stderr)
gh api "repos/${REPO}/git/refs/tags" --paginate \
| jq -r '.[].ref | ltrimstr("refs/tags/")' \
| while read -r tag; do
[ "$tag" = "$PROTECTED" ] && continue
if ! gh release view "$tag" --repo "$REPO" > /dev/null 2>&1; then
echo "[INFO] deleting orphaned tag=${tag}"
gh api -X DELETE "repos/${REPO}/git/refs/tags/${tag}" || \
echo "[WARN] delete failed tag=${tag}"
fi
done

groups = defaultdict(list)
with open("/tmp/artifacts.tsv") as f:
for line in f:
line = line.strip()
if not line:
continue
parts = line.split("\t")
if len(parts) < 4:
continue
artifact_id, name, created_at, branch = parts[0], parts[1], parts[2], parts[3]
created = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
groups[(name, branch)].append((created, artifact_id))
- name: Delete old Actions artifacts (keep 2 per name+branch)
run: |
set -euo pipefail

for (name, branch), entries in groups.items():
entries.sort(key=lambda x: x[0], reverse=True)
for created, artifact_id in entries[MAX_PER_BRANCH:]:
age = (NOW - created).total_seconds()
if age < GRACE_SECS:
print(f"[INFO] skip artifact_id={artifact_id} name={name} age={int(age)}s below grace period")
continue
print(f"[INFO] deleting artifact_id={artifact_id} name={name} branch={branch} age={int(age)}s")
result = subprocess.run(
["gh", "api", "-X", "DELETE", f"repos/{REPO}/actions/artifacts/{artifact_id}"],
capture_output=True, text=True
)
if result.returncode != 0:
print(f"[WARN] delete failed artifact_id={artifact_id} err={result.stderr.strip()}", file=sys.stderr)
EOF
gh api "repos/${REPO}/actions/artifacts?per_page=100" --paginate \
| jq -r '.artifacts[] | select(.expired == false) | [
(.id | tostring),
.name,
(.workflow_run.head_branch // "unknown"),
.created_at
] | @tsv' \
| sort -t$'\t' -k2,2 -k3,3 -k4,4r \
| awk -F'\t' '
{
key = $2 SUBSEP $3
count[key]++
if (count[key] > 2) print $1
}
' \
| while read -r id; do
echo "[INFO] deleting artifact id=${id}"
gh api -X DELETE "repos/${REPO}/actions/artifacts/${id}" || \
echo "[WARN] delete failed artifact_id=${id}"
done
99 changes: 86 additions & 13 deletions .github/workflows/build-iso.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,62 @@ permissions:
contents: write

jobs:
lint:
name: Format and lint
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
sha: ${{ steps.push.outputs.sha }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref || github.ref_name }}
token: ${{ secrets.GITHUB_TOKEN }}

- uses: cachix/install-nix-action@v27
with:
extra_nix_config: |
experimental-features = nix-command flakes

- name: Run nixpkgs-fmt
run: nix run nixpkgs#nixpkgs-fmt -- .

- name: Run statix
run: nix run nixpkgs#statix -- check .

- name: Commit formatting fixes
id: push
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
if ! git diff --quiet; then
git add -A
git commit -m "style: apply nixpkgs-fmt"
git push
fi
echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT

check:
name: Nix flake check
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.lint.outputs.sha }}

- uses: cachix/install-nix-action@v27
with:
extra_nix_config: |
experimental-features = nix-command flakes

- name: Check flake evaluation
run: nix flake check --no-build

build:
name: ISO ${{ matrix.config }} (${{ matrix.arch }})
needs: [lint, check]
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
Expand All @@ -36,6 +90,8 @@ jobs:

steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.lint.outputs.sha }}

- uses: cachix/install-nix-action@v27
with:
Expand Down Expand Up @@ -82,28 +138,38 @@ jobs:
- name: Resolve version
id: ver
run: |
VERSION=$(grep 'version' versions.nix | head -1 | sed 's/.*"\(.*\)"/\1/')
VERSION=$(grep -m1 'version' versions.nix | sed 's/[^"]*"\([^"]*\)".*/\1/')
BRANCH="${{ github.ref_name }}"
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "branch=${BRANCH}" >> $GITHUB_OUTPUT

- name: Create versioned pre-release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="${{ steps.ver.outputs.version }}"
BRANCH="${{ steps.ver.outputs.branch }}"
TAG="iso-v${VERSION}"
BRANCH_SLUG=$(echo "$BRANCH" | sed 's|/|-|g')
TAG="iso-v${VERSION}--${BRANCH_SLUG}"
REPO="${{ github.repository }}"
BASE="https://github.com/${REPO}/releases/download/${TAG}"

gh release create "${TAG}" \
--title "ISO v${VERSION}" \
--notes "branch: ${BRANCH}" \
--prerelease \
*.iso *.sha256
if gh release view "${TAG}" --repo "$REPO" > /dev/null 2>&1; then
gh release upload "${TAG}" --repo "$REPO" *.iso *.sha256 --clobber
else
gh release create "${TAG}" \
--title "ISO v${VERSION} (${BRANCH})" \
--notes "" \
--prerelease \
*.iso *.sha256
fi

if [ "${BRANCH}" = "develop" ]; then
git tag -f iso-latest
git push origin iso-latest --force
SHA="${{ github.sha }}"
gh api -X PATCH "repos/${REPO}/git/refs/tags/iso-latest" \
-f sha="$SHA" -F force=true 2>/dev/null || \
gh api -X POST "repos/${REPO}/git/refs" \
-f ref="refs/tags/iso-latest" -f sha="$SHA"
fi

echo "## ISO Download URLs" >> $GITHUB_STEP_SUMMARY
Expand All @@ -112,8 +178,6 @@ jobs:
echo "|--------|------|-----|" >> $GITHUB_STEP_SUMMARY
echo "| csfx-node-iso | amd64 | ${BASE}/csfx-node-iso-amd64.iso |" >> $GITHUB_STEP_SUMMARY
echo "| csfx-node-iso-arm64 | arm64 | ${BASE}/csfx-node-iso-arm64-arm64.iso |" >> $GITHUB_STEP_SUMMARY
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

release:
name: Publish ISOs to Release
Expand All @@ -135,6 +199,8 @@ jobs:
echo "version=${VERSION}" >> $GITHUB_OUTPUT

- name: Upload to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="${{ steps.ver.outputs.version }}"
TAG="v${VERSION}"
Expand All @@ -156,5 +222,12 @@ jobs:
echo "|--------|------|-----|" >> $GITHUB_STEP_SUMMARY
echo "| csfx-node-iso | amd64 | ${BASE}/csfx-node-iso-amd64.iso |" >> $GITHUB_STEP_SUMMARY
echo "| csfx-node-iso-arm64 | arm64 | ${BASE}/csfx-node-iso-arm64-arm64.iso |" >> $GITHUB_STEP_SUMMARY
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

cleanup:
name: Artifact Cleanup
needs: [release-dev, release]
if: always()
uses: ./.github/workflows/artifact-cleanup.yml
permissions:
contents: write
actions: write
Loading
Loading