diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 0000000..88a4e4f --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,330 @@ +# Build and publish a postit-debian release on the Forgejo instance. +# +# Triggered by a push of a git tag. Validates the tag/changelog pair, +# builds the .deb for amd64 and arm64 (sequential cross-RID .NET +# publishes on a single amd64 runner container — matrix is not used +# here because the runner image pazof/yavsc-build-env has no Node, +# so actions/upload-artifact and actions/download-artifact (which +# require Node) cannot be used to pass the .deb files between jobs. +# All in one job, like yavsc's .forgejo/workflows/release.yml.), +# then publishes a Forgejo release via the REST API and uploads both +# .deb files as assets. +# +# Authentication: the runner auto-provides a token scoped to the +# repository. We read it once into the local env var FORGEJO_TOKEN +# and never reference the runtime-level name again. +# +# Why bash + jq + curl, no third-party actions: the runner's docker +# label points at pazof/yavsc-build-env, a Debian image with jq but +# without Node.js or python3. Any action like actions/checkout, +# rasterstate/forgejo-release-action, actions/upload-artifact, +# actions/download-artifact, etc. fails with "executable file not +# found in $PATH". Same constraint as yavsc's +# .forgejo/workflows/release.yml. +# +# Re-tag policy (cf. AGENTS.md "Re-tag = le mal") : on push de tag +# ou dispatch, on *réutilise* la release existante (via PATCH) au +# lieu d'en créer une nouvelle. Un tag Git pointe vers un commit +# fixe ; si le binaire change (rebuild après modif du packaging), +# on met à jour la release existante plutôt que d'en multiplier +# pour un même tag. Le permalien /releases/tag/ reste stable. +# +# Inter-step state: we persist values between steps via a plain +# env file under /tmp, sourced at the top of each step that needs +# it. This keeps the workflow self-contained and avoids any +# runtime variable names we did not choose. +name: Forgejo Release postit-deb + +on: + push: + tags: + - '*' + workflow_dispatch: + inputs: + tag: + description: 'Tag pazof/yavsc à packager (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 amd64 + build + # arm64 + publication via l'API REST Forgejo (pas d'actions + # tierces Node). + release: + runs-on: docker + container: + image: docker.io/pazof/yavsc-build-env:debian12-dotnet10-android36-v2 + env: + STATE_FILE: /tmp/release-state.env + steps: + - name: Installer les pré-requis de build (debhelper + icônes) + # L'image runner fournit déjà dotnet-sdk-10.0, git, jq, + # curl. On ajoute les outils spécifiques au packaging + # Debian (debhelper, imagemagick pour les icônes .png + # via `convert`, librsvg2-bin pour le SVG). + run: | + apt-get update + apt-get install -y --no-install-recommends \ + build-essential debhelper imagemagick librsvg2-bin \ + ca-certificates + rm -rf /var/lib/apt/lists/* + : > "$STATE_FILE" + + - name: Clone du repo au tag demandé + env: + TAG: ${{ forgejo.event_name == 'push' && forgejo.ref_name || inputs.tag }} + run: | + if [[ -z "$TAG" ]]; then + echo "::error::No tag provided. In workflow_dispatch, set the 'tag' input." + exit 1 + fi + + cd /src + if [[ ! -d _src/.git ]]; then + # Clone unshallow pour préserver l'historique — utile + # si un futur test en a besoin. Le coût est marginal + # pour ce repo (< 50 commits). + git clone https://forgejo.pschneider.fr/notazof/postit-debian.git _src + fi + + cd _src + git fetch --tags --force --prune origin + git checkout "$TAG" + + echo "Checked out at $(git rev-parse HEAD) on tag $TAG" + echo "TAG=$TAG" >> "$STATE_FILE" + + - name: Valider le tag et la section CHANGELOG + run: | + source "$STATE_FILE" + cd /src/_src + 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. + 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 garde le titre + # (ligne `## [TAG] - channel`) pour la vérification du + # canal, puis on l'exclut du body envoyé à la release. + 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 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 + + RELEASE_BODY=$(echo "$BODY" | tail -n +2) + IS_PRERELEASE=$([ "$CHANNEL" = "stable" ] && echo false || echo true) + + echo "Section CHANGELOG validée pour [$TAG] - $CHANNEL" + + # Persist values for the next steps via our local state file. + { + echo "RELEASE_BODY<> "$STATE_FILE" + + - name: Build .deb amd64 + env: + POSTIT_RUNTIME: linux-x64 + run: | + source "$STATE_FILE" + cd /src/_src + echo "→ Building amd64 for POSTIT_GIT_TAG=$TAG" + make deb POSTIT_GIT_TAG="$TAG" POSTIT_RUNTIME=linux-x64 + + - name: Build .deb arm64 + env: + POSTIT_RUNTIME: linux-arm64 + run: | + source "$STATE_FILE" + cd /src/_src + echo "→ Building arm64 for POSTIT_GIT_TAG=$TAG" + # Cross-RID .NET depuis un hôte amd64 : standard, pas + # besoin de runner arm64 natif. + make deb POSTIT_GIT_TAG="$TAG" POSTIT_RUNTIME=linux-arm64 + + - name: Localiser les .deb produits + run: | + source "$STATE_FILE" + cd /src + DEB_AMD64=$(find . -maxdepth 3 -name "postit_*${TAG}-1_amd64.deb" \ + -not -path "./_src/debian/*" -printf '%p\n' | head -1) + DEB_ARM64=$(find . -maxdepth 3 -name "postit_*${TAG}-1_arm64.deb" \ + -not -path "./_src/debian/*" -printf '%p\n' | head -1) + if [[ -z "$DEB_AMD64" || -z "$DEB_ARM64" ]]; then + echo "::error::Missing .deb files. amd64='$DEB_AMD64' arm64='$DEB_ARM64'" + ls -la /src/ 2>/dev/null || true + exit 1 + fi + echo "DEB_AMD64=/src/$DEB_AMD64" >> "$STATE_FILE" + echo "DEB_ARM64=/src/$DEB_ARM64" >> "$STATE_FILE" + echo "✓ Found both .deb files" + + - name: Publier la release Forgejo via l'API REST + env: + FORGEJO_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + FORGEJO_API_URL: ${{ forgejo.api_url }} + FORGEJO_REPOSITORY: ${{ forgejo.repository }} + run: | + source "$STATE_FILE" + if [[ -z "$TAG" ]]; then + echo "::error::No tag resolved for the API call." + exit 1 + fi + + # Le runner Forgejo expose l'API sur forgejo.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="${FORGEJO_API_URL%/}" + API_BASE="${API_BASE%/api/v1}" + + # 1. Vérifier si la release existe déjà pour ce tag. + # Politique : on réutilise (PATCH) plutôt que d'en + # créer une nouvelle — cf. note "Re-tag policy" en + # tête de fichier. + echo "::group::Check existing release for tag $TAG" + HTTP=$(curl -sS -o /tmp/existing.json -w '%{http_code}' \ + -H "Authorization: token $FORGEJO_TOKEN" \ + -H "Accept: application/json" \ + "$API_BASE/api/v1/repos/$FORGEJO_REPOSITORY/releases/tags/$TAG") + echo "GET releases/tags/$TAG -> HTTP $HTTP" + EXISTING_ID="" + if [[ "$HTTP" == "200" ]]; then + EXISTING_ID=$(jq -r '.id // empty' /tmp/existing.json) + 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" + jq -n \ + --arg body "$RELEASE_BODY" \ + --argjson prerelease "$IS_PRERELEASE" \ + '{body: $body, prerelease: $prerelease}' \ + > /tmp/patch.json + HTTP=$(curl -sS -o /tmp/release.json -w '%{http_code}' \ + -X PATCH \ + -H "Authorization: token $FORGEJO_TOKEN" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + --data-binary @/tmp/patch.json \ + "$API_BASE/api/v1/repos/$FORGEJO_REPOSITORY/releases/$EXISTING_ID") + echo "PATCH release -> HTTP $HTTP" + echo "::endgroup::" + else + echo "::group::Create release" + jq -n \ + --arg tag "$TAG" \ + --arg name "$TAG" \ + --arg body "$RELEASE_BODY" \ + --argjson prerelease "$IS_PRERELEASE" \ + '{tag_name: $tag, name: $name, body: $body, prerelease: $prerelease}' \ + > /tmp/post.json + HTTP=$(curl -sS -o /tmp/release.json -w '%{http_code}' \ + -X POST \ + -H "Authorization: token $FORGEJO_TOKEN" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + --data-binary @/tmp/post.json \ + "$API_BASE/api/v1/repos/$FORGEJO_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=$(jq -r '.id' /tmp/release.json) + echo "Release id=$RELEASE_ID" + + # 3. Upload les .deb en assets. Le nom du fichier passe + # en query string (?name=...), pas en argument + # positionnel entre --data-binary et l'URL. + for entry in "amd64:$DEB_AMD64" "arm64:$DEB_ARM64"; do + arch="${entry%%:*}" + deb="${entry#*:}" + echo "::group::Upload asset for arch=$arch: $deb" + HTTP=$(curl -sS -o /tmp/asset.json -w '%{http_code}' \ + -X POST \ + -H "Authorization: token $FORGEJO_TOKEN" \ + -H "Content-Type: application/octet-stream" \ + -H "Accept: application/json" \ + --data-binary "@$deb" \ + "$API_BASE/api/v1/repos/$FORGEJO_REPOSITORY/releases/$RELEASE_ID/assets?name=$(basename "$deb")") + echo "POST asset ($arch) -> HTTP $HTTP" + echo "::endgroup::" + + if [[ "$HTTP" != "201" ]]; then + echo "::error::Asset upload failed for $arch (HTTP $HTTP):" + cat /tmp/asset.json + exit 1 + fi + done + + echo "Release publiée : $API_BASE/$FORGEJO_REPOSITORY/releases/tag/$TAG" diff --git a/.github/workflows/build-and-release-deb.yml b/.github/workflows/build-and-release-deb.yml deleted file mode 100644 index c485ef6..0000000 --- a/.github/workflows/build-and-release-deb.yml +++ /dev/null @@ -1,283 +0,0 @@ -name: Build and Release postit-deb - -on: - push: - branches: - - main - tags: - - '*' - workflow_dispatch: - inputs: - tag: - description: 'Tag de pazof/yavsc à packager (ex. 1.0.6, 1.0.7-rc1). Requis pour un build ad-hoc.' - 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 - force_republish: - description: 'Re-publier une release dont le tag existe déjà. Par défaut refusé (re-tag = le mal).' - required: false - type: boolean - default: false - -# softprops/action-gh-release a besoin de contents: write -# pour publier une release + uploader un asset. -permissions: - contents: write - -jobs: - # Build matrix : un .deb par architecture. Le tag Git poussé sur - # ce dépôt devient POSTIT_GIT_TAG pour `make deb`, qui clone - # l'amont pazof/yavsc à ce tag et produit le .deb correspondant. - # Sur amd64, la cross-compilation linux-arm64 marche nativement - # (dotnet publish --runtime linux-arm64 depuis un hôte amd64). - # On évite donc les runners arm64 natifs (qui existent mais sont - # récents et plus chers en minutes). - deb-build: - name: Build .deb (${{ matrix.runtime }}) - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - runtime: linux-x64 - arch: amd64 - artifact_name: postit-amd64 - - runtime: linux-arm64 - arch: arm64 - artifact_name: postit-arm64 - steps: - - name: Checkout postit-debian - uses: actions/checkout@v7 - with: - fetch-depth: 0 - fetch-tags: true - - - name: Installer les pré-requis de build (debhelper + icônes) - run: | - sudo apt-get update - sudo apt-get install -y \ - build-essential debhelper imagemagick librsvg2-bin \ - git ca-certificates - - - name: Installer .NET SDK 10 - uses: microsoft/setup-dotnet@v4 - with: - dotnet-version: '10.0.x' - - - name: Déterminer POSTIT_GIT_TAG - id: tag - run: | - # Sur un push de branche (pas un tag), github.ref_name est - # 'main' — `make deb POSTIT_GIT_TAG=main` clone pazof/yavsc - # sur la branche main et produit un .deb à jour. Sur un push - # de tag, c'est le numéro de tag (ex. '1.0.6'). Sur - # workflow_dispatch, on lit l'input `tag`. - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - TAG="${{ inputs.tag }}" - else - TAG="${{ github.ref_name }}" - fi - if [[ -z "$TAG" ]]; then - echo "::error::POSTIT_GIT_TAG is empty. Pour workflow_dispatch, l'input 'tag' est obligatoire." - exit 1 - fi - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - echo "→ POSTIT_GIT_TAG=$TAG" - - - name: Build du .deb via make deb - env: - POSTIT_GIT_TAG: ${{ steps.tag.outputs.tag }} - POSTIT_RUNTIME: ${{ matrix.runtime }} - run: | - echo "→ Building for POSTIT_GIT_TAG=$POSTIT_GIT_TAG POSTIT_RUNTIME=$POSTIT_RUNTIME" - make deb POSTIT_GIT_TAG="$POSTIT_GIT_TAG" POSTIT_RUNTIME="$POSTIT_RUNTIME" - - - name: Localiser le .deb produit - id: locate - run: | - # Le Makefile mv les .deb vers $POSTIT_OUT_DIR (par défaut - # le répertoire parent du repo). Sur GitHub Actions, c'est - # le workspace parent : /home/runner/work/.. Le .deb est - # nommé d'après le tag brut (avec ou sans 'v', tel quel - # poussé sur le remote), on cherche donc avec ref_name. - DEB=$(find /home/runner -maxdepth 4 -name "postit_*${{ github.ref_name }}-1_${{ matrix.arch }}.deb" \ - -not -path "*/debian/*" \ - -printf '%p\n' | head -1) - if [[ -z "$DEB" ]]; then - echo "::error::No .deb matching postit_*${{ github.ref_name }}-1_${{ matrix.arch }}.deb found." - echo "Files in parent dir:" - ls -la /home/runner/work/ 2>/dev/null || true - exit 1 - fi - echo "deb_path=$DEB" >> "$GITHUB_OUTPUT" - echo "✓ Found $DEB" - - - name: Téléverser le .deb en tant qu'Artéfact GitHub - uses: actions/upload-artifact@v7 - with: - name: ${{ matrix.artifact_name }} - path: ${{ steps.locate.outputs.deb_path }} - retention-days: 7 - - # Validation : parse le tag, applique la parité patch (pair=stable / - # impair=preview / suffixe=instable), vérifie que CHANGELOG.md - # contient une section cohérente, et — point non négociable — - # refuse de re-publier un tag qui existe déjà (re-tag = le mal). - validate-release: - # Tourne sur push de tag (release officielle) ou sur workflow_dispatch - # avec un tag explicite (release ad-hoc). Sur push de branche, on - # ne publie pas — les jobs de build suffisent (artefacts seulement). - if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest - steps: - - name: Checkout postit-debian - uses: actions/checkout@v7 - with: - fetch-depth: 0 - fetch-tags: true - - - name: Déterminer le tag à publier - id: pick_tag - run: | - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - TAG="${{ inputs.tag }}" - else - TAG="${{ github.ref_name }}" - fi - if [[ -z "$TAG" ]]; then - echo "::error::Tag is empty. Sur workflow_dispatch, l'input 'tag' est obligatoire." - exit 1 - fi - # Strip leading 'v' (git tag convention). - if [[ "$TAG" =~ ^v(.*)$ ]]; then - TAG="${BASH_REMATCH[1]}" - echo "Stripped leading 'v' — using TAG=$TAG for validation." - fi - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - - - name: Valider le tag, le CHANGELOG et l'unicité du tag - env: - FORCE_UNSTABLE: ${{ inputs.force_unstable || github.event.inputs.force_unstable || 'false' }} - FORCE_REPUBLISH: ${{ inputs.force_republish || github.event.inputs.force_republish || 'false' }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - TAG="${{ steps.pick_tag.outputs.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. - 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" != "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]. awk en mode paragraphe. - BODY=$(awk -v tag="[$TAG]" ' - /^## \[/ { - if (in_section) exit - if (index($0, tag) > 0) in_section=1 - 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é. - HEADER=$(grep -m1 "^## \[$TAG\]" CHANGELOG.md) - if [[ "$HEADER" != *" - $CHANNEL"* ]]; then - echo "::error::Section '## [$TAG]' must declare suffix '- $CHANNEL' to match tag parity." - echo "Current section header: $HEADER" - exit 1 - fi - - echo "Section CHANGELOG validée pour [$TAG] - $CHANNEL" - - # Anti-re-tag : refuse de publier si une release existe déjà - # pour ce tag. softprops/action-gh-release créerait sinon une - # nouvelle release par-dessus (re-tag = le mal). Opt-in via - # workflow_dispatch + force_republish=true uniquement. - if gh release view "$TAG" >/dev/null 2>&1; then - if [[ "$FORCE_REPUBLISH" != "true" ]]; then - echo "::error::Release for tag '$TAG' already exists. Refusing to re-tag." - echo "Set force_republish=true via workflow_dispatch to override." - exit 1 - else - echo "::warning::Release '$TAG' already exists — force_republish=true, proceeding." - fi - else - echo "✓ No existing release for tag '$TAG'." - fi - - # Exposition aux étapes suivantes via $GITHUB_ENV. - { - echo "RELEASE_BODY<> "$GITHUB_ENV" - - publish-release: - if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' - needs: [deb-build, validate-release] - runs-on: ubuntu-latest - steps: - - name: Récupérer les .deb depuis les artefacts - uses: actions/download-artifact@v7 - with: - path: ./ - merge-multiple: true - - - name: Lister les .deb téléchargés - run: ls -la ./ - - - name: Publier la release GitHub et uploader les .deb - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ env.RELEASE_TAG }} - files: | - ./postit-amd64/*.deb - ./postit-arm64/*.deb - body: ${{ env.RELEASE_BODY }} - prerelease: ${{ env.IS_PRERELEASE }}