RedFlag/.gitea/workflows/release.yml
Fimeg d175bc5f77 projection: bind the repaired release source
The final projection state matches the source-owned release and public-surface policy.

Source-Sha: c1a84422a30a1dcf4ac9880a979de8fc6d4cb6c1
Policy-Sha: c1a84422a30a1dcf4ac9880a979de8fc6d4cb6c1
Tree-Digest: 13c2b0b731c29662c4909f73fc7246e8a0592c7e914fb8724d74594e38bebd69
2026-09-10 09:17:37 -04:00

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"