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..df3df752fc --- /dev/null +++ b/.github/workflows/finalize-release.yml @@ -0,0 +1,101 @@ +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: + # 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] + +permissions: {} + +jobs: + publish: + name: Publish Draft Release + # 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 == 'push' + && github.event.workflow_run.head_repository.full_name == github.repository + && 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 }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + WORKFLOW_NAME: ${{ github.event.workflow_run.name }} + WORKFLOW_CONCLUSION: ${{ github.event.workflow_run.conclusion }} + run: | + set -euo pipefail + + echo "Triggered by: $WORKFLOW_NAME" + echo "Tag: $TAG" + echo "Triggering run conclusion: $WORKFLOW_CONCLUSION" + + # If the triggering workflow failed, no point checking the other + 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" --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" --event push --commit "$HEAD_SHA" \ + --limit 1 --status completed \ + --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 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." + exit 0 + fi + + # 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 037a90960d..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 @@ -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 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 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. ---