diff --git a/.gitea/workflows/build-app.yml b/.gitea/workflows/build-app.yml index 9ef4a7a..a73495a 100644 --- a/.gitea/workflows/build-app.yml +++ b/.gitea/workflows/build-app.yml @@ -1,20 +1,22 @@ name: Build App +# **Releases are cut by hand.** This runs only when someone dispatches it +# (Actions -> Build App -> Run workflow). Merging to main does not release: +# main can carry finished-but-dormant work (a feature landing in phases) and +# nothing reaches users until a release is cut on purpose. +# +# Dispatched on `main` it releases: Gitea and GitHub releases, the version tag, +# the Linux update channel, and then the container images' `:latest` (see the +# create-tag job). Dispatched on any other branch it is a dry run that builds, +# signs and verifies all three platforms and publishes nothing, which is how to +# exercise Windows signing before a merge. Every publishing step and job +# carries the same `if:` (dispatch event *and* ref main); keep them in step if +# one is ever added. on: - push: - branches: [main] - paths: - - "app/**" - - "VERSION" - - ".gitea/workflows/build-app.yml" - - "scripts/windows-*.ps1" workflow_dispatch: -# Deliberately **not** on pull_request. Every publishing step here is gated on -# `gitea.event_name == 'push'`, so a PR run compiled all three platforms and -# produced nothing — and it ran alongside build-app-preview.yml, which compiles -# the same three and publishes them. Six OS builds per push, one set of which -# was unreachable. Previews now carry the PR check; this workflow is releases. +# Deliberately **not** on pull_request: build-app-preview.yml carries the PR +# check (tests plus all three platforms), and this workflow is releases only. env: # The ephemeral Proxmox runners (eph-pve3-*) ship needrestart, which after @@ -262,7 +264,7 @@ jobs: fi - name: Upload to Gitea release - if: gitea.event_name == 'push' + if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main' env: TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | @@ -288,7 +290,7 @@ jobs: curl -fsS -X POST \ -H "Authorization: token ${TOKEN}" \ -H "Content-Type: application/json" \ - -d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C ${TAG} (Linux)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \ + -d "{\"tag_name\": \"${TAG}\", \"target_commitish\": \"${{ gitea.sha }}\", \"name\": \"Triple-C ${TAG} (Linux)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \ "${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json ;; *) @@ -340,7 +342,7 @@ jobs: # from the versioned release above because the updater's URL must never # move, and `releases/latest` does. - name: Publish the Linux update channel - if: gitea.event_name == 'push' + if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main' env: GH_PAT: ${{ secrets.GH_PAT }} GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} @@ -429,7 +431,7 @@ jobs: ls -la artifacts/ - name: Upload to Gitea release - if: gitea.event_name == 'push' + if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main' env: TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | @@ -455,7 +457,7 @@ jobs: curl -fsS -X POST \ -H "Authorization: token ${TOKEN}" \ -H "Content-Type: application/json" \ - -d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C v${{ needs.compute-version.outputs.version }} (macOS)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \ + -d "{\"tag_name\": \"${TAG}\", \"target_commitish\": \"${{ gitea.sha }}\", \"name\": \"Triple-C v${{ needs.compute-version.outputs.version }} (macOS)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \ "${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json ;; *) @@ -717,7 +719,7 @@ jobs: dir artifacts\ - name: Upload to Gitea release - if: gitea.event_name == 'push' + if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main' shell: powershell env: TOKEN: ${{ secrets.REGISTRY_TOKEN }} @@ -741,8 +743,9 @@ jobs: if ($_.Exception.Response.StatusCode.value__ -eq 404) { Write-Host "Release $tag not found, creating" $body = @{ - tag_name = $tag - name = "Triple-C v$env:VERSION (Windows)" + tag_name = $tag + target_commitish = $env:COMMIT_SHA + name = "Triple-C v$env:VERSION (Windows)" body = "Automated build from commit $env:COMMIT_SHA" } | ConvertTo-Json $release = Invoke-RestMethod -Method Post -Headers $headers ` @@ -778,7 +781,7 @@ jobs: create-tag: runs-on: ubuntu-latest needs: [compute-version, build-linux, build-macos, build-windows] - if: gitea.event_name == 'push' + if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main' steps: - name: Checkout uses: actions/checkout@v4 @@ -788,24 +791,56 @@ jobs: - name: Create version tag env: TOKEN: ${{ secrets.REGISTRY_TOKEN }} + VERSION: ${{ needs.compute-version.outputs.version }} + SHA: ${{ gitea.sha }} run: | - VERSION="${{ needs.compute-version.outputs.version }}" + set -euo pipefail TAG="v${VERSION}" - echo "Creating tag ${TAG}..." - # Create annotated tag via Gitea API - curl -s -X POST \ - -H "Authorization: token ${TOKEN}" \ - -H "Content-Type: application/json" \ - -d "{\"tag_name\": \"${TAG}\", \"target\": \"${{ gitea.sha }}\", \"message\": \"Release ${TAG}\"}" \ - "${GITEA_URL}/api/v1/repos/${REPO}/tags" || echo "Tag may already exist (created by release)" + # Usually already there: creating the Linux release creates its tag, + # at this commit (`target_commitish`). Create it if not, then insist + # it points here, because the container step below builds whatever + # the tag names. + if ! curl -fsS -o tag.json -H "Authorization: token ${TOKEN}" \ + "${GITEA_URL}/api/v1/repos/${REPO}/tags/${TAG}"; then + echo "Creating tag ${TAG} at ${SHA}" + curl -fsS -X POST \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\": \"${TAG}\", \"target\": \"${SHA}\", \"message\": \"Release ${TAG}\"}" \ + "${GITEA_URL}/api/v1/repos/${REPO}/tags" > tag.json + fi + TAGGED=$(python3 -c "import json; print(json.load(open('tag.json'))['commit']['sha'])") + if [ "${TAGGED}" != "${SHA}" ]; then + echo "Tag ${TAG} points at ${TAGGED}, not the released commit ${SHA}" >&2 + exit 1 + fi + echo "Tag ${TAG} is at ${SHA}" - echo "Tag ${TAG} created successfully" + # The containers' `:latest` is what every installed app pulls, so it + # moves with an app release and at no other time (pushes to main only + # publish `:edge` and `:`). Each container workflow builds the tag + # it is handed, not main, so a merge landing mid-release cannot ride + # along. + - name: Release the container images + env: + TOKEN: ${{ secrets.REGISTRY_TOKEN }} + VERSION: ${{ needs.compute-version.outputs.version }} + run: | + set -euo pipefail + for WORKFLOW in build.yml build-stt.yml; do + echo "Dispatching ${WORKFLOW} for v${VERSION}" + curl -fsS -X POST \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"ref\": \"main\", \"inputs\": {\"release_tag\": \"v${VERSION}\"}}" \ + "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/${WORKFLOW}/dispatches" + done sync-to-github: runs-on: ubuntu-latest needs: [compute-version, build-linux, build-macos, build-windows] - if: gitea.event_name == 'push' + if: gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main' env: GH_PAT: ${{ secrets.GH_PAT }} GITHUB_REPO: shadowdao/triple-c diff --git a/.gitea/workflows/build-stt.yml b/.gitea/workflows/build-stt.yml index a8c38c5..571faa2 100644 --- a/.gitea/workflows/build-stt.yml +++ b/.gitea/workflows/build-stt.yml @@ -11,6 +11,12 @@ on: paths: - "stt-container/**" - ".gitea/workflows/build-stt.yml" + # Dispatched by build-app.yml when a release is cut, with the release's tag. + workflow_dispatch: + inputs: + release_tag: + description: "Release tag to build as :latest (default: the newest vX.Y.Z)" + required: false env: REGISTRY: repo.anhonesthost.net @@ -22,6 +28,65 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # **`:latest` is what installed apps pull, so only a release moves it.** + # A push to main publishes `:edge` and `:` (for testing a merged + # image by hand); a pull request publishes nothing. The weekly rebuild + # and a release dispatch build a release tag and publish it as `:latest` + # — the tag, not main, so unreleased container changes never reach users. + - name: Pick the source and the tags to publish + id: src + env: + EVENT: ${{ gitea.event_name }} + RELEASE_TAG: ${{ gitea.event.inputs.release_tag }} + REF: ${{ gitea.ref }} + run: | + set -euo pipefail + case "$EVENT" in + push|pull_request) + CHANNEL=edge + ;; + schedule|workflow_dispatch) + CHANNEL=latest + # `:latest` reaches every user, so it only comes from main: a + # dispatch from another branch, or a tag cut on one, is refused. + # An older release tag is allowed on purpose — that is a rollback. + if [ "$REF" != "refs/heads/main" ]; then + echo "Refusing to publish :latest from $REF (only main)" >&2 + exit 1 + fi + git fetch --tags --force + git fetch origin main + if [ -z "${RELEASE_TAG:-}" ]; then + RELEASE_TAG=$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1) + fi + if ! echo "$RELEASE_TAG" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "Not a release tag: '$RELEASE_TAG'" >&2 + exit 1 + fi + if ! git merge-base --is-ancestor "refs/tags/$RELEASE_TAG" FETCH_HEAD; then + echo "$RELEASE_TAG is not on main; refusing to publish it as :latest" >&2 + exit 1 + fi + git checkout --detach "refs/tags/$RELEASE_TAG" + echo "Building release $RELEASE_TAG" + ;; + *) + echo "Unexpected event $EVENT" >&2 + exit 1 + ;; + esac + SHA=$(git rev-parse HEAD) + { + echo "tags<> "$GITHUB_OUTPUT" - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -49,12 +114,10 @@ jobs: context: ./stt-container file: ./stt-container/Dockerfile platforms: linux/amd64,linux/arm64 - push: ${{ gitea.event_name == 'push' }} - tags: | - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }} - ghcr.io/shadowdao/triple-c-stt:latest - ghcr.io/shadowdao/triple-c-stt:${{ gitea.sha }} + # A pull_request run only validates; see the tag step for what each + # other event publishes. + push: ${{ gitea.event_name != 'pull_request' }} + tags: ${{ steps.src.outputs.tags }} # The gha cache is served by the runner that picks the job up, and # is unreachable from the job on some of ours (the home box's # firewalled per-job bridge; ephemeral runners). Import failures diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index ecd4453..d2ea308 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -13,9 +13,15 @@ on: - ".gitea/workflows/build.yml" # Weekly rebuild with no commit needed, so OS security fixes published since # the last push reach the image (see APT_REFRESH in container/Dockerfile). - # Scheduled runs use the default branch, i.e. main. + # It rebuilds the newest *release*, not main, because it moves `:latest`. schedule: - cron: "17 4 * * 1" + # Dispatched by build-app.yml when a release is cut, with the release's tag. + workflow_dispatch: + inputs: + release_tag: + description: "Release tag to build as :latest (default: the newest vX.Y.Z)" + required: false env: REGISTRY: repo.anhonesthost.net @@ -27,6 +33,65 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # **`:latest` is what installed apps pull, so only a release moves it.** + # A push to main publishes `:edge` and `:` (for testing a merged + # image by hand); a pull request publishes nothing. The weekly rebuild + # and a release dispatch build a release tag and publish it as `:latest` + # — the tag, not main, so unreleased container changes never reach users. + - name: Pick the source and the tags to publish + id: src + env: + EVENT: ${{ gitea.event_name }} + RELEASE_TAG: ${{ gitea.event.inputs.release_tag }} + REF: ${{ gitea.ref }} + run: | + set -euo pipefail + case "$EVENT" in + push|pull_request) + CHANNEL=edge + ;; + schedule|workflow_dispatch) + CHANNEL=latest + # `:latest` reaches every user, so it only comes from main: a + # dispatch from another branch, or a tag cut on one, is refused. + # An older release tag is allowed on purpose — that is a rollback. + if [ "$REF" != "refs/heads/main" ]; then + echo "Refusing to publish :latest from $REF (only main)" >&2 + exit 1 + fi + git fetch --tags --force + git fetch origin main + if [ -z "${RELEASE_TAG:-}" ]; then + RELEASE_TAG=$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1) + fi + if ! echo "$RELEASE_TAG" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "Not a release tag: '$RELEASE_TAG'" >&2 + exit 1 + fi + if ! git merge-base --is-ancestor "refs/tags/$RELEASE_TAG" FETCH_HEAD; then + echo "$RELEASE_TAG is not on main; refusing to publish it as :latest" >&2 + exit 1 + fi + git checkout --detach "refs/tags/$RELEASE_TAG" + echo "Building release $RELEASE_TAG" + ;; + *) + echo "Unexpected event $EVENT" >&2 + exit 1 + ;; + esac + SHA=$(git rev-parse HEAD) + { + echo "tags<> "$GITHUB_OUTPUT" - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -81,16 +146,12 @@ jobs: context: ./container file: ./container/Dockerfile platforms: linux/amd64,linux/arm64 - # Publish from main pushes and the weekly scheduled rebuild (which - # runs on main); a pull_request run only validates. + # A pull_request run only validates; see the tag step for what each + # other event publishes. push: ${{ gitea.event_name != 'pull_request' }} build-args: | APT_REFRESH=${{ steps.apt.outputs.stamp }} - tags: | - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }} - ghcr.io/shadowdao/triple-c-sandbox:latest - ghcr.io/shadowdao/triple-c-sandbox:${{ gitea.sha }} + tags: ${{ steps.src.outputs.tags }} # `ignore-error` is what stops a cache failure failing a build that # already succeeded. act_runner emulates the GitHub Actions cache # service on the runner host's LAN address, and the `docker-container` diff --git a/CLAUDE.md b/CLAUDE.md index d2b4cd6..49dd0bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -907,6 +907,25 @@ a project's Docker volumes are deliberately out of scope — this is not a proje ## Packaging +### Releases are cut by hand + +**Merging to `main` does not release anything users get.** A release is cut by dispatching +`build-app.yml` on `main` (Actions -> Build App -> Run workflow). That one run publishes the Gitea +and GitHub releases for all three platforms, the `vX.Y.Z` tag pinned to the dispatched commit, and +the Linux update channel. Its last step dispatches `build.yml` and `build-stt.yml` with the new tag, +and those rebuild that tag and move `:latest`, which is what installed apps pull. + +- Dispatching `build-app.yml` on any other branch is a **dry run**. It builds, signs and verifies, + and publishes nothing. Every publishing step and job is gated on + `gitea.event_name == 'workflow_dispatch' && gitea.ref == 'refs/heads/main'`, so give any new + publishing step the same `if:`. +- A push to `main` that touches `container/` or `stt-container/` publishes only `:edge` and + `:`, never `:latest`. The weekly scheduled sandbox rebuild (OS security fixes) rebuilds the + **newest release tag**, not main. +- This exists so that main can carry dormant work (a feature landing in phases) without each + merge shipping. Until the gate, every merge to main touching `app/` released, and every merge + touching `container/` moved `:latest`. + Linux ships as **AppImage only**, built by `build-app.yml` (releases) and `build-app-preview.yml` (the PR check). The `.deb` and `.rpm` were dropped: two more artifacts to build and publish for an audience the AppImage already serves, and neither could self-update. The @@ -952,8 +971,8 @@ quietly. **PR previews are deliberately not signed**, and `build-app-preview.yml reference the signing secrets. Two reasons: signing is metered (about 1000 signatures a month, against roughly 50 preview builds a month), and a PR's workflow runs the PR's own code, so a secret available there is available to whoever can push a branch. To exercise signing before a -merge, dispatch `build-app.yml` on the branch. Every publishing step there is gated on -`gitea.event_name == 'push'`, so a dispatch builds, signs and verifies without releasing. +merge, dispatch `build-app.yml` on the branch. Off `main` it builds, signs and verifies +without releasing (see Releases above). - `scripts/windows-signing-setup.ps1` runs once per job. It downloads the signing client (`Microsoft.ArtifactSigning.Client`) and a .NET runtime into `.code-signing/` in the workspace, @@ -986,8 +1005,8 @@ app"), never by the preview workflow, never echoed, and never on a command line. public, so its Actions logs are too. Gitea masks the secret values, and the signing dlib's `/debug` output carries no tokens (checked against its strings). Anyone who can push to this repo can reach the secrets through a workflow file, so repo write access is the boundary. -`main` is branch-protected (no direct or force pushes; changes land by merging a PR), so a signed -release only ever comes from a merged, visible change. The +`main` is branch-protected (no direct or force pushes; changes land by merging a PR), and only a +dispatch on `main` publishes, so a signed release only ever comes from a merged, visible change. The Azure side should hold the rest: an app registration with only the signer role on this one certificate profile, and a client secret with an expiry. Four things are load-bearing: