L'API Forgejo renvoie pour /releases/tags/<tag> un objet JSON pretty-printed où l'id racine (release.id, ex. 10706) est sur la première ligne, mais l'objet author contient aussi un id (souvent 1 pour le premier user du repo). L'ancienne regex sed matchait la première occurrence globale de "id" dans le fichier, donc elle retombait sur author.id=1 et le PATCH /releases/1 tombait en 404 'The target couldn't be found'. Fix : on pipe le fichier dans 'head -3' pour ne matcher que les premières lignes (couvre largement le préambule de l'objet release). Si Forgejo renvoie du JSON minifié (une seule ligne), head -3 renvoie toute la ligne et la regex matche le premier id (la racine, parce que les champs auteur sont après les champs racine).
325 lines
No EOL
14 KiB
YAML
325 lines
No EOL
14 KiB
YAML
# Build and publish a release on the Forgejo source-of-truth instance
|
|
# with the PostIt Android APK as an attached asset.
|
|
#
|
|
# Triggered by a push of a git tag. Validates the tag/changelog pair,
|
|
# builds the APK using the existing Dockerfile (--target build-env), then
|
|
# publishes a Forgejo release via the Forgejo REST API and uploads the
|
|
# APK as an asset.
|
|
#
|
|
# Authentication uses ${{ secrets.GITHUB_TOKEN }} (auto-provided by the
|
|
# Forgejo runner, scoped to contents: write for the current repo). A
|
|
# dedicated PAT (${{ secrets.RELEASE_TOKEN }}) was the preferred option
|
|
# for least-privilege, but creating repo-level secrets is currently
|
|
# broken on this Forgejo instance (InsertEncryptedSecret fails with a
|
|
# UTF-8 byte-sequence error, probably a text-vs-bytea column type on
|
|
# the secret table). Bumping to Forgejo v16 should fix it; until then,
|
|
# the runner-provided token keeps the workflow operational.
|
|
#
|
|
# Why bash + curl, no third-party actions: the runner's docker label
|
|
# points at pazof/yavsc-build-env, a Debian image without Node.js. Any
|
|
# action like actions/checkout, rasterstate/forgejo-release-action, etc.
|
|
# fails with "executable file not found in $PATH". Same constraint as
|
|
# .forgejo/workflows/buildAndTest.yml.
|
|
#
|
|
# This workflow complements .github/workflows/docker-publish-android.yml
|
|
# which targets the GitHub mirror; the validate-release logic mirrors
|
|
# the GitHub-side job so the two channels stay consistent.
|
|
name: Forgejo Release
|
|
|
|
on:
|
|
push:
|
|
tags:
|
|
- '*'
|
|
workflow_dispatch:
|
|
inputs:
|
|
tag:
|
|
description: 'Tag à publier (requis en dispatch, ex. 1.0.6 ou 1.0.7-rc1).'
|
|
required: true
|
|
type: string
|
|
force_unstable:
|
|
description: 'Publier une release avec suffixe (ex. 1.0.0-rc1) malgré le fail-fast par défaut.'
|
|
required: false
|
|
type: boolean
|
|
default: false
|
|
|
|
permissions:
|
|
contents: write
|
|
|
|
jobs:
|
|
# Job unique : validation tag/CHANGELOG + build APK + publication
|
|
# via l'API REST Forgejo (pas d'actions tierces Node).
|
|
release:
|
|
runs-on: docker
|
|
steps:
|
|
- name: Clone du repo au tag demandé
|
|
env:
|
|
# En push tag : github.ref_name est le tag.
|
|
# En workflow_dispatch : on lit l'input 'tag'.
|
|
TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }}
|
|
FORCE_UNSTABLE: ${{ inputs.force_unstable || 'false' }}
|
|
run: |
|
|
if [[ -z "$TAG" ]]; then
|
|
echo "::error::No tag provided. In workflow_dispatch, set the 'tag' input."
|
|
exit 1
|
|
fi
|
|
|
|
# WORKDIR de l'image (cf. dotnet-android-build-image/Dockerfile).
|
|
cd /src
|
|
|
|
# Clone unshallow pour que GitVersion.MsBuild ait l'historique
|
|
# et les tags (sinon MSB3073 sur la cible Android cf. PR #21).
|
|
if [[ ! -d _src/.git ]]; then
|
|
git clone https://forgejo.pschneider.fr/notazof/yavsc.git _src
|
|
fi
|
|
|
|
cd _src
|
|
git fetch --tags --force --prune origin
|
|
git checkout "$TAG"
|
|
|
|
echo "Checked out at $(git rev-parse HEAD) on $(git describe --tags --always 2>/dev/null || echo unknown)"
|
|
|
|
- name: Valider le tag et la section CHANGELOG
|
|
run: |
|
|
cd /src/_src
|
|
TAG="$(git describe --tags --exact-match HEAD 2>/dev/null || git rev-parse --short HEAD)"
|
|
echo "Validating tag $TAG"
|
|
|
|
# Parse semver : MAJOR.MINOR.PATCH[-SUFFIX]
|
|
if [[ ! "$TAG" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-.*)?$ ]]; then
|
|
echo "::error::Tag '$TAG' does not match MAJOR.MINOR.PATCH[-SUFFIX] format."
|
|
exit 1
|
|
fi
|
|
|
|
MAJOR="${BASH_REMATCH[1]}"
|
|
MINOR="${BASH_REMATCH[2]}"
|
|
PATCH="${BASH_REMATCH[3]}"
|
|
SUFFIX="${BASH_REMATCH[4]}"
|
|
|
|
# Classification du canal par parité du patch.
|
|
# Patch pair + pas de suffixe -> stable.
|
|
# Patch impair + pas de suffixe -> preview.
|
|
# Suffixe présent -> instable.
|
|
if [[ -n "$SUFFIX" ]]; then
|
|
CHANNEL="unstable"
|
|
elif (( PATCH % 2 == 0 )); then
|
|
CHANNEL="stable"
|
|
else
|
|
CHANNEL="preview"
|
|
fi
|
|
|
|
echo "Tag $TAG classifié comme channel=$CHANNEL"
|
|
|
|
# Fail-fast sur instable sauf opt-in explicite.
|
|
if [[ "$CHANNEL" == "unstable" && "${FORCE_UNSTABLE:-false}" != "true" ]]; then
|
|
echo "::error::Tag '$TAG' is unstable (suffix '$SUFFIX'). Refusing to publish."
|
|
echo "Set force_unstable=true via workflow_dispatch to override."
|
|
exit 1
|
|
fi
|
|
|
|
# Lecture du CHANGELOG.md (doit exister à la racine du repo).
|
|
if [[ ! -f CHANGELOG.md ]]; then
|
|
echo "::error::CHANGELOG.md not found at repo root."
|
|
exit 1
|
|
fi
|
|
|
|
# Extraction de la section [TAG]. On cherche la première ligne
|
|
# commençant par '## [' qui contient '[TAG]' (entre '## [' et
|
|
# la prochaine ligne '## [' ou fin de fichier). awk en mode
|
|
# paragraphe suffit et reste POSIX. On garde aussi le titre
|
|
# (ligne `## [TAG] - channel`) pour la vérification du canal.
|
|
BODY=$(awk -v tag="[$TAG]" '
|
|
/^## \[/ {
|
|
if (in_section) exit
|
|
if (index($0, tag) > 0) {
|
|
in_section=1
|
|
print
|
|
next
|
|
}
|
|
}
|
|
in_section { print }
|
|
' CHANGELOG.md)
|
|
|
|
if [[ -z "$BODY" ]]; then
|
|
echo "::error::No section matching '## [$TAG]' found in CHANGELOG.md."
|
|
echo "Add a '## [$TAG] - $CHANNEL' section before tagging."
|
|
exit 1
|
|
fi
|
|
|
|
# Vérification cohérence du canal déclaré dans le suffixe.
|
|
# Format attendu : "## [TAG] - stable" / "- preview" / "- unstable".
|
|
# On lit la première ligne du body qui contient le titre.
|
|
TITLE=$(echo "$BODY" | head -1)
|
|
if [[ "$TITLE" != *" - $CHANNEL"* ]]; then
|
|
echo "::error::Section title '$TITLE' must declare suffix '- $CHANNEL' to match tag parity."
|
|
exit 1
|
|
fi
|
|
|
|
# Body pour la release : retire la première ligne (titre).
|
|
BODY=$(echo "$BODY" | tail -n +2)
|
|
|
|
echo "Section CHANGELOG validée pour [$TAG] - $CHANNEL"
|
|
|
|
# Expose channel + body pour les étapes suivantes via $GITHUB_ENV.
|
|
echo "RELEASE_CHANNEL=$CHANNEL" >> "$GITHUB_ENV"
|
|
echo "RELEASE_BODY<<EOF" >> "$GITHUB_ENV"
|
|
echo "$BODY" >> "$GITHUB_ENV"
|
|
echo "EOF" >> "$GITHUB_ENV"
|
|
echo "IS_PRERELEASE=$([ "$CHANNEL" = "stable" ] && echo false || echo true)" >> "$GITHUB_ENV"
|
|
|
|
- name: Build des projets .NET (sans docker)
|
|
# L'image runner (pazof/yavsc-build-env) a le SDK .NET 10 + le
|
|
# workload Android, mais PAS le binaire `docker` ni de daemon
|
|
# Docker. On exécute donc les commandes dotnet directement
|
|
# au lieu de passer par `docker build`.
|
|
# Equivalent des stages build-env du Dockerfile (lignes
|
|
# restore + build Yavsc.Org + build Yavsc.Api + build
|
|
# Yavsc.Blogs + build PostIt.Android -r android-arm64).
|
|
run: |
|
|
cd /src/_src
|
|
dotnet restore
|
|
dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly
|
|
dotnet build src/Yavsc.Api/Yavsc.Api.csproj -c Release --no-restore -clp:ErrorsOnly
|
|
dotnet build src/Yavsc.Blogs/Yavsc.Blogs.csproj -c Release --no-restore -clp:ErrorsOnly
|
|
dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj \
|
|
-c Release --no-restore -clp:ErrorsOnly -r android-arm64
|
|
|
|
- name: Copier l'APK signé vers un emplacement connu
|
|
# Le build Android avec -r android-arm64 produit l'APK dans
|
|
# bin/Release/net10.0-android/android-arm64/. On le copie à
|
|
# la racine du checkout pour que l'étape d'upload le trouve.
|
|
run: |
|
|
cd /src/_src
|
|
APK=src/PostIt/PostIt.Android/bin/Release/net10.0-android/android-arm64/com.CompanyName.PostIt-Signed.apk
|
|
if [[ ! -f "$APK" ]]; then
|
|
echo "::error::APK not found at $APK"
|
|
ls -la src/PostIt/PostIt.Android/bin/Release/net10.0-android/ 2>/dev/null || true
|
|
exit 1
|
|
fi
|
|
cp "$APK" /src/_src/PostIt.Android.apk
|
|
ls -la /src/_src/PostIt.Android.apk
|
|
|
|
- name: Publier la release Forgejo via l'API REST
|
|
# Pas d'action tierce (pas de Node dans l'image runner).
|
|
# On parle à l'API Forgejo directement via curl.
|
|
# Docs : https://forgejo.pschneider.fr/api/swagger#/repository/release
|
|
env:
|
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
GITHUB_API_URL: ${{ github.api_url }}
|
|
GITHUB_REPOSITORY: ${{ github.repository }}
|
|
TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }}
|
|
RELEASE_BODY: ${{ env.RELEASE_BODY }}
|
|
IS_PRERELEASE: ${{ env.IS_PRERELEASE }}
|
|
run: |
|
|
if [[ -z "$TAG" ]]; then
|
|
echo "::error::No tag resolved for the API call."
|
|
exit 1
|
|
fi
|
|
|
|
# Le runner Forgejo expose l'API sur github.api_url (par
|
|
# défaut http://…/api/v1). On retire le suffixe /api/v1 s'il
|
|
# est présent pour dériver la base du serveur, puis on
|
|
# reconstruit l'URL de l'API proprement.
|
|
API_BASE="${GITHUB_API_URL%/}"
|
|
API_BASE="${API_BASE%/api/v1}"
|
|
|
|
# Pas de python3, pas de jq dans l'image runner. On génère
|
|
# le JSON à la main : escaping minimal des caractères
|
|
# spéciaux JSON dans les chaînes (\\, \", \n, \r, \t).
|
|
# Suffisant pour un CHANGELOG.md bien formé.
|
|
json_escape() {
|
|
local s="$1"
|
|
s="${s//\\/\\\\}"
|
|
s="${s//\"/\\\"}"
|
|
s="${s//$'\n'/\\n}"
|
|
s="${s//$'\r'/\\r}"
|
|
s="${s//$'\t'/\\t}"
|
|
printf '%s' "$s"
|
|
}
|
|
|
|
# Extraction d'un champ JSON scalaire de premier niveau depuis un
|
|
# fichier. On ne lit que les premières lignes pour éviter de
|
|
# matcher un champ homonyme dans un objet imbriqué (par ex.
|
|
# le champ "id" de l'auteur d'une release Forgejo, qui vaut
|
|
# typiquement 1 pour le premier user du repo). Sans cette
|
|
# restriction, le PATCH sur /releases/<id extrait> tombe en
|
|
# 404 "The target couldn't be found".
|
|
json_field() {
|
|
local file="$1" field="$2"
|
|
head -3 "$file" | sed -n "s/.*\"$field\"[[:space:]]*:[[:space:]]*\"\?\([0-9][0-9]*\)\"\?.*/\1/p" | head -1
|
|
}
|
|
|
|
# 1. Vérifier si la release existe déjà pour ce tag.
|
|
echo "::group::Check existing release for tag $TAG"
|
|
HTTP=$(curl -sS -o /tmp/existing.json -w '%{http_code}' \
|
|
-H "Authorization: token $GITHUB_TOKEN" \
|
|
-H "Accept: application/json" \
|
|
"$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases/tags/$TAG")
|
|
echo "GET releases/tags/$TAG -> HTTP $HTTP"
|
|
EXISTING_ID=""
|
|
if [[ "$HTTP" == "200" ]]; then
|
|
EXISTING_ID=$(json_field /tmp/existing.json id)
|
|
echo "Existing release id: ${EXISTING_ID:-none}"
|
|
fi
|
|
echo "::endgroup::"
|
|
|
|
# 2. Créer ou mettre à jour la release.
|
|
if [[ -n "$EXISTING_ID" ]]; then
|
|
echo "::group::Update release id=$EXISTING_ID"
|
|
BODY=$(printf '{"body":"%s","prerelease":%s}' \
|
|
"$(json_escape "$RELEASE_BODY")" "$IS_PRERELEASE")
|
|
HTTP=$(curl -sS -o /tmp/release.json -w '%{http_code}' \
|
|
-X PATCH \
|
|
-H "Authorization: token $GITHUB_TOKEN" \
|
|
-H "Content-Type: application/json" \
|
|
-H "Accept: application/json" \
|
|
--data-binary "$BODY" \
|
|
"$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases/$EXISTING_ID")
|
|
echo "PATCH release -> HTTP $HTTP"
|
|
echo "::endgroup::"
|
|
else
|
|
echo "::group::Create release"
|
|
BODY=$(printf '{"tag_name":"%s","name":"%s","body":"%s","prerelease":%s}' \
|
|
"$(json_escape "$TAG")" \
|
|
"$(json_escape "$TAG")" \
|
|
"$(json_escape "$RELEASE_BODY")" \
|
|
"$IS_PRERELEASE")
|
|
HTTP=$(curl -sS -o /tmp/release.json -w '%{http_code}' \
|
|
-X POST \
|
|
-H "Authorization: token $GITHUB_TOKEN" \
|
|
-H "Content-Type: application/json" \
|
|
-H "Accept: application/json" \
|
|
--data-binary "$BODY" \
|
|
"$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases")
|
|
echo "POST release -> HTTP $HTTP"
|
|
echo "::endgroup::"
|
|
fi
|
|
|
|
if [[ "$HTTP" != "200" && "$HTTP" != "201" ]]; then
|
|
echo "::error::Release creation/update failed (HTTP $HTTP):"
|
|
cat /tmp/release.json
|
|
exit 1
|
|
fi
|
|
|
|
RELEASE_ID=$(json_field /tmp/release.json id)
|
|
echo "Release id=$RELEASE_ID"
|
|
|
|
# 3. Upload l'APK en asset.
|
|
echo "::group::Upload APK asset"
|
|
HTTP=$(curl -sS -o /tmp/asset.json -w '%{http_code}' \
|
|
-X POST \
|
|
-H "Authorization: token $GITHUB_TOKEN" \
|
|
-H "Content-Type: application/octet-stream" \
|
|
-H "Accept: application/json" \
|
|
--data-binary "@/src/_src/PostIt.Android.apk" \
|
|
"?name=PostIt.Android.apk" \
|
|
"$API_BASE/api/v1/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets")
|
|
echo "POST asset -> HTTP $HTTP"
|
|
echo "::endgroup::"
|
|
|
|
if [[ "$HTTP" != "201" ]]; then
|
|
echo "::error::Asset upload failed (HTTP $HTTP):"
|
|
cat /tmp/asset.json
|
|
exit 1
|
|
fi
|
|
|
|
echo "Release publiée: $API_BASE/$GITHUB_REPOSITORY/releases/tag/$TAG" |