Neither the release nor the publish job has ever executed, so jq's presence in the runner image is unproven while both depend on it. The guard matches the one the history gate already uses for curl.
701 lines
31 KiB
YAML
701 lines
31 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 Rust/Qt crate version
|
|
# must match the first three tag octets.
|
|
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@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3
|
|
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@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3
|
|
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@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3
|
|
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@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3
|
|
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 native Qt/QML Desktop
|
|
if: matrix.goos == 'linux' && matrix.goarch == 'amd64'
|
|
run: |
|
|
set -euo pipefail
|
|
VERSION=${GITHUB_REF#refs/tags/v}
|
|
|
|
# CXX-Qt compiles the Rust bridge and embeds the QML module. The web
|
|
# application is not an input to the native Desktop binary.
|
|
sudo apt-get update -qq
|
|
sudo apt-get install -y -qq qt6-base-dev qt6-declarative-dev qt6-declarative-dev-tools libgl1-mesa-dev
|
|
|
|
cd desktop
|
|
cargo build --release --locked
|
|
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 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; bump-version.sh applies the same 3-vs-4-part mapping.
|
|
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
|
|
command -v jq >/dev/null || (apt-get update -qq && apt-get install -y -qq jq)
|
|
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@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3
|
|
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@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3
|
|
with:
|
|
path: artifacts
|
|
|
|
- name: Generate component manifest
|
|
run: |
|
|
set -euo pipefail
|
|
command -v jq >/dev/null || (apt-get update -qq && apt-get install -y -qq jq)
|
|
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"
|