The final projection state matches the source-owned release and public-surface policy. Source-Sha: c1a84422a30a1dcf4ac9880a979de8fc6d4cb6c1 Policy-Sha: c1a84422a30a1dcf4ac9880a979de8fc6d4cb6c1 Tree-Digest: 13c2b0b731c29662c4909f73fc7246e8a0592c7e914fb8724d74594e38bebd69
737 lines
33 KiB
YAML
737 lines
33 KiB
YAML
name: release
|
|
on:
|
|
push:
|
|
tags: ["v*"]
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
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: redflag-linux-build
|
|
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
|
|
# actions/checkout resolves the trigger ref to a commit SHA and then
|
|
# force-updates refs/tags/<tag> to point straight at it, printing
|
|
# "t [tag update]". That replaces the annotated tag object, and the
|
|
# signature it carries, with a lightweight ref — so there is nothing
|
|
# left here to verify. Fetch the real object back by name first.
|
|
# This cost v0.2.9.3 on 2026-09-04 and was invisible because the
|
|
# check below discarded its own stderr.
|
|
git fetch --force origin "refs/tags/v$TAG:refs/tags/v$TAG"
|
|
git config gpg.format ssh
|
|
git config gpg.ssh.allowedSignersFile "$SIGNERS_FILE"
|
|
if [ "$(git cat-file -t "refs/tags/v$TAG")" != tag ]; then
|
|
echo "::error::v$TAG is a lightweight tag — a release tag must be annotated and signed"
|
|
FAIL=1
|
|
elif [ "$(git rev-parse "v$TAG^{commit}")" != "$GITHUB_SHA" ]; then
|
|
echo "::error::v$TAG points at $(git rev-parse "v$TAG^{commit}"), not $GITHUB_SHA"
|
|
FAIL=1
|
|
elif ! git tag -v "v$TAG" >/dev/null 2>/tmp/tagverify.err; then
|
|
echo "::error::tag v$TAG is not signed or signature is invalid"
|
|
sed 's/^/ /' /tmp/tagverify.err
|
|
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 --log-opts="$GITHUB_SHA"
|
|
scripts/check-public-history.sh "$GITHUB_SHA"
|
|
|
|
# Build the web UI once — it's the same embed for every platform.
|
|
web:
|
|
runs-on: redflag-linux-build
|
|
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: redflag-linux-build
|
|
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@6bed0761d98439e5a578e2877258200ad565ba87 # 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: redflag-linux-build
|
|
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@6bed0761d98439e5a578e2877258200ad565ba87 # 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: |
|
|
pip_args=()
|
|
if pip3 install --help | grep -q -- '--break-system-packages'; then
|
|
pip_args+=(--break-system-packages)
|
|
fi
|
|
pip3 install "${pip_args[@]}" 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: |
|
|
VERSION=${GITHUB_REF#refs/tags/v}
|
|
export REDFLAG_RELEASE_VERSION="$VERSION"
|
|
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
|
|
REDFLAG_RELEASE_VERSION="$VERSION" 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 }})"
|
|
|
|
# The crate version stays 3-part because Cargo will not take a
|
|
# fourth field; build.rs carries the release identity in instead, so
|
|
# the installed binary and its /v1/desktop report both name the
|
|
# release. Anything less and the fleet cannot tell 0.2.9.3 from
|
|
# 0.2.9.0. Exact match, not grep -q: v0.2.9 must not satisfy v0.2.90.
|
|
DESKTOP_VER=$(../dist/redflag-desktop-${{ matrix.suffix }} --version 2>/dev/null || echo "no-version")
|
|
echo "Desktop version: $DESKTOP_VER (expected v$VERSION)"
|
|
DESKTOP_NUM=$(printf '%s\n' "$DESKTOP_VER" | grep -oE 'v[0-9]+(\.[0-9]+)*' | head -1)
|
|
if [ "$DESKTOP_NUM" != "v$VERSION" ]; then
|
|
echo "::error::desktop binary reports ${DESKTOP_NUM:-no version}, expected v$VERSION"
|
|
exit 1
|
|
fi
|
|
|
|
- name: Build Windows installer (RedFlagSetup.msi)
|
|
if: matrix.goos == 'windows' && matrix.goarch == 'amd64'
|
|
run: |
|
|
set -euo pipefail
|
|
VERSION=${GITHUB_REF#refs/tags/v}
|
|
|
|
# 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).
|
|
# Older distro wixl versions omit UI tables without failing the
|
|
# build. Release and custody proof use the same pinned compiler.
|
|
bash installer/windows/provision-msitools.sh "$RUNNER_TEMP/msitools-0.106"
|
|
export PATH="$RUNNER_TEMP/msitools-0.106/bin:$PATH"
|
|
export LD_LIBRARY_PATH="$RUNNER_TEMP/msitools-0.106/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
|
command -v wixl
|
|
command -v msiinfo
|
|
command -v msiextract
|
|
|
|
installer/windows/build-msi.sh \
|
|
dist/redflag-server-windows-amd64.exe \
|
|
"$VERSION" \
|
|
dist/RedFlagSetup-${{ matrix.suffix }}.msi \
|
|
dist/installer-proof-${{ matrix.suffix }}.json \
|
|
"$GITHUB_SHA" \
|
|
"$GITHUB_REF"
|
|
|
|
# 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; }
|
|
HELPER_OUT=$(./dist/redflag-helper-${{ matrix.suffix }} --version)
|
|
echo "$HELPER_OUT"
|
|
echo "$HELPER_OUT" | grep -q "v$VERSION" || { echo "::error::helper binary reports wrong version"; exit 1; }
|
|
|
|
- name: Build package-managed helper for Debian
|
|
if: matrix.goos == 'linux' && matrix.goarch == 'amd64'
|
|
run: |
|
|
set -euo pipefail
|
|
VERSION=${GITHUB_REF#refs/tags/v}
|
|
cd helper
|
|
REDFLAG_PACKAGE_BIN_DIR=/usr/bin REDFLAG_RELEASE_VERSION="$VERSION" \
|
|
cargo test --locked package_managed_binary_updates
|
|
REDFLAG_PACKAGE_BIN_DIR=/usr/bin REDFLAG_RELEASE_VERSION="$VERSION" \
|
|
cargo build --release --locked --target-dir target/debian
|
|
|
|
- name: Package tarball
|
|
run: |
|
|
VERSION=${GITHUB_REF#refs/tags/v}
|
|
cd dist
|
|
# AGPL binaries travel with their licence or they are conveyed
|
|
# without one.
|
|
cp ../LICENSE ../THIRD_PARTY_LICENSES.md .
|
|
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="LICENSE THIRD_PARTY_LICENSES.md 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 LICENSE THIRD_PARTY_LICENSES.md redflag-*-${{ matrix.suffix }}
|
|
sha256sum redflag-$VERSION-${{ matrix.suffix }}.tar.gz > checksums-$VERSION-${{ matrix.suffix }}.txt
|
|
# The .deb is a distributable in its own right, not a tarball member:
|
|
# it must reach custody as its own file or it is published untracked.
|
|
# amd64-only, matching build-deb.sh's control architecture.
|
|
if [ "${{ matrix.suffix }}" = "linux-amd64" ]; then
|
|
bash ../installer/linux/build-deb.sh --version "$VERSION" --bindir . --outdir . \
|
|
--helper ../helper/target/debian/release/redflag-helper
|
|
bash ../installer/linux/inspect-deb.sh "redflag_${VERSION}_amd64.deb" --version "$VERSION"
|
|
sha256sum redflag_${VERSION}_amd64.deb >> checksums-$VERSION-${{ matrix.suffix }}.txt
|
|
fi
|
|
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
|
|
# The .deb uses dpkg's redflag_<version>_<arch> naming, so the
|
|
# redflag-<bin>-<suffix> loop above does not see it either.
|
|
for DEB in redflag_*_amd64.deb; do
|
|
[ -f "$DEB" ] || continue
|
|
SHA=$(sha256sum "$DEB" | awk '{print $1}')
|
|
SIZE=$(stat -c%s "$DEB")
|
|
jq --arg plat "package-${{ matrix.goos }}" --arg arch "${{ matrix.goarch }}" \
|
|
--arg file "$DEB" --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"
|
|
done
|
|
echo "Artifact snippet:"
|
|
cat "release-${{ matrix.suffix }}.artifacts.json"
|
|
|
|
- uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3
|
|
with:
|
|
name: release-${{ matrix.suffix }}
|
|
# The checksum file and the manifest snippet do not start with
|
|
# redflag-, so a single redflag-* glob leaves both behind in the
|
|
# matrix job and publish finds nothing to merge or attach.
|
|
path: |
|
|
dist/redflag-*-${{ matrix.suffix }}*
|
|
dist/redflag_*_amd64.deb
|
|
dist/RedFlagSetup-${{ matrix.suffix }}.msi
|
|
dist/installer-proof-${{ matrix.suffix }}.json
|
|
dist/checksums-*-${{ matrix.suffix }}.txt
|
|
dist/release-${{ matrix.suffix }}.artifacts.json
|
|
retention-days: 1
|
|
|
|
# Generic CI assembles one deterministic candidate and deposits it on the
|
|
# package shelf. This token can write packages; it cannot publish a release.
|
|
stage_package:
|
|
runs-on: redflag-linux-build
|
|
container:
|
|
image: debian:13-slim@sha256:d7e12182ce18b85b93007c1dedf31f2d29e01ccf3182cc4017c709b6259bc132
|
|
needs: [release]
|
|
outputs:
|
|
receipt_sha256: ${{ steps.assemble.outputs.receipt_sha256 }}
|
|
steps:
|
|
- name: Install distro runtime
|
|
run: apt-get update -qq && apt-get install -y --no-install-recommends ca-certificates curl git jq nodejs python3
|
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Download all artifacts
|
|
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3
|
|
with:
|
|
path: artifacts
|
|
|
|
- name: Assemble deterministic release candidate
|
|
id: assemble
|
|
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}
|
|
scripts/assemble-release-candidate.sh \
|
|
artifacts candidate "$VERSION" "$GITHUB_SHA" "v$VERSION"
|
|
RECEIPT=$(sha256sum candidate/files.sha256 | awk '{print $1}')
|
|
echo "receipt_sha256=$RECEIPT" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Deposit candidate on package shelf
|
|
env:
|
|
PACKAGE_WRITE_TOKEN: ${{ secrets.PACKAGE_WRITE_TOKEN }}
|
|
run: |
|
|
VERSION=${GITHUB_REF#refs/tags/v}
|
|
scripts/stage-release-candidate.sh candidate artifacts "$VERSION"
|
|
|
|
# Trusted execution fetches and verifies the exact package candidate. It
|
|
# rebuilds nothing and publishes only Casey's internal Gitea alpha release.
|
|
# Public promotion is a separate manual workflow after hands-on testing.
|
|
publish_internal:
|
|
runs-on: release-trusted
|
|
needs: [stage_package]
|
|
permissions:
|
|
contents: write
|
|
steps:
|
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
|
with:
|
|
fetch-depth: 0
|
|
persist-credentials: false
|
|
|
|
- name: Reverify release tag
|
|
run: |
|
|
set -euo pipefail
|
|
VERSION=${GITHUB_REF#refs/tags/v}
|
|
TAG="v$VERSION"
|
|
TOKEN_FILE=/srv/release-trusted/.secrets/gitea-redflag-release-token
|
|
test -r "$TOKEN_FILE"
|
|
GIT_ASKPASS="$PWD/scripts/git-askpass-token.sh" \
|
|
GIT_TOKEN_FILE="$TOKEN_FILE" \
|
|
GIT_TOKEN_USERNAME=release-redflag \
|
|
GIT_TERMINAL_PROMPT=0 \
|
|
git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG"
|
|
git config gpg.format ssh
|
|
git config gpg.ssh.allowedSignersFile .gitea/allowed_signers
|
|
test "$(git cat-file -t "refs/tags/$TAG")" = tag
|
|
test "$(git rev-parse "${TAG}^{commit}")" = "$GITHUB_SHA"
|
|
git tag -v "$TAG"
|
|
|
|
# The trusted lane checks ancestry independently of ordinary CI.
|
|
GIT_ASKPASS="$PWD/scripts/git-askpass-token.sh" \
|
|
GIT_TOKEN_FILE="$TOKEN_FILE" \
|
|
GIT_TOKEN_USERNAME=release-redflag \
|
|
GIT_TERMINAL_PROMPT=0 \
|
|
git fetch --no-tags origin refs/heads/public:refs/remotes/origin/public
|
|
git merge-base --is-ancestor "$GITHUB_SHA" refs/remotes/origin/public
|
|
|
|
- name: Fetch and verify package candidate
|
|
env:
|
|
PACKAGE_RECEIPT_SHA256: ${{ needs.stage_package.outputs.receipt_sha256 }}
|
|
run: |
|
|
set -euo pipefail
|
|
VERSION=${GITHUB_REF#refs/tags/v}
|
|
scripts/fetch-release-candidate.sh \
|
|
artifacts/package artifacts "$VERSION" \
|
|
"$PACKAGE_RECEIPT_SHA256" \
|
|
/srv/release-trusted/.secrets/package-read-token
|
|
python3 scripts/release-contract.py verify artifacts/package "$VERSION" "$GITHUB_SHA" "v$VERSION"
|
|
|
|
- name: Create Gitea release
|
|
run: |
|
|
set -euo pipefail
|
|
VERSION=${GITHUB_REF#refs/tags/v}
|
|
TAG="v$VERSION"
|
|
API="${GITHUB_SERVER_URL}/api/v1"
|
|
TOKEN_FILE=/srv/release-trusted/.secrets/gitea-redflag-release-token
|
|
test -r "$TOKEN_FILE"
|
|
GITEA_RELEASE_TOKEN=$(<"$TOKEN_FILE")
|
|
test -n "$GITEA_RELEASE_TOKEN"
|
|
|
|
gitea_curl() {
|
|
curl --config <(printf 'silent\nshow-error\nheader = "Authorization: token %s"\n' "$GITEA_RELEASE_TOKEN") "$@"
|
|
}
|
|
|
|
# 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)"
|
|
|
|
release_status=$(gitea_curl -o /tmp/gitea-release.json -w '%{http_code}' \
|
|
"$API/repos/${GITHUB_REPOSITORY}/releases/tags/$TAG")
|
|
case "$release_status" in
|
|
200)
|
|
;;
|
|
404)
|
|
payload=$(jq -nc \
|
|
--arg tag "$TAG" \
|
|
--arg name "$TAG" \
|
|
--argjson prerelease "$PRERELEASE" \
|
|
'{tag_name:$tag,name:$name,draft:false,prerelease:$prerelease}')
|
|
gitea_curl -f -X POST "$API/repos/${GITHUB_REPOSITORY}/releases" \
|
|
-H 'Content-Type: application/json' \
|
|
--data "$payload" >/tmp/gitea-release.json
|
|
;;
|
|
*)
|
|
echo "::error::Gitea release lookup failed with HTTP $release_status"
|
|
exit 1
|
|
;;
|
|
esac
|
|
RELEASE_ID=$(jq -er .id /tmp/gitea-release.json)
|
|
|
|
gitea_curl -f \
|
|
"$API/repos/${GITHUB_REPOSITORY}/releases/$RELEASE_ID/assets?limit=100" \
|
|
>/tmp/gitea-assets.json
|
|
mapfile -d '' release_files < <(
|
|
find artifacts/package -maxdepth 1 -type f -print0
|
|
)
|
|
test "${#release_files[@]}" -gt 0
|
|
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/gitea-assets.json)
|
|
if [ -n "$download_url" ]; then
|
|
existing=$(mktemp)
|
|
gitea_curl -f -L -o "$existing" "$download_url"
|
|
cmp -s "$file" "$existing" || {
|
|
echo "::error::existing Gitea release asset differs: $name"
|
|
exit 1
|
|
}
|
|
rm -f "$existing"
|
|
echo "Reused byte-identical Gitea asset: $name"
|
|
else
|
|
echo "Uploading $name"
|
|
gitea_curl -f -X POST \
|
|
"$API/repos/${GITHUB_REPOSITORY}/releases/$RELEASE_ID/assets?name=$name" \
|
|
-F "attachment=@$file" >/dev/null
|
|
fi
|
|
done
|
|
echo "Internal Gitea alpha release v$VERSION published byte-exact"
|