Watch
1
0
Fork
You've already forked RedFlag
0
RedFlag/.gitea/workflows/release.yml
Fimeg f9e099f4f4 ci: install jq before the release manifest steps
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.
2026-09-03 18:58:52 -04:00

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"