name: Release (master) # Version-gated release on every master merge. The single source of truth is # in barakoCMS/barakoCMS.csproj: # * bump it in your PR -> merging publishes that version (NuGet + GitHub # Packages + Docker images) and promotes it to playground.baryo.dev. # * leave it unchanged -> the gate sees the version is already on NuGet and # the whole pipeline is a no-op. No accidental re-publishes, no surprise # deploys. To ship, bump the version. # # The breakable dev-playground tier (deploy-dev-playground.yml) is where you # iterate; this is the deliberate, versioned promotion to production. # # The order runs from cheap and reversible to expensive and permanent: # # gate -> test -> verify-packages -> build images # -> deploy-playground (and prove it is running THIS commit) # -> approval on the `nuget` environment # -> publish-packages, publish-images # -> tag v and write the GitHub Release # # Publishing used to come before the deploy, which put the one step that cannot be # undone ahead of the one that finds the failures (#157). NuGet has no delete, only # unlist, and a version anyone has already resolved stays resolved. on: push: branches: ["master"] paths-ignore: - "docs/**" - "**/*.md" - ".github/workflows/deploy-dev-playground.yml" workflow_dispatch: concurrency: group: release cancel-in-progress: false jobs: # Read the version and decide whether there's anything to release. Everything # downstream is guarded on should_release, so an unchanged version costs one # cheap job and nothing else. gate: name: Version gate runs-on: ubuntu-latest outputs: version: ${{ steps.check.outputs.version }} should_release: ${{ steps.check.outputs.should_release }} steps: - uses: actions/checkout@v7 - id: check run: | set -euo pipefail VERSION=$(sed -n 's/.*\(.*\)<\/Version>.*/\1/p' barakoCMS/barakoCMS.csproj | head -1) if [ -z "$VERSION" ]; then echo "Could not read from barakoCMS/barakoCMS.csproj" >&2 exit 1 fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" # NuGet flat-container lists every published version of a package # (id lowercased). If this version is already there, there's nothing # to release. PUBLISHED=$(curl -s "https://api.nuget.org/v3-flatcontainer/barakocms/index.json" \ | grep -o "\"$VERSION\"" || true) if [ -n "$PUBLISHED" ]; then echo "should_release=false" >> "$GITHUB_OUTPUT" echo "::notice::BarakoCMS $VERSION is already on NuGet — nothing to release. Bump to ship." else echo "should_release=true" >> "$GITHUB_OUTPUT" echo "::notice::Releasing BarakoCMS $VERSION." fi # The publish job names the `nuget` environment so a person has to approve the one step that # cannot be undone (#203). An environment with no protection rules approves everything # silently, and naming one that does not exist creates it that way, so the approval would be # a line of YAML and nothing else. The `playground` environment on this same workflow has # protection_rules: [] today, which is what that looks like from the outside: an environment # in the job, no gate in the run. # # Checked here rather than in the publish job, because by then the packages are built and the # playground is deployed, and the answer would arrive after the expensive part. # # Fails closed. If this step cannot read the environment it stops the release, which is the # only safe direction for a check whose whole purpose is to confirm a gate exists. - name: A human is required on the nuget environment if: steps.check.outputs.should_release == 'true' env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail if ! gh api "repos/${{ github.repository }}/environments/nuget" > /tmp/env.json 2>/tmp/env.err; then echo "::error::Could not read the 'nuget' environment. Create it in Settings > Environments and add yourself as a required reviewer." cat /tmp/env.err exit 1 fi REVIEWERS=$(jq '[.protection_rules[]? | select(.type == "required_reviewers")] | length' /tmp/env.json) case "$REVIEWERS" in ''|*[!0-9]*) echo "::error::Could not read the environment's protection rules, so failing closed."; exit 1 ;; esac if [ "$REVIEWERS" -eq 0 ]; then echo "::error::The 'nuget' environment has no required reviewers, so the approval on publish-packages approves itself. Add a reviewer in Settings > Environments > nuget." exit 1 fi echo "::notice::The nuget environment has a required reviewer. Publishing will wait for approval." test: name: Build, test, pack needs: gate if: needs.gate.outputs.should_release == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-dotnet@v6 with: dotnet-version: 10.0.x # One compilation, for the whole solution. Every step below runs --no-build against this # output, so the assemblies the tests exercise are the same files that get packed and # pushed. Before this, test and publish each compiled independently and "we ship what we # tested" held only for as long as two separate builds happened to agree. - name: Build the solution once run: | dotnet restore barakoCMS.sln dotnet build barakoCMS.sln --no-restore --configuration Release # The step below names one project. That is fine while there is one, and it is a silent hole # the moment somebody adds a second: a new suite would sit in the repo, never run, and the # release would go out anyway. Discovering the projects here means the omission is a failed # build rather than something nobody notices until the package it covered ships broken. - name: Every test project is covered by the step below run: | set -euo pipefail FOUND=$(find . -name '*.Tests.csproj' -not -path './**/bin/*' -not -path './**/obj/*' | sort) EXPECTED="./BarakoCMS.Tests/BarakoCMS.Tests.csproj" [ "$FOUND" = "$EXPECTED" ] || { echo "::error::Test projects on disk do not match what this workflow runs." echo "found:"; echo "$FOUND" echo "run:"; echo "$EXPECTED" echo "Add the new project to the Backend tests step, then update this list." exit 1 } - name: Backend tests run: > dotnet test --project BarakoCMS.Tests/BarakoCMS.Tests.csproj --no-build --configuration Release --report-trx --report-trx-filename results.trx --results-directory ./testresults env: DOCKER_HOST: unix:///var/run/docker.sock # `dotnet test` exits 0 when it discovers nothing. A filter that matches no tests, a project # that fails to discover, a target-framework change that leaves the runner with no assembly: # every one of those is a green step. Without this, the gate in front of fourteen published # packages is "a command exited 0", and one of the ways it exits 0 is by doing nothing. - name: Confirm the suite actually ran run: | set -euo pipefail TRX=$(find ./testresults -name '*.trx' | head -1) [ -n "$TRX" ] || { echo "::error::No .trx was produced, so nothing can be verified."; exit 1; } TOTAL=$(grep -o 'total="[0-9]*"' "$TRX" | head -1 | grep -o '[0-9]*' || true) FAILED=$(grep -o 'failed="[0-9]*"' "$TRX" | head -1 | grep -o '[0-9]*' || true) # Both counters must actually parse. Defaulting a missing or malformed value to 0 makes # the zero-failure assertion pass on an unreadable report, which is the same fail-open # shape this whole gate exists to remove. case "$TOTAL" in ''|*[!0-9]*) echo "::error::Could not read a numeric total from $TRX."; exit 1 ;; esac case "$FAILED" in ''|*[!0-9]*) echo "::error::Could not read a numeric failed count from $TRX."; exit 1 ;; esac echo "ran $TOTAL, failed $FAILED" # A floor, not an exact count. An exact count becomes a chore on every PR and then gets # deleted; a floor only moves when someone removes a lot of tests. 1057 pass today. [ "${TOTAL:-0}" -ge 900 ] || { echo "::error::Only $TOTAL tests ran, expected at least 900. The suite did not run." exit 1 } # Asserted separately rather than as passed == total, because a skipped test breaks that # comparison and there is one skipped today. [ "${FAILED:-0}" -eq 0 ] || { echo "::error::$FAILED test(s) failed."; exit 1; } # --no-build throughout: these packages come out of the build the tests just ran against. # # Every module still carries its own in its csproj; from 4.0.0 they are set to the # same number as the core rather than each drifting on its own 0.x track. Nothing here reads # the release version to build a filename, so a module that ever diverges again still packs # correctly, and anything parsing these filenames must keep taking the version from the name. - name: Pack run: | set -euo pipefail for p in barakoCMS BarakoCMS.Accounting BarakoCMS.Import BarakoCMS.Files BarakoCMS.Files.S3 BarakoCMS.Email.Resend BarakoCMS.DeviceTrust BarakoCMS.ExternalAuth BarakoCMS.FeatureFlags BarakoCMS.Portability BarakoCMS.Diagnostics BarakoCMS.Analytics.Umami BarakoCMS.Pwa BarakoCMS.AI; do dotnet pack "$p/$p.csproj" --no-build --configuration Release -o out done # A software bill of materials is a hard requirement under the EU Cyber Resilience Act and US # federal procurement, and this release produced none for any of the fourteen packages or the # image. CycloneDX because it is the common answer in .NET tooling; the format matters less # than one existing, but being consistent about which one does matter. # # Generated from the solution rather than per project: one dependency graph covering # everything shipped, which is what a reviewer wants, and it does not go stale project by # project when a reference moves. - name: Generate the SBOM run: | set -euo pipefail dotnet tool install --global CycloneDX --version 5.4.0 export PATH="$PATH:$HOME/.dotnet/tools" mkdir -p sbom dotnet CycloneDX barakoCMS.sln --output sbom --filename barakoCMS.cdx.json --json # npm ci here rather than reusing the admin job: that job runs in parallel on a different # runner, so its node_modules do not exist on this one. npm --prefix admin ci --ignore-scripts npx --yes @cyclonedx/cyclonedx-npm --output-file sbom/barako-admin.cdx.json admin/package.json # A tool that writes nothing still exits 0, so check the files rather than the command. for f in sbom/barakoCMS.cdx.json sbom/barako-admin.cdx.json; do [ -s "$f" ] || { echo "::error::$f is missing or empty."; exit 1; } COMPONENTS=$(python3 -c "import json,sys; print(len(json.load(open('$f')).get('components', [])))") [ "$COMPONENTS" -gt 0 ] || { echo "::error::$f lists no components, so it describes nothing."; exit 1; } echo "$f: $COMPONENTS components" done - name: Upload the SBOM uses: actions/upload-artifact@v7 with: name: sbom path: sbom/*.json if-no-files-found: error retention-days: 90 # `*.*nupkg`, not `*.nupkg`, so the symbol packages come along. Directory.Build.props has set # IncludeSymbols and SymbolPackageFormat=snupkg since Source Link went in, and pack has been # writing out/*.snupkg all along, but this pattern never matched them. The publish job pushes # from this artifact and nothing else, so every Source Link build shipped no symbols at all # and no step went red about it. The assertion in verify-packages keeps it that way. - name: Upload the packages uses: actions/upload-artifact@v7 with: name: nupkg path: out/*.*nupkg if-no-files-found: error retention-days: 7 # Between pack and push: does the thing we are about to publish actually install? Nothing inside # the solution can answer that, because inside the solution every reference resolves by project. # A missing nuspec dependency, an undeclared target framework or a lib/ folder in the wrong place # all survive a green test run and only surface on a consumer's restore. verify-packages: name: Install the packages as a consumer needs: [gate, test] if: needs.gate.outputs.should_release == 'true' runs-on: ubuntu-latest steps: # No actions/checkout on purpose. This job must not be able to see the source, or a mistake # here could pass by resolving a project reference instead of the packed artifact. # # Do not add `cache: true` here either. A warm NuGet cache serves an already-extracted copy # of a package instead of opening the .nupkg we just built, so a package whose contents are # broken restores from cache and passes. Verified: with a warm cache the assembly assertion # below does not fire; with a cold one it does. This job needs the cold path. - uses: actions/setup-dotnet@v6 with: dotnet-version: 10.0.x - uses: actions/download-artifact@v8 with: name: nupkg path: packages # The release claims to produce an SBOM, so this job proves the file exists and describes # something. The pattern is the same as the assembly assertion below: a step that produced no # output still exits 0, and the release should not be able to say "SBOM" while shipping none. - uses: actions/download-artifact@v8 with: name: sbom path: sbom - name: Confirm the SBOM was produced run: | set -euo pipefail for f in sbom/barakoCMS.cdx.json sbom/barako-admin.cdx.json; do [ -s "$f" ] || { echo "::error::$f is missing or empty."; exit 1; } COMPONENTS=$(python3 -c "import json; print(len(json.load(open('$f')).get('components', [])))") [ "$COMPONENTS" -gt 0 ] || { echo "::error::$f lists no components."; exit 1; } echo "$f: $COMPONENTS components" done # The release claims Source Link, and Source Link without a published .snupkg gives a consumer # nothing to step into. The upload pattern silently dropped every symbol package for as long # as it read `out/*.nupkg`, and no step noticed, so assert the pairing rather than trust the # pattern. # # The loops below still read `*.nupkg` and still see exactly the fourteen packages: a glob # ending in the literal `.nupkg` does not match a file ending in `.snupkg`. That is why the # upload above needed `*.*nupkg` in the first place. - name: Confirm a symbol package sits beside every package run: | set -euo pipefail COUNT=0 for f in packages/*.nupkg; do sym="${f%.nupkg}.snupkg" [ -s "$sym" ] || { echo "::error::$(basename "$f") has no .snupkg beside it, so it ships no symbols."; exit 1; } COUNT=$((COUNT + 1)) done [ "$COUNT" -eq 14 ] || { echo "::error::Checked $COUNT packages for symbols, expected 14."; exit 1; } echo "all $COUNT packages have a symbol package" - name: Restore, build and load every package from a scratch project run: | set -euo pipefail ls packages/*.nupkg >/dev/null 2>&1 || { echo "::error::No .nupkg was downloaded."; exit 1; } FEED="$(pwd)/packages" dotnet new web -o consumer --force >/dev/null cd consumer # The local feed sits alongside nuget.org rather than replacing it: these packages' # own dependencies (Marten, FastEndpoints) still have to resolve from upstream. dotnet nuget add source "$FEED" --name local-artifact COUNT=0 for f in "$FEED"/*.nupkg; do base=$(basename "$f" .nupkg) ver=$(echo "$base" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$' || true) [ -n "$ver" ] || { echo "::error::Could not read a version out of $base."; exit 1; } id=${base%".$ver"} echo "::group::$id $ver" # Pinned. Without --version this could silently resolve a higher version already on # nuget.org and prove nothing about the file we just built. dotnet add package "$id" --version "$ver" echo "::endgroup::" COUNT=$((COUNT + 1)) done # A loop over an empty directory succeeds and reports nothing, which is the same # fail-open shape the test-count gate above exists to close. [ "$COUNT" -eq 14 ] || { echo "::error::Installed $COUNT packages, expected 14."; exit 1; } # Building with all fourteen referenced at once also proves they co-resolve: two modules # pinning incompatible versions of a shared dependency fails here and nowhere earlier. dotnet build --configuration Release # Restore succeeding is not the same as the package delivering anything. A nuspec that # declares no net10.0 lib/ folder restores clean and contributes no assembly, so assert # each one actually reached the output. for f in "$FEED"/*.nupkg; do base=$(basename "$f" .nupkg) ver=$(echo "$base" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$') id=${base%".$ver"} found=$(find bin/Release -iname "$id.dll" | head -1) [ -n "$found" ] || { echo "::error::$id restored but shipped no net10.0 assembly."; exit 1; } done echo "all $COUNT packages installed, built and loaded" # A library must not inject files into its consumer's project. The core is a # Microsoft.NET.Sdk.Web project, where appsettings*.json are Content items and pack ships # Content by default, so 3.21.0 shipped content/appsettings.json and # contentFiles/any/net10.0/appsettings.json and dropped the host's own configuration into # every consumer, to collide with theirs at build and publish. # # Checked on the .nupkg rather than on the restored project, because a content file that # lands somewhere unexpected still ships. cd .. for f in "$FEED"/*.nupkg; do entries=$(unzip -Z1 "$f" | grep -E '^(content|contentFiles)/' || true) if [ -n "$entries" ]; then echo "::error::$(basename "$f") ships content files into consumer projects:" echo "$entries" exit 1 fi done echo "no package injects content files" # Pushes the exact files that were tested and install-verified. Deliberately has no # actions/checkout: without the source this job *cannot* rebuild, even by accident. If you find # yourself adding a checkout here, what you actually want is a step in the test job. publish-packages: # The version is in the job name because that is what the approval prompt shows. Approving # "Publish packages" is a habit; approving "Publish packages 3.21.0 to NuGet" is a decision # about a number (#203). name: Publish packages ${{ needs.gate.outputs.version }} to NuGet # Waits on the playground, which is the ordering change in #157: the irreversible step runs # last. NuGet has no delete, only unlist, and anyone who already resolved a version keeps it. needs: [gate, test, verify-packages, deploy-playground] if: needs.gate.outputs.should_release == 'true' runs-on: ubuntu-latest # The approval (#203). The gate job has already checked this environment has a required # reviewer, because an environment without one waves everything through in silence. environment: name: nuget url: https://www.nuget.org/packages/BarakoCMS/${{ needs.gate.outputs.version }} permissions: contents: read packages: write steps: - uses: actions/setup-dotnet@v6 with: dotnet-version: 10.0.x - uses: actions/download-artifact@v8 with: name: nupkg path: out # The pattern stays `*.nupkg`. `dotnet nuget push` is symbol-aware: it pushes each .nupkg and # then the .snupkg sitting next to it, which is why the artifact above has to carry both. - name: Publish to NuGet.org run: dotnet nuget push "out/*.nupkg" --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate # --no-symbols here because GitHub Packages has no symbol server. nuget.org is where the # .snupkg belongs, and it is pushed by the step above. - name: Publish to GitHub Packages run: dotnet nuget push "out/*.nupkg" --api-key ${{ secrets.GITHUB_TOKEN }} --source https://nuget.pkg.github.com/BaryoDev/index.json --skip-duplicate --no-symbols # Keeps its checkout: a Docker build genuinely needs the source tree. # Each architecture is built on a runner of that architecture and pushed by digest, then joined # into one manifest list by publish-images below. # # Not `platforms: linux/amd64,linux/arm64` on one runner, which is the one-line version: that # cross-builds the .NET suite under QEMU and is slow enough to be its own problem. The playground # job already proves the native-arm-runner approach works here. # # push-by-digest means these pushes carry no tag. Nothing is publicly resolvable until the merge # step below succeeds, so a half-finished matrix cannot leave :latest pointing at one architecture. build-images: name: Build ${{ matrix.image.name }} (${{ matrix.arch }}) needs: [gate, test, verify-packages] if: needs.gate.outputs.should_release == 'true' runs-on: ${{ matrix.runner }} permissions: contents: read packages: write env: VERSION: ${{ needs.gate.outputs.version }} strategy: fail-fast: false matrix: image: - name: barako-cms file: Dockerfile.suite context: . - name: barako-cms-decaf file: Dockerfile context: . - name: barako-admin file: admin/Dockerfile context: ./admin arch: [amd64, arm64] include: - arch: amd64 runner: ubuntu-latest platform: linux/amd64 - arch: arm64 runner: ubuntu-24.04-arm platform: linux/arm64 steps: - uses: actions/checkout@v7 - uses: docker/setup-buildx-action@v4 - uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push by digest id: build uses: docker/build-push-action@v7 with: context: ${{ matrix.image.context }} file: ${{ matrix.image.file }} platforms: ${{ matrix.platform }} # Stamped from the release version rather than read from package.json, which has said # 0.1.0 since the beginning. Harmless on the two API images, which ignore it. # # BARAKO_BUILD_SHA is what /health/build answers with. .git is in .dockerignore, so the # commit cannot be read inside the build; it has to be handed in here. build-args: | BARAKO_VERSION=${{ env.VERSION }} BARAKO_BUILD_SHA=${{ github.sha }} labels: org.opencontainers.image.source=https://github.com/BaryoDev/barakoCMS outputs: type=image,name=ghcr.io/baryodev/${{ matrix.image.name }},push-by-digest=true,name-canonical=true,push=true cache-from: type=gha,scope=${{ matrix.image.name }}-${{ matrix.arch }} cache-to: type=gha,mode=max,scope=${{ matrix.image.name }}-${{ matrix.arch }} # An empty digest here would make the merge step below build a manifest out of nothing, so it # fails now rather than producing an image that resolves to no architectures. - name: Record the digest run: | set -euo pipefail DIGEST="${{ steps.build.outputs.digest }}" case "$DIGEST" in sha256:*) ;; *) echo "::error::build produced no usable digest, got '$DIGEST'"; exit 1 ;; esac mkdir -p /tmp/digests echo "$DIGEST" > "/tmp/digests/${{ matrix.image.name }}.${{ matrix.arch }}" - uses: actions/upload-artifact@v7 with: name: digest-${{ matrix.image.name }}-${{ matrix.arch }} path: /tmp/digests/* if-no-files-found: error retention-days: 1 # Joins the per-architecture digests into one manifest list per image and applies the public tags. # Keeps the name publish-images because deploy-playground and notify-failure both depend on it. publish-images: name: Publish public images # Also behind the playground now. An image tag is mutable, so this is not the irreversible step, # but publishing :latest for a build nobody has run is still how a bad version reaches whoever # pulls next. needs: [gate, build-images, deploy-playground] if: needs.gate.outputs.should_release == 'true' runs-on: ubuntu-latest permissions: contents: read packages: write env: VERSION: ${{ needs.gate.outputs.version }} steps: - uses: docker/setup-buildx-action@v4 - uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Login to Docker Hub (optional mirror) id: dockerhub continue-on-error: true uses: docker/login-action@v4 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - uses: actions/download-artifact@v8 with: pattern: digest-* path: /tmp/digests merge-multiple: true - name: Create the manifest lists run: | set -euo pipefail V="${{ env.VERSION }}" for img in barako-cms barako-cms-decaf barako-admin; do REFS="" for arch in amd64 arm64; do F="/tmp/digests/$img.$arch" [ -s "$F" ] || { echo "::error::no digest for $img/$arch, refusing to publish a single-architecture image"; exit 1; } REFS="$REFS ghcr.io/baryodev/$img@$(cat "$F")" done docker buildx imagetools create \ --tag "ghcr.io/baryodev/$img:latest" \ --tag "ghcr.io/baryodev/$img:$V" \ $REFS done # The gate. Publishing a single-architecture image is invisible to everyone except whoever # pulls it on the wrong hardware, and they find out with an exec format error rather than a # message about architectures. It is checked on the pushed manifest rather than on the build # config, because the config being right is not evidence that the push was. - name: Every published image serves both architectures run: | set -euo pipefail V="${{ env.VERSION }}" for img in barako-cms barako-cms-decaf barako-admin; do PLATFORMS=$(docker buildx imagetools inspect --raw "ghcr.io/baryodev/$img:$V" \ | jq -r '[.manifests[]? | select(.platform.os != "unknown") | "\(.platform.os)/\(.platform.architecture)"] | sort | join(",")') case "$PLATFORMS" in ''|*[!a-z0-9/,]*) echo "::error::could not read platforms for $img, got '$PLATFORMS'"; exit 1 ;; esac echo "$img: $PLATFORMS" >> "$GITHUB_STEP_SUMMARY" [ "$PLATFORMS" = "linux/amd64,linux/arm64" ] || { echo "::error::$img publishes '$PLATFORMS', expected linux/amd64,linux/arm64"; exit 1; } done # imagetools create on a manifest list copies the list, so the mirror is multi-architecture # too. It would silently mirror one architecture if the source were single-architecture, which # is the other reason the check above runs before this. - name: Mirror images to Docker Hub if: steps.dockerhub.outcome == 'success' run: | set -euo pipefail V="${{ env.VERSION }}" for img in barako-cms barako-cms-decaf barako-admin; do docker buildx imagetools create \ --tag "arnelirobles/$img:latest" \ --tag "arnelirobles/$img:$V" \ "ghcr.io/baryodev/$img:$V" done # Native arm64 :playground images for the Ampere VM — built on an arm runner so # the suite compiles natively (no QEMU). The playground runs these directly. build-playground-images: name: Build arm64 playground images needs: [gate, test, verify-packages] if: needs.gate.outputs.should_release == 'true' runs-on: ubuntu-24.04-arm permissions: contents: read packages: write env: VERSION: ${{ needs.gate.outputs.version }} steps: - uses: actions/checkout@v7 - uses: docker/setup-buildx-action@v4 - uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push suite (arm64) uses: docker/build-push-action@v7 with: context: . file: Dockerfile.suite platforms: linux/arm64 push: true # The image the deploy check reads back from /health/build. Without this the playground # reports "unknown" and the release stops, which is the correct direction to fail. build-args: | BARAKO_BUILD_SHA=${{ github.sha }} tags: | ghcr.io/baryodev/barako-cms:playground ghcr.io/baryodev/barako-cms:playground-${{ env.VERSION }} labels: org.opencontainers.image.source=https://github.com/BaryoDev/barakoCMS cache-from: type=gha,scope=pg-api cache-to: type=gha,mode=max,scope=pg-api - name: Build and push admin (arm64) uses: docker/build-push-action@v7 with: context: ./admin file: admin/Dockerfile platforms: linux/arm64 push: true build-args: | NEXT_BASE_PATH=/barakocms NEXT_PUBLIC_API_URL=https://playground.baryo.dev/barakocms-api BARAKO_VERSION=${{ env.VERSION }} tags: | ghcr.io/baryodev/barako-admin:playground ghcr.io/baryodev/barako-admin:playground-${{ env.VERSION }} labels: org.opencontainers.image.source=https://github.com/BaryoDev/barakoCMS cache-from: type=gha,scope=pg-admin cache-to: type=gha,mode=max,scope=pg-admin # Runs before anything is published, which is the whole of #157. It used to need publish-packages # and publish-images, so the order was: tests pass, fourteen packages become permanent, and only # then does anything get deployed and looked at. # # A test host is not where this project's releases have broken. 3.14.0 crash-looped on any # existing database because a new Marten index is a delta CreateOnly refuses, and CI was green on # a fresh one. That class of failure needs a deployment to find, and a deployment is reversible in # a way a NuGet version is not. # # It no longer needs the public images either: the playground runs the arm64 :playground tags from # build-playground-images, not the multi-arch public ones. deploy-playground: name: Deploy to playground needs: [gate, build-playground-images] if: needs.gate.outputs.should_release == 'true' runs-on: ubuntu-latest environment: name: playground url: https://playground.baryo.dev/barakocms steps: - uses: actions/checkout@v7 # for scripts/smoke-test.sh # Forced-command key (/home/opc/deploy-playground.sh) — pulls both # :playground images, recreates app + admin, fails if either doesn't # answer 200. A leaked key cannot open a shell. - name: Pull and restart over SSH uses: appleboy/ssh-action@v1.2.5 with: host: ${{ secrets.ORACLE_HOST }} username: ${{ secrets.ORACLE_USER }} key: ${{ secrets.PLAYGROUND_DEPLOY_KEY }} command_timeout: 8m script: deploy # Expects 401, not 200. This gate exists to prove FastEndpoints mapped its routes behind the # public proxy, and an anonymous 401 proves exactly that: an unmapped route answers 404, so # 401 means the route is there and the auth pipeline ran. It used to ask /api/content-types # for a 200, which was weaker than it looked — that route read a document type nothing ever # wrote, so the 200 was real but the body was always empty (#124). /health covers app-up and # database-reachable separately, in the smoke test below. - name: Verify from the public URL run: | for i in $(seq 1 20); do code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 \ https://playground.baryo.dev/barakocms-api/api/schemas || true) if [ "$code" = "401" ]; then echo "playground API routed and refused an anonymous caller after ${i} attempt(s)" break fi sleep 3 done [ "${code:-}" = "401" ] || { echo "playground did not return 401 (last: ${code:-none}); 404 means routing is down, 200 means /api/schemas lost its role check" >&2; exit 1; } # Read-only smoke on the public demo (no writes — no SMOKE_WRITE, no creds), so we confirm the # app is live and its DB reachable without leaving test content on playground. - name: Smoke test (read-only) run: bash scripts/smoke-test.sh https://playground.baryo.dev/barakocms-api # Everything above this line is satisfied by the build that was already running. A 401, a 200 # and a healthy database are what the previous release returns too, so a deploy that pulled # nothing would pass all of them and the publish behind it would ship a build nothing had run. # # This compares the commit the deployed image was built from against the commit being # released. Identity, not a version string: two builds of 3.20.2 are the same characters # (#157). - name: The playground is running this build env: EXPECTED_SHA: ${{ github.sha }} run: bash scripts/check-deployed-build.sh https://playground.baryo.dev/barakocms-api "$EXPECTED_SHA" # Sixty-seven versions went to nuget.org and the newest git tag was v3.2.0, so the repository's # front page advertised a release from many versions ago and `git log v3.21.0..master` did not # resolve (#155). Nothing was broken; nothing said so either. # # After publishing, deliberately. A tag is a claim that this commit is that version, and until # the packages are up it is not one. The changelog section is read first, so a missing release # note fails before anything is tagged rather than producing an empty GitHub Release. # # Nothing here backfills the historical tags. Mapping sixty-odd versions to commits is a bigger # job than it is worth, and tagging from here forward fixes both the front page and the diff. tag-release: name: Tag v${{ needs.gate.outputs.version }} and write the release needs: [gate, publish-packages, publish-images] if: needs.gate.outputs.should_release == 'true' runs-on: ubuntu-latest permissions: contents: write env: VERSION: ${{ needs.gate.outputs.version }} GH_TOKEN: ${{ github.token }} steps: - uses: actions/checkout@v7 - name: Read the release notes out of the changelog run: bash scripts/release-notes.sh "$VERSION" > /tmp/notes.md # Re-runnable. A release that already exists is left alone rather than failing the job, so # re-running a partially failed release run does not need someone to delete a tag first. - name: Tag the release commit and publish the release run: | set -euo pipefail if gh release view "v$VERSION" >/dev/null 2>&1; then echo "::notice::Release v$VERSION already exists; leaving it alone." exit 0 fi gh release create "v$VERSION" \ --target "${{ github.sha }}" \ --title "v$VERSION" \ --notes-file /tmp/notes.md echo "::notice::Tagged v$VERSION and published the release." # Org discussions (best-effort). Discord is handled by announce-discord below, NOT here: passing the # multiline changelog through a job-output into a reusable-workflow input silently emptied it, so # every Discord post fell back to a generic line. No DISCORD_WEBHOOK is passed here on purpose. announce: name: Announce (discussions) needs: [gate, publish-packages] if: needs.gate.outputs.should_release == 'true' uses: BaryoDev/.github/.github/workflows/announce.yml@main with: package: "barakoCMS suite" version: ${{ needs.gate.outputs.version }} # The core package, not a module. This line interpolates the gate's version, which is core's # , so naming a module here only resolved if that module happened to share core's # number. None ever did: the highest BarakoCMS.Accounting on nuget.org is 0.3.1 against a core # at 3.21.0, so the announced command has never worked. Core is the one id that is guaranteed # to exist at this version, because publish-packages just pushed it. install: "dotnet add package BarakoCMS --version ${{ needs.gate.outputs.version }}" secrets: ANNOUNCE_TOKEN: ${{ secrets.ANNOUNCE_TOKEN }} # Detailed Discord announcement, via the reusable workflow that reads CHANGELOG in-job. Only the # version (a single-line string) crosses the boundary, so the notes can't be emptied in transit. announce-discord: name: Announce (Discord) needs: [gate, publish-packages] if: needs.gate.outputs.should_release == 'true' uses: ./.github/workflows/discord-announce.yml with: version: ${{ needs.gate.outputs.version }} secrets: inherit # A failed release (bad publish, deploy, or smoke) should ping. Best-effort; never fails the run. notify-failure: needs: [gate, test, verify-packages, publish-packages, publish-images, build-playground-images, deploy-playground, tag-release] if: failure() runs-on: ubuntu-latest steps: - name: Notify Discord env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} run: | [ -z "${DISCORD_WEBHOOK:-}" ] && { echo "no webhook; skipping"; exit 0; } payload=$(jq -n --arg u "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ '{content: ("🔴 barakoCMS release failed — <" + $u + ">")}') curl -s -o /dev/null -H "Content-Type: application/json" -X POST -d "$payload" "$DISCORD_WEBHOOK" || true