From 0a994677a1c00c95650b1e5e4b9b3ce20e10d797 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Sun, 15 Mar 2026 02:13:47 +0100 Subject: [PATCH 1/5] fix: use draft releases to support immutable release policy Release Please now creates draft releases. Docker and CLI workflows attach assets to the mutable draft. A new finalize-release workflow publishes the draft once both workflows succeed, making it immutable. This fixes the 422 "Cannot upload assets to an immutable release" error that blocked GoReleaser and release note updates. --- .github/release-please-config.json | 1 + .github/workflows/finalize-release.yml | 71 ++++++++++++++++++++++++++ CLAUDE.md | 3 +- docs/security.md | 1 + 4 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/finalize-release.yml diff --git a/.github/release-please-config.json b/.github/release-please-config.json index 9fe875f62d..49fcd84f3c 100644 --- a/.github/release-please-config.json +++ b/.github/release-please-config.json @@ -8,6 +8,7 @@ "changelog-path": ".github/CHANGELOG.md", "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, + "draft": true, "changelog-sections": [ { "type": "feat", "section": "Features" }, { "type": "fix", "section": "Bug Fixes" }, diff --git a/.github/workflows/finalize-release.yml b/.github/workflows/finalize-release.yml new file mode 100644 index 0000000000..c76bd67211 --- /dev/null +++ b/.github/workflows/finalize-release.yml @@ -0,0 +1,71 @@ +name: Finalize Release + +# Publish draft releases once both CLI and Docker workflows succeed. +# Release Please creates draft releases (immutable once published), +# then CLI (GoReleaser) and Docker (GHCR) attach assets to the draft. +# This workflow fires on each completion and publishes when both are done. + +on: + workflow_run: + workflows: [Docker, CLI] + types: [completed] + +permissions: {} + +jobs: + publish: + name: Publish Draft Release + # Only process tag-triggered runs (release builds) + if: startsWith(github.event.workflow_run.head_branch, 'v') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Check both workflows and publish draft + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event.workflow_run.head_branch }} + run: | + set -euo pipefail + + echo "Triggered by: ${{ github.event.workflow.name }}" + echo "Tag: $TAG" + echo "Triggering run conclusion: ${{ github.event.workflow_run.conclusion }}" + + # If the triggering workflow failed, no point checking the other + if [ "${{ github.event.workflow_run.conclusion }}" != "success" ]; then + echo "Triggering workflow failed — skipping." + exit 0 + fi + + # Check status of both workflows for this tag. + # gh run list --branch works for both branches and tags. + CLI_CONCLUSION=$(gh run list --repo "$GITHUB_REPOSITORY" \ + --workflow cli.yml --branch "$TAG" --limit 1 \ + --json conclusion --jq '.[0].conclusion // "pending"') + + DOCKER_CONCLUSION=$(gh run list --repo "$GITHUB_REPOSITORY" \ + --workflow docker.yml --branch "$TAG" --limit 1 \ + --json conclusion --jq '.[0].conclusion // "pending"') + + echo "CLI: $CLI_CONCLUSION" + echo "Docker: $DOCKER_CONCLUSION" + + if [ "$CLI_CONCLUSION" != "success" ] || [ "$DOCKER_CONCLUSION" != "success" ]; then + echo "Not all workflows succeeded yet (CLI=$CLI_CONCLUSION, Docker=$DOCKER_CONCLUSION)." + echo "The other workflow's completion will trigger another attempt." + exit 0 + fi + + # Both succeeded — check if release is still a draft + IS_DRAFT=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \ + --json isDraft --jq '.isDraft') + + if [ "$IS_DRAFT" != "true" ]; then + echo "Release $TAG is not a draft (isDraft=$IS_DRAFT). Already published or not found." + exit 0 + fi + + # Publish the draft release (makes it immutable) + gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false + echo "Release $TAG published successfully." diff --git a/CLAUDE.md b/CLAUDE.md index 037a90960d..dff430f043 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -272,7 +272,8 @@ site/ # Astro landing page (synthorg.io) - **DAST**: `.github/workflows/dast.yml` — ZAP API scan against the backend OpenAPI spec on push to main + weekly schedule. Builds backend image locally, starts container, runs ZAP. Results available as workflow artifacts (no SARIF — action v0.10.0 lacks native SARIF output). Not on PRs (too slow). - **Socket.dev**: GitHub App — supply chain attack detection on PRs (typosquatting, malware, suspicious ownership changes, obfuscated code). No config file needed, auto-comments on PRs. - **CLA**: `.github/workflows/cla.yml` — Contributor License Agreement signature check on PRs via `contributor-assistant/github-action`. Triggers on `pull_request_target` and `issue_comment`. Skips Dependabot. Signatures stored in `.github/cla-signatures.json` on the `cla-signatures` branch (unprotected, so the action can commit directly). -- **Release**: `.github/workflows/release.yml` — Release Please (Google) auto-creates a release PR on every push to main. Merging the release PR creates a git tag (`vX.Y.Z`) + GitHub Release with changelog. Tag push triggers the Docker workflow to build version-tagged images. Uses `RELEASE_PLEASE_TOKEN` secret (PAT/GitHub App token) so tag creation triggers downstream workflows (GITHUB_TOKEN cannot). Config in `.github/release-please-config.json` and `.github/.release-please-manifest.json`. After creating/updating a release PR, auto-updates the BSL Change Date in LICENSE to 3 years ahead. +- **Release**: `.github/workflows/release.yml` — Release Please (Google) auto-creates a release PR on every push to main. Merging the release PR creates a git tag (`vX.Y.Z`) + **draft** GitHub Release with changelog. Tag push triggers Docker and CLI workflows to attach assets to the draft. Uses `RELEASE_PLEASE_TOKEN` secret (PAT/GitHub App token) so tag creation triggers downstream workflows (GITHUB_TOKEN cannot). Config in `.github/release-please-config.json` (`"draft": true`) and `.github/.release-please-manifest.json`. After creating/updating a release PR, auto-updates the BSL Change Date in LICENSE to 3 years ahead. +- **Finalize Release**: `.github/workflows/finalize-release.yml` — publishes draft releases after both Docker and CLI workflows succeed. Triggers on `workflow_run` completion of Docker and CLI. Checks both workflow conclusions for the tag, publishes the draft only when both are successful. Immutable releases are enabled on the repo — once published, release assets and body cannot be modified. ## Dependencies diff --git a/docs/security.md b/docs/security.md index 07567f6e05..45a60ab019 100644 --- a/docs/security.md +++ b/docs/security.md @@ -153,6 +153,7 @@ Images are **only pushed to GHCR after both scanners pass**. - **CLI binaries**: SLSA Level 3 provenance attestations (verify via `gh attestation verify`) - **Git commits**: GPG/SSH signed (enforced by branch protection ruleset) - **GitHub Actions**: All actions pinned by full SHA commit hash +- **GitHub Releases**: Immutable releases enabled — once published, assets and body cannot be modified (prevents supply chain tampering). Releases are created as drafts by Release Please, finalized after all assets are attached. --- From b2697e8519837980ab2b0809a0f5b8615b025e4a Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Sun, 15 Mar 2026 02:21:50 +0100 Subject: [PATCH 2/5] fix: address 8 review findings from zizmor, Greptile, CodeRabbit, Gemini - finalize-release.yml: move template expressions to env vars (zizmor fix) - finalize-release.yml: add event != 'pull_request' guard (CodeRabbit) - finalize-release.yml: handle TOCTOU race on concurrent publish (Greptile) - finalize-release.yml: graceful error if release not found (infra-reviewer) - finalize-release.yml: add --status completed to run queries (Greptile) - finalize-release.yml: add branches filter to workflow_run trigger - CLAUDE.md: update CLI bullet to say "draft" release (docs-consistency) - CLAUDE.md: clarify Finalize Release description (Gemini) --- .github/workflows/finalize-release.yml | 49 +++++++++++++++++++------- CLAUDE.md | 4 +-- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/.github/workflows/finalize-release.yml b/.github/workflows/finalize-release.yml index c76bd67211..53d639bee2 100644 --- a/.github/workflows/finalize-release.yml +++ b/.github/workflows/finalize-release.yml @@ -9,14 +9,19 @@ on: workflow_run: workflows: [Docker, CLI] types: [completed] + branches: [main] permissions: {} jobs: publish: name: Publish Draft Release - # Only process tag-triggered runs (release builds) - if: startsWith(github.event.workflow_run.head_branch, 'v') + # Only process tag-triggered release builds, not PR-triggered runs. + # The event != 'pull_request' guard prevents a PR that modifies the + # Docker/CLI workflows from reaching this privileged publish step. + if: >- + github.event.workflow_run.event != 'pull_request' + && startsWith(github.event.workflow_run.head_branch, 'v') runs-on: ubuntu-latest permissions: contents: write @@ -25,27 +30,32 @@ jobs: env: GH_TOKEN: ${{ github.token }} TAG: ${{ github.event.workflow_run.head_branch }} + WORKFLOW_NAME: ${{ github.event.workflow.name }} + WORKFLOW_CONCLUSION: ${{ github.event.workflow_run.conclusion }} run: | set -euo pipefail - echo "Triggered by: ${{ github.event.workflow.name }}" + echo "Triggered by: $WORKFLOW_NAME" echo "Tag: $TAG" - echo "Triggering run conclusion: ${{ github.event.workflow_run.conclusion }}" + echo "Triggering run conclusion: $WORKFLOW_CONCLUSION" # If the triggering workflow failed, no point checking the other - if [ "${{ github.event.workflow_run.conclusion }}" != "success" ]; then + if [ "$WORKFLOW_CONCLUSION" != "success" ]; then echo "Triggering workflow failed — skipping." exit 0 fi # Check status of both workflows for this tag. # gh run list --branch works for both branches and tags. + # --status completed filters out in-progress re-triggered runs. CLI_CONCLUSION=$(gh run list --repo "$GITHUB_REPOSITORY" \ --workflow cli.yml --branch "$TAG" --limit 1 \ + --status completed \ --json conclusion --jq '.[0].conclusion // "pending"') DOCKER_CONCLUSION=$(gh run list --repo "$GITHUB_REPOSITORY" \ --workflow docker.yml --branch "$TAG" --limit 1 \ + --status completed \ --json conclusion --jq '.[0].conclusion // "pending"') echo "CLI: $CLI_CONCLUSION" @@ -57,15 +67,30 @@ jobs: exit 0 fi - # Both succeeded — check if release is still a draft - IS_DRAFT=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \ - --json isDraft --jq '.isDraft') + # Both succeeded — check if release exists and is still a draft + if ! IS_DRAFT=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \ + --json isDraft --jq '.isDraft' 2>/dev/null); then + echo "Release $TAG not found — nothing to publish." + exit 0 + fi if [ "$IS_DRAFT" != "true" ]; then - echo "Release $TAG is not a draft (isDraft=$IS_DRAFT). Already published or not found." + echo "Release $TAG is not a draft (isDraft=$IS_DRAFT). Already published." exit 0 fi - # Publish the draft release (makes it immutable) - gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false - echo "Release $TAG published successfully." + # Publish the draft release (makes it immutable). + # Handle TOCTOU race: if a concurrent run published first, the second + # call may fail with 422 (immutable). Re-check and exit cleanly. + if gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false; then + echo "Release $TAG published successfully." + else + IS_DRAFT_NOW=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \ + --json isDraft --jq '.isDraft' 2>/dev/null || echo "unknown") + if [ "$IS_DRAFT_NOW" = "false" ]; then + echo "Release $TAG was already published by a concurrent run — OK." + else + echo "::error::Failed to publish release $TAG (isDraft=$IS_DRAFT_NOW)" + exit 1 + fi + fi diff --git a/CLAUDE.md b/CLAUDE.md index dff430f043..25d88dc2dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -259,7 +259,7 @@ site/ # Astro landing page (synthorg.io) - Concurrency group cancels stale builds on rapid pushes - **Docker**: `.github/workflows/docker.yml` — builds backend + web images, pushes to GHCR, signs with cosign. SLSA L3 provenance attestations via `actions/attest-build-provenance` (SHA-pinned, Sigstore-signed, pushed to registry). Scans: Trivy (CRITICAL = hard fail, HIGH = warn-only) + Grype (critical cutoff). CVE triage via `.github/.trivyignore.yaml` and `.github/.grype.yaml`. Images only pushed after scans pass. Triggers on push to main and version tags (`v*`). - **Matrix**: Python 3.14 -- **CLI**: `.github/workflows/cli.yml` — Go lint (`golangci-lint` + `go vet`) + test (`-race -coverprofile`) + build (cross-compile matrix: linux/darwin/windows × amd64/arm64) + vulnerability check (`govulncheck`) + fuzz testing (main-only, 30s/target, `continue-on-error`, matrix over 4 packages) on `cli/**` changes. `cli-pass` gate includes fuzz result as informational warning. GoReleaser release on `v*` tags (attaches assets to existing Release Please release). SLSA L3 provenance attestations via `actions/attest-build-provenance` (SHA-pinned, Sigstore-signed). Post-release step appends install instructions + checksum table + provenance verification instructions to GitHub Release notes. +- **CLI**: `.github/workflows/cli.yml` — Go lint (`golangci-lint` + `go vet`) + test (`-race -coverprofile`) + build (cross-compile matrix: linux/darwin/windows × amd64/arm64) + vulnerability check (`govulncheck`) + fuzz testing (main-only, 30s/target, `continue-on-error`, matrix over 4 packages) on `cli/**` changes. `cli-pass` gate includes fuzz result as informational warning. GoReleaser release on `v*` tags (attaches assets to the draft Release Please release). SLSA L3 provenance attestations via `actions/attest-build-provenance` (SHA-pinned, Sigstore-signed). Post-release step appends install instructions + checksum table + provenance verification instructions to the draft release notes (while still in draft — before finalize-release publishes). - **Dependabot**: daily uv + github-actions + npm + pre-commit + docker + gomod updates, grouped minor/patch, no auto-merge. Use `/review-dep-pr` to review Dependabot PRs before merging - **Python audit**: `.github/workflows/python-audit.yml` — weekly pip-audit scan for Python dependency vulnerabilities (also runs per-PR via `python-audit` job in ci.yml) - **Dockerfile lint**: hadolint lints all 3 Dockerfiles (backend, web, sandbox) in CI via `dockerfile-lint` job + hadolint-docker pre-commit hook locally @@ -273,7 +273,7 @@ site/ # Astro landing page (synthorg.io) - **Socket.dev**: GitHub App — supply chain attack detection on PRs (typosquatting, malware, suspicious ownership changes, obfuscated code). No config file needed, auto-comments on PRs. - **CLA**: `.github/workflows/cla.yml` — Contributor License Agreement signature check on PRs via `contributor-assistant/github-action`. Triggers on `pull_request_target` and `issue_comment`. Skips Dependabot. Signatures stored in `.github/cla-signatures.json` on the `cla-signatures` branch (unprotected, so the action can commit directly). - **Release**: `.github/workflows/release.yml` — Release Please (Google) auto-creates a release PR on every push to main. Merging the release PR creates a git tag (`vX.Y.Z`) + **draft** GitHub Release with changelog. Tag push triggers Docker and CLI workflows to attach assets to the draft. Uses `RELEASE_PLEASE_TOKEN` secret (PAT/GitHub App token) so tag creation triggers downstream workflows (GITHUB_TOKEN cannot). Config in `.github/release-please-config.json` (`"draft": true`) and `.github/.release-please-manifest.json`. After creating/updating a release PR, auto-updates the BSL Change Date in LICENSE to 3 years ahead. -- **Finalize Release**: `.github/workflows/finalize-release.yml` — publishes draft releases after both Docker and CLI workflows succeed. Triggers on `workflow_run` completion of Docker and CLI. Checks both workflow conclusions for the tag, publishes the draft only when both are successful. Immutable releases are enabled on the repo — once published, release assets and body cannot be modified. +- **Finalize Release**: `.github/workflows/finalize-release.yml` — publishes draft releases created by Release Please. Triggers on `workflow_run` completion of Docker and CLI workflows. Verifies that both workflows succeeded for the associated tag before publishing the draft. Guards against PR-triggered runs (`event != 'pull_request'`). Handles TOCTOU races (concurrent publish attempts exit cleanly). Immutable releases are enabled on the repo — once published, release assets and body cannot be modified. ## Dependencies From f4ab916700bedaeef04baf212678c9c25a37c2b1 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Sun, 15 Mar 2026 02:24:39 +0100 Subject: [PATCH 3/5] ci: suppress zizmor dangerous-triggers for finalize-release workflow_run is safe here: no checkout, no untrusted code execution, guarded by event != 'pull_request' and tag prefix check. --- .github/workflows/finalize-release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/finalize-release.yml b/.github/workflows/finalize-release.yml index 53d639bee2..7fef0be227 100644 --- a/.github/workflows/finalize-release.yml +++ b/.github/workflows/finalize-release.yml @@ -6,6 +6,9 @@ name: Finalize Release # This workflow fires on each completion and publishes when both are done. on: + # zizmor: ignore[dangerous-triggers] — safe: no checkout, no untrusted code + # execution; only queries workflow status via gh CLI and publishes a draft + # release. Guarded by event != 'pull_request' and startsWith(head_branch, 'v'). workflow_run: workflows: [Docker, CLI] types: [completed] From ab632fffaa5c72c8c54b14478b9d3e624f985b87 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Sun, 15 Mar 2026 02:43:50 +0100 Subject: [PATCH 4/5] fix: critical workflow fixes from round 2 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove branches: [main] filter — it blocks tag-triggered runs, making the workflow completely inoperative for releases (Greptile) - Fix WORKFLOW_NAME: use workflow_run.name (triggering workflow) instead of workflow.name (current workflow = always "Finalize Release") (Greptile) - Add actions: read permission — gh run list needs it to query workflow run statuses, otherwise 403 (CodeRabbit) --- .github/workflows/finalize-release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/finalize-release.yml b/.github/workflows/finalize-release.yml index 7fef0be227..8e60480457 100644 --- a/.github/workflows/finalize-release.yml +++ b/.github/workflows/finalize-release.yml @@ -12,7 +12,6 @@ on: workflow_run: workflows: [Docker, CLI] types: [completed] - branches: [main] permissions: {} @@ -27,13 +26,14 @@ jobs: && startsWith(github.event.workflow_run.head_branch, 'v') runs-on: ubuntu-latest permissions: + actions: read contents: write steps: - name: Check both workflows and publish draft env: GH_TOKEN: ${{ github.token }} TAG: ${{ github.event.workflow_run.head_branch }} - WORKFLOW_NAME: ${{ github.event.workflow.name }} + WORKFLOW_NAME: ${{ github.event.workflow_run.name }} WORKFLOW_CONCLUSION: ${{ github.event.workflow_run.conclusion }} run: | set -euo pipefail From 1b691fd792b85b5bd42902be9d5a706a110f622a Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Sun, 15 Mar 2026 02:51:15 +0100 Subject: [PATCH 5/5] fix: harden finalize-release security guards (CodeRabbit round 3) - Restrict to push events only (not just != pull_request) - Block fork workflows via head_repository.full_name check - Pin gh run list queries to exact HEAD_SHA commit --- .github/workflows/finalize-release.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/finalize-release.yml b/.github/workflows/finalize-release.yml index 8e60480457..df3df752fc 100644 --- a/.github/workflows/finalize-release.yml +++ b/.github/workflows/finalize-release.yml @@ -22,7 +22,8 @@ jobs: # The event != 'pull_request' guard prevents a PR that modifies the # Docker/CLI workflows from reaching this privileged publish step. if: >- - github.event.workflow_run.event != 'pull_request' + github.event.workflow_run.event == 'push' + && github.event.workflow_run.head_repository.full_name == github.repository && startsWith(github.event.workflow_run.head_branch, 'v') runs-on: ubuntu-latest permissions: @@ -33,6 +34,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} TAG: ${{ github.event.workflow_run.head_branch }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} WORKFLOW_NAME: ${{ github.event.workflow_run.name }} WORKFLOW_CONCLUSION: ${{ github.event.workflow_run.conclusion }} run: | @@ -52,13 +54,13 @@ jobs: # gh run list --branch works for both branches and tags. # --status completed filters out in-progress re-triggered runs. CLI_CONCLUSION=$(gh run list --repo "$GITHUB_REPOSITORY" \ - --workflow cli.yml --branch "$TAG" --limit 1 \ - --status completed \ + --workflow cli.yml --branch "$TAG" --event push --commit "$HEAD_SHA" \ + --limit 1 --status completed \ --json conclusion --jq '.[0].conclusion // "pending"') DOCKER_CONCLUSION=$(gh run list --repo "$GITHUB_REPOSITORY" \ - --workflow docker.yml --branch "$TAG" --limit 1 \ - --status completed \ + --workflow docker.yml --branch "$TAG" --event push --commit "$HEAD_SHA" \ + --limit 1 --status completed \ --json conclusion --jq '.[0].conclusion // "pending"') echo "CLI: $CLI_CONCLUSION"