Watch
1
0
Fork
You've already forked RedFlag
0
RedFlag/.gitea/workflows/release.yml
Fimeg f6b03f40c6 ci: publish and verify forgejo releases
Tags now cross only after the release gates. Public assets are read back anonymously and hashed; the Rust Action pin now names its advertised stable commit.
2026-08-20 12:40:47 -04:00

733 lines
32 KiB
YAML

name: release
on:
push:
tags: ["v*"]
permissions:
contents: write
jobs:
# Gate runs before any build. A tag that doesn't agree with the tree is a
# broken release waiting to happen — fail here, not after artifacts exist.
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Version gate
run: |
set -euo pipefail
TAG=${GITHUB_REF#refs/tags/v}
echo "Tag version: $TAG"
FAIL=0
# versions.go — anchored so MinAgentVersion does not match.
SERVER_VER=$(grep -P '^\s*AgentVersion\s*=' server/internal/version/versions.go | grep -oP '"\K[^"]+')
CONFIG_VER=$(grep -P '^\s*ConfigVersion\s*=' server/internal/version/versions.go | grep -oP '"\K[^"]+')
echo "versions.go: AgentVersion=$SERVER_VER ConfigVersion=$CONFIG_VER"
if [ "$TAG" != "$SERVER_VER" ]; then
echo "::error::tag=$TAG but versions.go AgentVersion=$SERVER_VER"
FAIL=1
fi
if [ "$TAG" != "$CONFIG_VER" ]; then
echo "::error::tag=$TAG but versions.go ConfigVersion=$CONFIG_VER"
FAIL=1
fi
# docker-compose.yml BUILD_VERSION default.
COMPOSE_VER=$(grep -oP '(?<=BUILD_VERSION:-)[0-9]+(\.[0-9]+){3}' docker-compose.yml)
echo "docker-compose: BUILD_VERSION=$COMPOSE_VER"
if [ "$TAG" != "$COMPOSE_VER" ]; then
echo "::error::tag=$TAG but docker-compose BUILD_VERSION=$COMPOSE_VER"
FAIL=1
fi
# helper/Cargo.toml — 3-part semver, compare against first 3 octets.
CARGO_VER=$(grep -m1 '^version' helper/Cargo.toml | cut -d'"' -f2)
TAG_SEMVER=$(echo "$TAG" | cut -d. -f1-3)
echo "Cargo.toml: version=$CARGO_VER (tag semver=$TAG_SEMVER)"
if [ "$TAG_SEMVER" != "$CARGO_VER" ]; then
echo "::error::tag=$TAG (semver=$TAG_SEMVER) but Cargo.toml=$CARGO_VER"
FAIL=1
fi
# Desktop: optional component, but if present, its version must match.
# desktop/Cargo.toml is the single source of the desktop version (3-part
# semver); tauri.conf.json carries no version field and inherits it from
# the crate (a 4-octet there is invalid semver and Tauri's build refuses
# it), so there is nothing to cross-check on tauri.conf.json.
if [ -f desktop/Cargo.toml ]; then
DESKTOP_CARGO_VER=$(grep -m1 '^version' desktop/Cargo.toml | cut -d'"' -f2)
echo "desktop/Cargo.toml: version=$DESKTOP_CARGO_VER"
if [ "$TAG_SEMVER" != "$DESKTOP_CARGO_VER" ]; then
echo "::error::tag=$TAG (semver=$TAG_SEMVER) but desktop/Cargo.toml=$DESKTOP_CARGO_VER"
FAIL=1
fi
fi
# CHANGELOG must mention the version being released.
if ! grep -q "$TAG" CHANGELOG.md; then
echo "::error::CHANGELOG.md has no entry for $TAG"
FAIL=1
fi
# Forward-only: the new tag must sort above every existing tag.
HIGHEST=$(git tag --list 'v*' --sort=-v:refname | head -1)
echo "Highest tag: $HIGHEST"
if [ "$HIGHEST" != "v$TAG" ]; then
echo "::error::v$TAG does not sort above existing tags (highest=$HIGHEST) — version must move forward"
FAIL=1
fi
# The tagged commit must be on public — no releases from stray branches.
if ! git rev-parse --verify --quiet origin/public >/dev/null; then
echo "::error::origin/public not found in checkout — cannot verify tag ancestry"
FAIL=1
elif ! git merge-base --is-ancestor "$GITHUB_SHA" origin/public; then
echo "::error::tagged commit $GITHUB_SHA is not on public"
FAIL=1
fi
# Tag must be signed with an SSH key from the allowed signers file.
# This ensures the release was created by an authorized maintainer.
SIGNERS_FILE=".gitea/allowed_signers"
if [ ! -f "$SIGNERS_FILE" ]; then
echo "::error::allowed signers file not found at $SIGNERS_FILE"
FAIL=1
else
git config gpg.format ssh
git config gpg.ssh.allowedSignersFile "$SIGNERS_FILE"
if ! git tag -v "v$TAG" >/dev/null 2>&1; then
echo "::error::tag v$TAG is not signed or signature is invalid"
FAIL=1
else
echo "Tag signature verified"
fi
fi
exit $FAIL
- name: Component catalog gate
run: |
set -euo pipefail
TAG=${GITHUB_REF#refs/tags/v}
FAIL=0
# The component catalog in server/internal/services/release_manifest.go
# is the authoritative list. Every required component must have source in
# the tree — the build jobs will verify binaries and the publish job will
# verify artifact presence, but this gate catches "we said we build X but
# X's directory doesn't exist" before any compile time is spent.
echo "Checking component catalog coverage..."
# Agent: must have agent/cmd/ and go.mod
if [ ! -d agent/cmd/agent ] || [ ! -f agent/go.mod ]; then
echo "::error::component 'agent' required but agent/cmd/ or go.mod missing"
FAIL=1
fi
# Server: must have server/cmd/ and go.mod
if [ ! -d server/cmd/server ] || [ ! -f server/go.mod ]; then
echo "::error::component 'server' required but server/cmd/ or go.mod missing"
FAIL=1
fi
# Helper: must have helper/Cargo.toml
if [ ! -f helper/Cargo.toml ]; then
echo "::error::component 'helper' required but helper/Cargo.toml missing"
FAIL=1
fi
# Web: must have web/package.json
if [ ! -f web/package.json ]; then
echo "::error::component 'web' required but web/package.json missing"
FAIL=1
fi
# Desktop: optional, but if present, verify its source exists
if [ -d desktop ]; then
if [ -f desktop/Cargo.toml ]; then
DESKTOP_VER=$(grep -m1 '^version' desktop/Cargo.toml | cut -d'"' -f2 || echo "")
echo "desktop/Cargo.toml: version=$DESKTOP_VER"
if [ -n "$DESKTOP_VER" ] && [ "$DESKTOP_VER" != "0.1.0" ]; then
echo "[INFO] [gate] desktop version is $DESKTOP_VER (not 0.1.0 stub — version lockstep effective)"
fi
fi
else
echo "[INFO] [gate] desktop component optional — no desktop/ directory, skipping"
fi
echo "Component catalog gate: OK"
exit $FAIL
- name: Complete public history gate
run: |
set -euo pipefail
command -v curl || (apt-get update -qq && apt-get install -y -qq curl)
curl -fsSL -o /tmp/gitleaks.tar.gz \
https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz
echo '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb /tmp/gitleaks.tar.gz' |
sha256sum -c -
tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks
/tmp/gitleaks git . --redact --no-banner
scripts/check-public-history.sh "$GITHUB_SHA"
# Build the web UI once — it's the same embed for every platform.
web:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
cache-dependency-path: web/package-lock.json
- name: Build web UI
run: cd web && npm ci && npm run build
- name: Stage for embedding
run: |
rm -rf server/internal/webui/dist
cp -r web/dist server/internal/webui/dist
test -s server/internal/webui/dist/index.html
test -d server/internal/webui/dist/assets
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: webui-dist
path: server/internal/webui/dist
retention-days: 1
# Supply-chain gate with teeth: a release cannot ship with an un-accepted
# reachable dependency vulnerability. Runs the SAME scripts/dep-scan.sh as CI,
# plus --posture-out to emit the attested posture embedded into the server
# binary and signed into the release manifest. If this fails, `release` never
# builds (it is in `needs`).
dep-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: agent/go.mod
cache: true
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
cache-dependency-path: web/package-lock.json
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
- name: Install scanners
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
cargo install cargo-audit --locked
- name: Dependency gate + posture
run: scripts/dep-scan.sh --posture-out server/internal/services/posture-build.json
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: supply-chain-posture
path: server/internal/services/posture-build.json
retention-days: 1
# Build binaries for every target platform. The webui-dist and supply-chain
# posture artifacts are downloaded into the embed paths before the server compile.
release:
runs-on: ubuntu-latest
needs: [gate, web, dep-scan]
strategy:
fail-fast: false
matrix:
include:
- goos: linux
goarch: amd64
rust_target: x86_64-unknown-linux-gnu
suffix: linux-amd64
linker: ""
use_zigbuild: false
skip_helper: false
- goos: linux
goarch: arm64
rust_target: aarch64-unknown-linux-gnu
suffix: linux-arm64
linker: gcc-aarch64-linux-gnu
use_zigbuild: false
skip_helper: false
- goos: windows
goarch: amd64
rust_target: ""
suffix: windows-amd64
linker: gcc-mingw-w64-x86-64
use_zigbuild: false
skip_helper: true
- goos: darwin
goarch: arm64
rust_target: aarch64-apple-darwin
suffix: darwin-arm64
linker: ""
use_zigbuild: true
skip_helper: false
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: agent/go.mod
cache: true
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
with:
targets: ${{ matrix.rust_target }}
- name: Install cross-linker
if: matrix.linker != ''
run: sudo apt-get update && sudo apt-get install -y ${{ matrix.linker }}
- name: Install cargo-zigbuild
if: matrix.use_zigbuild
run: pip3 install --break-system-packages cargo-zigbuild
- name: Download web UI
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: webui-dist
path: server/internal/webui/dist
# Embed the attested supply-chain posture (replaces the committed
# attested:false stub) so the running server signs an honest posture.
- name: Download supply-chain posture
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: supply-chain-posture
path: server/internal/services
- name: Build server
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
run: |
VERSION=${GITHUB_REF#refs/tags/v}
EXT=""
if [ "${{ matrix.goos }}" = "windows" ]; then EXT=".exe"; fi
cd server && go build -ldflags "-s -w \
-X github.com/Fimeg/RedFlag/server/internal/version/versions.AgentVersion=$VERSION \
-X github.com/Fimeg/RedFlag/server/internal/version/versions.ConfigVersion=$VERSION" \
-o ../dist/redflag-server-${{ matrix.suffix }}${EXT} ./cmd/server/
- name: Build agent
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
run: |
VERSION=${GITHUB_REF#refs/tags/v}
EXT=""
if [ "${{ matrix.goos }}" = "windows" ]; then EXT=".exe"; fi
cd agent && go build -ldflags "-s -w \
-X github.com/Fimeg/RedFlag/agent/internal/version.Version=$VERSION \
-X github.com/Fimeg/RedFlag/agent/internal/version.ConfigVersion=$VERSION \
-X github.com/Fimeg/RedFlag/agent/internal/version.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-o ../dist/redflag-agent-${{ matrix.suffix }}${EXT} ./cmd/agent/
- name: Build helper
if: "!matrix.skip_helper"
run: |
cd helper
if [ "${{ matrix.use_zigbuild }}" = "true" ]; then
cargo zigbuild --release --target ${{ matrix.rust_target }}
else
cargo build --release --target ${{ matrix.rust_target }}
fi
EXT=""
if [ "${{ matrix.goos }}" = "windows" ]; then EXT=".exe"; fi
cp target/${{ matrix.rust_target }}/release/redflag-helper${EXT} ../dist/redflag-helper-${{ matrix.suffix }}${EXT}
- name: Build desktop (Tauri system tray)
if: matrix.goos == 'linux' && matrix.goarch == 'amd64'
run: |
set -euo pipefail
VERSION=${GITHUB_REF#refs/tags/v}
# Tauri v2 system dependencies (webkit2gtk-4.1 for Ubuntu 24.04+).
sudo apt-get update -qq
sudo apt-get install -y -qq libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev 2>/dev/null || true
# Build the desktop frontend (Tauri's beforeBuildCommand, but we do it
# explicitly so the web build is deterministic).
cd web && npm ci --silent && npm run build:desktop && cd ..
# Build the desktop binary.
cd desktop
cargo build --release
cp target/release/redflag-desktop ../dist/redflag-desktop-${{ matrix.suffix }}
echo "Desktop binary built: $(ls -lh ../dist/redflag-desktop-${{ matrix.suffix }})"
# Verify it self-reports the tag version.
DESKTOP_VER=$(../dist/redflag-desktop-${{ matrix.suffix }} --version 2>/dev/null || echo "no-version")
echo "Desktop version: $DESKTOP_VER"
echo "$DESKTOP_VER" | grep -q "v$VERSION" || { echo "::error::desktop binary reports $DESKTOP_VER, expected v$VERSION"; exit 1; }
- name: Build desktop (Tauri system tray - Windows cross-compile)
if: matrix.goos == 'windows' && matrix.goarch == 'amd64'
run: |
set -euo pipefail
VERSION=${GITHUB_REF#refs/tags/v}
# Tauri v2 host build scripts need webkit2gtk even when cross-compiling.
sudo apt-get update -qq
sudo apt-get install -y -qq libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev clang lld llvm 2>/dev/null || true
# Install cargo-xwin for MSVC cross-compilation.
rustup target add x86_64-pc-windows-msvc
cargo install --locked cargo-xwin
# Build the desktop frontend.
cd web && npm ci --silent && npm run build:desktop && cd ..
# Cross-compile the desktop binary (bundling disabled — raw exe).
cd desktop
cargo xwin build --release --target x86_64-pc-windows-msvc
cp target/x86_64-pc-windows-msvc/release/redflag-desktop.exe \
../dist/redflag-desktop-${{ matrix.suffix }}.exe
echo "Desktop binary built: $(ls -lh ../dist/redflag-desktop-${{ matrix.suffix }}.exe)"
# Cross-compiled binary can't run here for version check, but the gate
# job validates the manifest hash before publish.
- name: Build Windows installer (RedFlagSetup.msi)
if: matrix.goos == 'windows' && matrix.goarch == 'amd64'
run: |
set -euo pipefail
VERSION=${GITHUB_REF#refs/tags/v}
# MSI's ProductVersion only carries 3 significant fields for
# upgrade detection — same 3-vs-4-part reconciliation bump-version.sh
# already does for desktop/Cargo.toml vs tauri.conf.json.
WIX_VERSION=$(echo "$VERSION" | cut -d. -f1-3)
# NOT the official WiX Toolset .NET CLI — `wix build` genuinely
# does not work when the compiler runs on Linux (reproduced: even
# a single-char Directory/@Name fails WIX0389 "not a relative
# path" on every WiX version 4.0.5 through 6.0.1; the tool's own
# output says "only supports Windows... undefined behavior"
# beyond that point). msitools' `wixl` is a from-scratch
# Linux-native reimplementation of the same MSI-building grammar,
# built for exactly this case — verified locally 2026-07-01
# (msiinfo confirms Directory/Component/ServiceInstall/Upgrade
# tables all populated correctly against a real cross-compiled
# server binary).
sudo apt-get update -qq
sudo apt-get install -y -qq msitools
mkdir -p installer/windows/dist
cp dist/redflag-server-windows-amd64.exe installer/windows/dist/redflag-server-windows-amd64.exe
cd installer/windows
wixl Product.wxs \
-D RedFlagVersion="$WIX_VERSION" \
-o ../../dist/RedFlagSetup-${{ matrix.suffix }}.msi
echo "Installer built: $(ls -lh ../../dist/RedFlagSetup-${{ matrix.suffix }}.msi)"
# Did it make it in: linux-amd64 binaries self-report the tag version.
# Cross-compiled binaries can't run here (wrong arch/OS), but the native
# ones must match.
- name: Verify binary versions (native only)
if: matrix.goos == 'linux' && matrix.goarch == 'amd64'
run: |
set -euo pipefail
VERSION=${GITHUB_REF#refs/tags/v}
SERVER_OUT=$(./dist/redflag-server-${{ matrix.suffix }} --version)
echo "$SERVER_OUT"
echo "$SERVER_OUT" | grep -q "v$VERSION" || { echo "::error::server binary reports wrong version"; exit 1; }
AGENT_OUT=$(./dist/redflag-agent-${{ matrix.suffix }} --version)
echo "$AGENT_OUT"
echo "$AGENT_OUT" | grep -q "v$VERSION" || { echo "::error::agent binary reports wrong version"; exit 1; }
- name: Package tarball
run: |
VERSION=${GITHUB_REF#refs/tags/v}
cd dist
ls -la
if [ "${{ matrix.goos }}" = "windows" ]; then
# Windows: zip (no helper — it's Unix-only). Desktop binary and
# the RedFlagSetup installer are both optional (only exist for
# windows-amd64 today, not e.g. windows-arm64).
ZIP_FILES="redflag-server-${{ matrix.suffix }}.exe redflag-agent-${{ matrix.suffix }}.exe"
if [ -f "redflag-desktop-${{ matrix.suffix }}.exe" ]; then
ZIP_FILES="$ZIP_FILES redflag-desktop-${{ matrix.suffix }}.exe"
fi
if [ -f "RedFlagSetup-${{ matrix.suffix }}.msi" ]; then
ZIP_FILES="$ZIP_FILES RedFlagSetup-${{ matrix.suffix }}.msi"
fi
zip redflag-$VERSION-${{ matrix.suffix }}.zip $ZIP_FILES
sha256sum redflag-$VERSION-${{ matrix.suffix }}.zip > checksums-$VERSION-${{ matrix.suffix }}.txt
else
tar czf redflag-$VERSION-${{ matrix.suffix }}.tar.gz redflag-*-${{ matrix.suffix }}
sha256sum redflag-$VERSION-${{ matrix.suffix }}.tar.gz > checksums-$VERSION-${{ matrix.suffix }}.txt
fi
- name: Generate manifest artifact snippet
run: |
set -euo pipefail
VERSION=${GITHUB_REF#refs/tags/v}
cd dist
# Emit one JSON object per binary in this platform's tarball.
# The publish job merges these into the full manifest artifacts array.
echo '[]' > "release-${{ matrix.suffix }}.artifacts.json"
for bin in redflag-server redflag-agent redflag-helper redflag-desktop; do
for f in "${bin}-${{ matrix.suffix }}" "${bin}-${{ matrix.suffix }}.exe"; do
if [ -f "$f" ]; then
SHA=$(sha256sum "$f" | awk '{print $1}')
SIZE=$(stat -c%s "$f")
# Map binary prefix to manifest platform name.
case "$bin" in
redflag-server) PLAT="server-${{ matrix.goos }}" ;;
redflag-agent) PLAT="${{ matrix.goos }}" ;;
redflag-helper) PLAT="helper-${{ matrix.goos }}" ;;
redflag-desktop) PLAT="desktop-${{ matrix.goos }}" ;;
esac
jq --arg plat "$PLAT" --arg arch "${{ matrix.goarch }}" \
--arg file "$(basename "$f")" --arg sha "$SHA" \
--argjson size "$SIZE" \
'. + [{"platform":$plat,"architecture":$arch,"filename":$file,"sha256":$sha,"size":$size}]' \
"release-${{ matrix.suffix }}.artifacts.json" > tmp.json \
&& mv tmp.json "release-${{ matrix.suffix }}.artifacts.json"
fi
done
done
# RedFlagSetup.msi doesn't fit the redflag-<bin>-<suffix> naming
# convention above (no redflag- prefix, .msi not .exe) — handled
# separately. Windows-amd64 only for now.
if [ -f "RedFlagSetup-${{ matrix.suffix }}.msi" ]; then
SHA=$(sha256sum "RedFlagSetup-${{ matrix.suffix }}.msi" | awk '{print $1}')
SIZE=$(stat -c%s "RedFlagSetup-${{ matrix.suffix }}.msi")
jq --arg plat "installer-${{ matrix.goos }}" --arg arch "${{ matrix.goarch }}" \
--arg file "RedFlagSetup-${{ matrix.suffix }}.msi" --arg sha "$SHA" \
--argjson size "$SIZE" \
'. + [{"platform":$plat,"architecture":$arch,"filename":$file,"sha256":$sha,"size":$size}]' \
"release-${{ matrix.suffix }}.artifacts.json" > tmp.json \
&& mv tmp.json "release-${{ matrix.suffix }}.artifacts.json"
fi
echo "Artifact snippet:"
cat "release-${{ matrix.suffix }}.artifacts.json"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: release-${{ matrix.suffix }}
path: dist/redflag-*-${{ matrix.suffix }}*
retention-days: 1
# Publish: gather all platform artifacts, generate the component manifest,
# retain the internal Gitea release, then project the exact signed tag and
# verified binaries to the public Forgejo release shelf.
publish:
runs-on: ubuntu-latest
needs: [release]
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Download all artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
path: artifacts
- name: Generate component manifest
run: |
set -euo pipefail
VERSION=${GITHUB_REF#refs/tags/v}
NOW=$(date -u +%s)
# Component catalog — kept in sync with server/internal/services/release_manifest.go
# (componentCatalog()). Every component listed here MUST have a built artifact
# verified by the release matrix jobs, or the gate fails this release.
cat > manifest.json <<MANIFEST
{
"version": "$VERSION",
"generated_at": $NOW,
"key_id": "",
"components": [
{"name": "server", "kind": "binary", "required": true, "version_cmd": "--version"},
{"name": "agent", "kind": "binary", "required": true, "version_cmd": "--version"},
{"name": "helper", "kind": "binary", "required": true, "version_cmd": "--version"},
{"name": "desktop", "kind": "binary", "required": false, "version_cmd": "--version",
"provisioning": ["autostart_entry", "redflag-local_group", "desktop_user_membership"]},
{"name": "web", "kind": "embedded","required": true},
{"name": "installer","kind": "binary", "required": false}
],
"artifacts": []
}
MANIFEST
# Populate artifacts from downloaded release bundles. Each platform job
# uploads manifest artifact snippets as release-<suffix>.artifacts.json.
for f in artifacts/release-*/release-*.artifacts.json; do
if [ -f "$f" ]; then
echo "Merging artifact entries from $(basename "$(dirname "$f")")/$(basename "$f")"
jq -s '.[0].artifacts + .[1].artifacts' manifest.json "$f" > manifest.tmp \
&& mv manifest.tmp manifest.json
fi
done
# Verify every required component has at least one artifact.
# web = embedded (verified by web job producing a non-empty dist/).
# server/agent/helper/desktop = binary artifacts, cross-compiled per
# platform by the release matrix. docker-compose-from-source remains
# a valid, separate install path for the server — it just isn't a
# signed release artifact, so it isn't in this manifest.
for comp in server agent helper; do
if ! jq -e --arg c "$comp" '.artifacts[] | select(.platform | test($c))' manifest.json > /dev/null; then
echo "::error::required component '$comp' has no artifacts in manifest"
exit 1
fi
done
echo "Component completeness verified"
# Upload manifest so the release job below attaches it.
cp manifest.json artifacts/manifest.json
- name: Create Gitea release
run: |
set -euo pipefail
VERSION=${GITHUB_REF#refs/tags/v}
API="${GITHUB_SERVER_URL}/api/v1"
# Alpha until v0.3.0. Anything sorting below the stable floor publishes
# as a prerelease; this auto-flips to a stable release at v0.3.0 with no
# manual toggle to forget.
STABLE_FLOOR="0.3.0"
if [ "$(printf '%s\n%s\n' "$VERSION" "$STABLE_FLOOR" | sort -V | head -1)" = "$VERSION" ] \
&& [ "$VERSION" != "$STABLE_FLOOR" ]; then
PRERELEASE=true
else
PRERELEASE=false
fi
echo "Release $VERSION prerelease=$PRERELEASE (stable floor v$STABLE_FLOOR)"
RESPONSE=$(curl -sf -X POST "$API/repos/${GITHUB_REPOSITORY}/releases" \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"v$VERSION\",\"name\":\"v$VERSION\",\"draft\":false,\"prerelease\":$PRERELEASE}")
RELEASE_ID=$(echo "$RESPONSE" | grep -oP '"id":\s*\K[0-9]+' | head -1)
if [ -z "$RELEASE_ID" ]; then
echo "::error::failed to parse release id from API response: $RESPONSE"
exit 1
fi
echo "Created release id=$RELEASE_ID"
# Upload every artifact (tarballs, zips, checksums, manifest).
find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.txt' -o -name 'manifest.json' \) | while read f; do
echo "Uploading $(basename "$f")"
curl -sf -X POST "$API/repos/${GITHUB_REPOSITORY}/releases/$RELEASE_ID/assets?name=$(basename "$f")" \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-F "attachment=@$f" > /dev/null
done
echo "Release v$VERSION published"
- name: Publish and verify Forgejo release
env:
PUBLIC_FORGE_TOKEN: ${{ secrets.PUBLIC_FORGE_TOKEN }}
run: |
set -euo pipefail
: "${PUBLIC_FORGE_TOKEN:?PUBLIC_FORGE_TOKEN is required}"
VERSION=${GITHUB_REF#refs/tags/v}
TAG="v$VERSION"
EXPECTED="$GITHUB_SHA"
FORGE_URL='https://forge.caseytunturi.com/Fimeg/RedFlag.git'
FORGE_API='https://forge.caseytunturi.com/api/v1/repos/Fimeg/RedFlag'
test "$(git rev-parse "${TAG}^{commit}")" = "$EXPECTED"
git fetch --no-tags "$FORGE_URL" \
refs/heads/public:refs/remotes/public-forge/public
if ! git merge-base --is-ancestor "$EXPECTED" refs/remotes/public-forge/public; then
echo "::error::Forgejo public branch does not contain release commit $EXPECTED"
exit 1
fi
remote_tag=$(git ls-remote "$FORGE_URL" "refs/tags/$TAG" | awk '{print $1}')
if [ -n "$remote_tag" ]; then
git fetch --force --no-tags "$FORGE_URL" \
"refs/tags/$TAG:refs/public-forge/release-tag"
test "$(git rev-parse 'refs/public-forge/release-tag^{commit}')" = "$EXPECTED"
else
forge_auth=$(printf 'publisher-redflag:%s' "$PUBLIC_FORGE_TOKEN" | base64 -w0)
git -c "http.https://forge.caseytunturi.com/.extraheader=Authorization: Basic $forge_auth" \
push "$FORGE_URL" "refs/tags/$TAG:refs/tags/$TAG"
fi
STABLE_FLOOR='0.3.0'
if [ "$(printf '%s\n%s\n' "$VERSION" "$STABLE_FLOOR" | sort -V | head -1)" = "$VERSION" ] \
&& [ "$VERSION" != "$STABLE_FLOOR" ]; then
PRERELEASE=true
else
PRERELEASE=false
fi
release_status=$(curl -sS -o /tmp/forgejo-release.json -w '%{http_code}' \
-H "Authorization: token $PUBLIC_FORGE_TOKEN" \
"$FORGE_API/releases/tags/$TAG")
case "$release_status" in
200)
RELEASE_ID=$(jq -r .id /tmp/forgejo-release.json)
;;
404)
payload=$(jq -nc \
--arg tag "$TAG" \
--arg name "$TAG" \
--argjson prerelease "$PRERELEASE" \
'{tag_name:$tag,name:$name,draft:false,prerelease:$prerelease}')
curl -fsS -X POST "$FORGE_API/releases" \
-H "Authorization: token $PUBLIC_FORGE_TOKEN" \
-H 'Content-Type: application/json' \
--data "$payload" >/tmp/forgejo-release.json
RELEASE_ID=$(jq -r .id /tmp/forgejo-release.json)
;;
*)
echo "::error::Forgejo release lookup failed with HTTP $release_status"
exit 1
;;
esac
test "$RELEASE_ID" != null
mapfile -d '' release_files < <(
find artifacts -type f \
\( -name '*.tar.gz' -o -name '*.zip' -o -name '*.txt' -o -name 'manifest.json' \) \
-print0
)
if [ "${#release_files[@]}" -eq 0 ]; then
echo '::error::release produced no public artifacts'
exit 1
fi
curl -fsS -H "Authorization: token $PUBLIC_FORGE_TOKEN" \
"$FORGE_API/releases/$RELEASE_ID/assets?limit=30" >/tmp/forgejo-assets.json
for file in "${release_files[@]}"; do
name=$(basename "$file")
download_url=$(jq -r --arg name "$name" \
'[.[] | select(.name == $name)] | if length == 1 then .[0].browser_download_url else "" end' \
/tmp/forgejo-assets.json)
if [ -z "$download_url" ]; then
curl -fsS -X POST \
"$FORGE_API/releases/$RELEASE_ID/assets?name=$name" \
-H "Authorization: token $PUBLIC_FORGE_TOKEN" \
-F "attachment=@$file" >/tmp/forgejo-upload.json
download_url=$(jq -r .browser_download_url /tmp/forgejo-upload.json)
fi
expected_hash=$(sha256sum "$file" | awk '{print $1}')
public_hash=$(curl -fsSL "$download_url" | sha256sum | awk '{print $1}')
if [ "$public_hash" != "$expected_hash" ]; then
echo "::error::anonymous Forgejo asset hash mismatch: $name"
exit 1
fi
echo "Verified public release asset: $name $expected_hash"
done
anonymous_tag=$(curl -fsS "$FORGE_API/releases/tags/$TAG" | jq -r .tag_name)
test "$anonymous_tag" = "$TAG"
echo "Forgejo release $TAG is anonymous and byte-exact"