Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
107e105
feat: resume coderabbit when claude addresses feedback
michaelmcnees Mar 28, 2026
ad104e1
docs: track design specs in git, add release pipeline spec (#70)
michaelmcnees Apr 1, 2026
003d251
build: register bun workspaces, add version anchors for changesets
michaelmcnees Apr 1, 2026
a162aa5
ci: add changesets Version PR workflow
michaelmcnees Apr 1, 2026
990d26d
ci: add per-package tagging workflow with appVersion sync
michaelmcnees Apr 1, 2026
09c92b5
build: add goreleaser config for engine releases
michaelmcnees Apr 1, 2026
eed2011
ci: replace release.yml with goreleaser-based engine release workflow
michaelmcnees Apr 1, 2026
e2f97a1
ci: add Cloudflare Pages site deploy workflow
michaelmcnees Apr 1, 2026
6872341
ci: add Helm chart OCI publish workflow
michaelmcnees Apr 1, 2026
6deaab0
fix: address code review findings for release pipeline
michaelmcnees Apr 1, 2026
3f450a3
Merge branch 'chore/coderabbit-re-review'
michaelmcnees Apr 1, 2026
6f8aae5
docs: add OpenAPI spec design for issue #55
michaelmcnees Apr 5, 2026
4e40123
build: add swaggo/swag dependency for OpenAPI spec generation
michaelmcnees Apr 5, 2026
13f653f
refactor: export response types, add named API request/response types
michaelmcnees Apr 5, 2026
fce8fae
docs: add swag global API annotations and health endpoint stubs
michaelmcnees Apr 5, 2026
4b7174f
docs: add swag annotations to api.go handlers
michaelmcnees Apr 5, 2026
6d321a6
docs: add swag annotations to handleRun and handleCancel
michaelmcnees Apr 5, 2026
8954f8f
feat: generate initial Swagger 2.0 spec via swaggo/swag
michaelmcnees Apr 5, 2026
f1c421f
feat: serve OpenAPI spec at GET /api/v1/openapi.json
michaelmcnees Apr 5, 2026
9781526
ci: add make spec target and OpenAPI spec freshness check
michaelmcnees Apr 5, 2026
593309f
docs: address pre-push review findings
michaelmcnees Apr 5, 2026
1cdc247
fix: address CodeRabbit review findings
michaelmcnees Apr 5, 2026
df573f7
fix: use named response types and fix govulncheck filter exit code
michaelmcnees Apr 5, 2026
ca4f2d3
docs: pin swag version in Makefile spec target comment to v1.16.6
michaelmcnees Apr 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/scripts/sync-chart-appversion.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail

# Usage: sync-chart-appversion.sh <engine-version>
# Example: sync-chart-appversion.sh 0.4.1

ENGINE_VERSION="$1"
CHART_FILE="packages/helm-chart/Chart.yaml"

if [[ ! -f "$CHART_FILE" ]]; then
echo "ERROR: $CHART_FILE not found"
exit 1
fi

sed -i "s/^appVersion:.*/appVersion: \"${ENGINE_VERSION}\"/" "$CHART_FILE"
echo "Updated $CHART_FILE appVersion to $ENGINE_VERSION"
20 changes: 19 additions & 1 deletion .github/workflows/claude.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
timeout-minutes: 30
permissions:
contents: read
pull-requests: read
pull-requests: write
issues: read
id-token: write
actions: read
Comment on lines 26 to 31

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In GitHub Actions, do job-level permissions apply to all steps (including third-party actions), and is splitting write actions into a separate job the recommended least-privilege pattern?

💡 Result:

Yes, job-level permissions in GitHub Actions apply to all steps within that job, including third-party actions, as they all inherit the job's GITHUB_TOKEN permissions. Yes, splitting jobs so that those requiring write permissions are separate from read-only jobs (especially those using third-party actions) is a recommended least-privilege security pattern to minimize blast radius if an action is compromised.

Citations:


🏁 Script executed:

cd /
find . -name "claude.yml" -path "*/.github/workflows/*" 2>/dev/null | head -20

Repository: dvflw/mantle

Length of output: 104


🏁 Script executed:

cat -n .github/workflows/claude.yml

Repository: dvflw/mantle

Length of output: 3316


🌐 Web query:

GitHub Actions needs context syntax needs.job_id.outcome vs result

💡 Result:

In GitHub Actions, the needs context provides access to the status of prerequisite jobs specified in the needs key of a job. The syntax is needs.<job_id>.result, where <job_id> is the ID of the dependent job (e.g., needs.build.result). - needs.<job_id>.result: The final result of the dependent job after applying continue-on-error (possible values: success, failure, cancelled, skipped). This corresponds to the job's "conclusion". There is no needs.<job_id>.outcome property documented in the official GitHub Actions contexts reference. The term "outcome" is used for steps (e.g., steps.<step_id>.outcome is the raw result before continue-on-error), but not for jobs in the needs context. The job context (for the current job) contains job.status (e.g., in_progress, completed) but no outcome or result properties matching the query. For dependent jobs, use needs.<job_id>.result. Example usage in a workflow: jobs: build: runs-on: ubuntu-latest # ... steps ... test: runs-on: ubuntu-latest needs: build if: ${{ needs.build.result == 'success' }} # ... steps ... Note: An open issue notes that needs.<job_id>.result uses the conclusion (post-continue-on-error), not raw outcome, which may be relevant for continue-on-error: true jobs.

Citations:


Limit write token scope to a dedicated follow-up job

Line 28 grants pull-requests: write to the entire claude job, which also runs a third-party action (Line 40). This unnecessarily expands write-token exposure. Keep this job read-only and move the CodeRabbit comment step into a second job with only pull-requests: write.

Suggested refactor
 jobs:
   claude:
     if: |
       (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude') &&
         (github.event.comment.user.login == github.repository_owner || github.event.comment.author_association == 'OWNER' || github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'COLLABORATOR')) ||
       (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude') &&
         (github.event.comment.user.login == github.repository_owner || github.event.comment.author_association == 'OWNER' || github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'COLLABORATOR')) ||
       (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude') &&
         (github.event.review.user.login == github.repository_owner || github.event.review.author_association == 'OWNER' || github.event.review.author_association == 'MEMBER' || github.event.review.author_association == 'COLLABORATOR')) ||
       (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')) &&
         (github.event.issue.user.login == github.repository_owner || github.event.issue.author_association == 'OWNER' || github.event.issue.author_association == 'MEMBER' || github.event.issue.author_association == 'COLLABORATOR'))
     runs-on: ubuntu-latest
     timeout-minutes: 30
     permissions:
       contents: read
-      pull-requests: write
+      pull-requests: read
       issues: read
       id-token: write
       actions: read
     steps:
       - name: Checkout repository
         uses: actions/checkout@v4
         with:
           fetch-depth: 1

       - name: Run Claude Code
         id: claude
         uses: anthropics/claude-code-action@094bd24d575e7b30ac1576024817bf1a97c81262 # v1
         with:
           claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
           additional_permissions: |
             actions: read

-      - name: Request CodeRabbit re-review
-        if: >-
-          steps.claude.outcome == 'success' && (
-            github.event.issue.pull_request ||
-            github.event_name == 'pull_request_review_comment' ||
-            github.event_name == 'pull_request_review'
-          )
-        env:
-          GH_TOKEN: ${{ github.token }}
-          PR_NUMBER: >-
-            ${{
-              github.event.issue.number ||
-              github.event.pull_request.number
-            }}
-        run: |
-          gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \
-            --body "@coderabbitai review"
+
+  request-coderabbit-review:
+    needs: claude
+    if: >-
+      needs.claude.result == 'success' && (
+        github.event.issue.pull_request ||
+        github.event_name == 'pull_request_review_comment' ||
+        github.event_name == 'pull_request_review'
+      )
+    runs-on: ubuntu-latest
+    permissions:
+      pull-requests: write
+    steps:
+      - name: Request CodeRabbit re-review
+        env:
+          GH_TOKEN: ${{ github.token }}
+          PR_NUMBER: >-
+            ${{
+              github.event.issue.number ||
+              github.event.pull_request.number
+            }}
+        run: |
+          gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \
+            --body "@coderabbitai review"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/claude.yml around lines 26 - 31, The claude job currently
grants pull-requests: write for the whole job; remove or change pull-requests:
write in the claude job's permissions block to read-only (keep contents: read,
issues: read, id-token: write, actions: read as needed) and create a second job
(e.g., a follow_up or code_rabbit_comment job) that runs after claude and
contains only the CodeRabbit comment step; give that new job the minimal
permissions including pull-requests: write and required checkout/token access
and set needs: claude so the write token is scoped only to the job that posts
the PR comment.

Expand All @@ -42,3 +42,21 @@ jobs:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
additional_permissions: |
actions: read

- name: Request CodeRabbit re-review
if: >-
steps.claude.outcome == 'success' && (
github.event.issue.pull_request ||
github.event_name == 'pull_request_review_comment' ||
github.event_name == 'pull_request_review'
)
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: >-
${{
github.event.issue.number ||
github.event.pull_request.number
}}
run: |
gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" \
--body "@coderabbitai review"
32 changes: 31 additions & 1 deletion .github/workflows/engine-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,16 @@ jobs:
- name: Install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest
- name: Run govulncheck
run: govulncheck ./...
run: |
# GO-2026-4887 and GO-2026-4883 are in docker/docker (testcontainers test dep only).
# Fixed in: N/A — no upstream fix available. Remove exclusions when a patched release ships.
OUTPUT=$(govulncheck ./... 2>&1 || true)
echo "$OUTPUT"
ACTIONABLE=$(echo "$OUTPUT" | grep -E '^Vulnerability #[0-9]+:' | grep -vE 'GO-2026-4887|GO-2026-4883')
if [ -n "$ACTIONABLE" ]; then
echo "FAIL: unfixed actionable vulnerabilities found"
exit 1
fi

gosec:
name: Gosec
Expand All @@ -74,3 +83,24 @@ jobs:
run: go install github.com/securego/gosec/v2/cmd/gosec@latest
- name: Run gosec
run: gosec -exclude=G101,G104,G112,G115,G117,G202,G204,G301,G304,G306,G404,G703,G705,G706 ./...

spec:
name: Spec Freshness
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.25'
- name: Install swag
run: go install github.com/swaggo/swag/cmd/swag@v1.16.6
- name: Regenerate spec
run: |
swag init \
-g docs.go \
--dir internal/server,internal/workflow,internal/budget \
--output internal/server/docs \
--outputTypes json,yaml \
--parseInternal
- name: Check for drift
run: git diff --exit-code internal/server/docs/
66 changes: 66 additions & 0 deletions .github/workflows/release-engine.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
name: Release Engine

on:
push:
tags:
- 'engine/v*'

permissions:
contents: write
packages: write

jobs:
release:
name: Build and Release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- uses: actions/setup-go@v5
with:
go-version: '1.25'

- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- uses: docker/setup-buildx-action@v3

- name: Set goreleaser tag
run: |
# Strip "engine/" prefix so goreleaser sees a clean semver tag
TAG="${GITHUB_REF#refs/tags/engine/}"
echo "GORELEASER_CURRENT_TAG=${TAG}" >> "$GITHUB_ENV"

- name: Run goreleaser
uses: goreleaser/goreleaser-action@v6
with:
version: '~> v2'
args: release --clean
workdir: packages/engine
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

trivy:
name: Trivy Scan
runs-on: ubuntu-latest
needs: release
steps:
- name: Extract version
id: vars
run: |
TAG="${GITHUB_REF#refs/tags/engine/v}"
echo "version=${TAG}" >> "$GITHUB_OUTPUT"

- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@0.28.0
with:
image-ref: "ghcr.io/dvflw/mantle:${{ steps.vars.outputs.version }}"
format: table
exit-code: '1'
severity: CRITICAL,HIGH
49 changes: 49 additions & 0 deletions .github/workflows/release-helm.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: Release Helm Chart

on:
push:
tags:
- 'helm-chart/v*'

permissions:
contents: write
packages: write

jobs:
publish:
name: Package and Publish
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: azure/setup-helm@v4

- name: Log in to GHCR
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io --username ${{ github.actor }} --password-stdin

- name: Extract version
id: vars
run: |
TAG="${GITHUB_REF#refs/tags/helm-chart/v}"
echo "version=${TAG}" >> "$GITHUB_OUTPUT"

- name: Package chart
run: helm package packages/helm-chart/

- name: Push to OCI registry
run: helm push mantle-${{ steps.vars.outputs.version }}.tgz oci://ghcr.io/dvflw/helm-charts

- name: Create GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
VERSION: ${{ steps.vars.outputs.version }}
run: |
gh release create "${TAG}" \
--title "Helm Chart ${VERSION}" \
--notes "Published to \`oci://ghcr.io/dvflw/helm-charts/mantle:${VERSION}\`

Install with:
\`\`\`bash
helm install mantle oci://ghcr.io/dvflw/helm-charts/mantle --version ${VERSION}
\`\`\`"
37 changes: 37 additions & 0 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Release Please

on:
push:
branches: [main]

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false

permissions:
contents: write
pull-requests: write

jobs:
version:
name: Version Packages
runs-on: ubuntu-latest
# Skip if this push is the Version PR merge itself (prevents loop)
if: "!startsWith(github.event.head_commit.message, 'ci: version packages')"
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- uses: oven-sh/setup-bun@v2

- run: bun install --frozen-lockfile

- name: Create or update Version PR
uses: changesets/action@v1
with:
version: bun run version
commit: "ci: version packages"
title: "ci: version packages"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
97 changes: 97 additions & 0 deletions .github/workflows/release-tags.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
name: Release Tags

on:
push:
branches: [main]

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false

permissions:
contents: write

jobs:
tag:
name: Create Package Tags
runs-on: ubuntu-latest
# Only run on Version PR merges
if: "startsWith(github.event.head_commit.message, 'ci: version packages')"
Comment on lines +18 to +19

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use the push base instead of head_commit.message + HEAD~1.

Line 19 only works for one commit-message shape, and Lines 33-50 only compare the last parent commit. Merge commits, rebase merges, or multi-commit pushes from the version PR can therefore miss real version bumps and skip tagging entirely. Compare against ${{ github.event.before }} and let the detection step no-op when nothing changed.

🛠️ Safer version-base detection
-    # Only run on Version PR merges
-    if: "startsWith(github.event.head_commit.message, 'ci: version packages')"
     steps:
       - uses: actions/checkout@v4
         with:
-          fetch-depth: 2
+          fetch-depth: 0
@@
-          ENGINE_OLD=$(git show HEAD~1:packages/engine/package.json 2>/dev/null | jq -r '.version' 2>/dev/null || echo "")
+          ENGINE_OLD=$(git show "${{ github.event.before }}:packages/engine/package.json" 2>/dev/null | jq -r '.version' 2>/dev/null || echo "")
@@
-          HELM_OLD=$(git show HEAD~1:packages/helm-chart/package.json 2>/dev/null | jq -r '.version' 2>/dev/null || echo "")
+          HELM_OLD=$(git show "${{ github.event.before }}:packages/helm-chart/package.json" 2>/dev/null | jq -r '.version' 2>/dev/null || echo "")
@@
-          SITE_OLD=$(git show HEAD~1:packages/site/package.json 2>/dev/null | jq -r '.version' 2>/dev/null || echo "")
+          SITE_OLD=$(git show "${{ github.event.before }}:packages/site/package.json" 2>/dev/null | jq -r '.version' 2>/dev/null || echo "")

Also applies to: 21-61

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/release-tags.yml around lines 18 - 19, Replace the brittle
startsWith(github.event.head_commit.message, 'ci: version packages') + HEAD~1
comparisons with checks based on the push base using github.event.before: use
github.event.before as the base ref/commit to diff against (instead of
head_commit.message or HEAD~1) in the version-detection step and in any
subsequent comparisons (the logic currently referencing
startsWith(github.event.head_commit.message, ...) and the HEAD~1 parent-diff).
Update the detection step to no-op when github.event.before equals github.sha
(no changes), and use git diff --name-only ${{ github.event.before }}..${{
github.sha }} (or the equivalent action inputs) to reliably detect
package/version changes across merges, rebases, and multi-commit pushes.

steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2

- name: Detect version bumps
id: bumps
run: |
ENGINE_BUMPED=false
HELM_BUMPED=false
SITE_BUMPED=false

# Check engine version bump
ENGINE_OLD=$(git show HEAD~1:packages/engine/package.json 2>/dev/null | jq -r '.version' 2>/dev/null || echo "")
ENGINE_NEW=$(jq -r '.version' packages/engine/package.json)
if [[ -n "$ENGINE_OLD" && "$ENGINE_OLD" != "$ENGINE_NEW" ]]; then
echo "Engine version bumped: $ENGINE_OLD -> $ENGINE_NEW"
ENGINE_BUMPED=true
fi

# Check helm-chart version bump
HELM_OLD=$(git show HEAD~1:packages/helm-chart/package.json 2>/dev/null | jq -r '.version' 2>/dev/null || echo "")
HELM_NEW=$(jq -r '.version' packages/helm-chart/package.json)
if [[ -n "$HELM_OLD" && "$HELM_OLD" != "$HELM_NEW" ]]; then
echo "Helm chart version bumped: $HELM_OLD -> $HELM_NEW"
HELM_BUMPED=true
fi

# Check site version bump
SITE_OLD=$(git show HEAD~1:packages/site/package.json 2>/dev/null | jq -r '.version' 2>/dev/null || echo "")
SITE_NEW=$(jq -r '.version' packages/site/package.json)
if [[ -n "$SITE_OLD" && "$SITE_OLD" != "$SITE_NEW" ]]; then
echo "Site version bumped: $SITE_OLD -> $SITE_NEW"
SITE_BUMPED=true
fi

echo "engine_bumped=${ENGINE_BUMPED}" >> "$GITHUB_OUTPUT"
echo "engine_version=${ENGINE_NEW}" >> "$GITHUB_OUTPUT"
echo "helm_bumped=${HELM_BUMPED}" >> "$GITHUB_OUTPUT"
echo "helm_version=${HELM_NEW}" >> "$GITHUB_OUTPUT"
echo "site_bumped=${SITE_BUMPED}" >> "$GITHUB_OUTPUT"
echo "site_version=${SITE_NEW}" >> "$GITHUB_OUTPUT"

- name: Sync Chart.yaml appVersion
if: steps.bumps.outputs.engine_bumped == 'true'
run: |
.github/scripts/sync-chart-appversion.sh "${{ steps.bumps.outputs.engine_version }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add packages/helm-chart/Chart.yaml
git commit -m "ci: sync Chart.yaml appVersion to ${{ steps.bumps.outputs.engine_version }}"
git push

- name: Create and push tags
run: |
TAGGED=""

if [[ "${{ steps.bumps.outputs.engine_bumped }}" == "true" ]]; then
git tag "engine/v${{ steps.bumps.outputs.engine_version }}"
TAGGED="$TAGGED engine/v${{ steps.bumps.outputs.engine_version }}"
fi

if [[ "${{ steps.bumps.outputs.helm_bumped }}" == "true" ]]; then
git tag "helm-chart/v${{ steps.bumps.outputs.helm_version }}"
TAGGED="$TAGGED helm-chart/v${{ steps.bumps.outputs.helm_version }}"
fi

if [[ "${{ steps.bumps.outputs.site_bumped }}" == "true" ]]; then
git tag "site/v${{ steps.bumps.outputs.site_version }}"
TAGGED="$TAGGED site/v${{ steps.bumps.outputs.site_version }}"
fi

if [[ -n "$TAGGED" ]]; then
echo "Pushing tags:$TAGGED"
git push origin --tags
else
echo "No version bumps detected"
fi
Loading
Loading