diff --git a/.github/prompts/classify-pr.prompt.yml b/.github/prompts/classify-pr.prompt.yml index 0d87499..4ae0c75 100644 --- a/.github/prompts/classify-pr.prompt.yml +++ b/.github/prompts/classify-pr.prompt.yml @@ -2,7 +2,7 @@ messages: - role: system content: | You classify pull requests for release note categorization. - Respond with exactly one word: bug, enhancement, or documentation. + Respond with JSON: {"label": "bug"}, {"label": "enhancement"}, or {"label": "documentation"}. - bug: corrects wrong behavior, broken defaults, incorrect error codes, retry/backoff defects, auth handling bugs, compatibility regressions. @@ -19,6 +19,23 @@ messages: When a PR mixes categories: bug > enhancement > documentation. Prefer diff evidence over the PR title. model: openai/gpt-4o-mini +responseFormat: json_schema +jsonSchema: |- + { + "name": "classification", + "strict": true, + "schema": { + "type": "object", + "properties": { + "label": { + "type": "string", + "enum": ["bug", "enhancement", "documentation"] + } + }, + "required": ["label"], + "additionalProperties": false + } + } modelParameters: - maxCompletionTokens: 10 + maxCompletionTokens: 25 temperature: 0 diff --git a/.github/workflows/ai-labeler.yml b/.github/workflows/ai-labeler.yml index de1c201..498f2e6 100644 --- a/.github/workflows/ai-labeler.yml +++ b/.github/workflows/ai-labeler.yml @@ -23,8 +23,8 @@ jobs: - name: Build prompt env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR: ${{ github.event.pull_request.number }} run: | - PR=${{ github.event.pull_request.number }} gh pr diff "$PR" > /tmp/pr.diff gh pr view "$PR" --json title --jq .title > /tmp/pr-title.txt gh pr view "$PR" --json body --jq '.body // ""' > /tmp/pr-body.txt @@ -79,13 +79,15 @@ jobs: - name: Apply label env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RESPONSE_FILE: ${{ steps.classify.outputs.response-file }} + PR: ${{ github.event.pull_request.number }} run: | - LABEL=$(echo "${{ steps.classify.outputs.response }}" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]') + LABEL=$(jq -r '.label // empty' "$RESPONSE_FILE" 2>/dev/null || cat "$RESPONSE_FILE") + LABEL=$(printf '%s' "$LABEL" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]') case "$LABEL" in bug|enhancement|documentation) ;; *) echo "Unexpected: $LABEL — skipping"; exit 0 ;; esac - PR=${{ github.event.pull_request.number }} CURRENT=$(gh pr view "$PR" --json labels --jq '.labels[].name') for L in bug enhancement documentation; do if [ "$L" != "$LABEL" ] && echo "$CURRENT" | grep -qx "$L"; then @@ -105,8 +107,8 @@ jobs: id: api-diff env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR: ${{ github.event.pull_request.number }} run: | - PR=${{ github.event.pull_request.number }} gh pr diff "$PR" > /tmp/full.diff # Filter diff to exported Go library files (not tests, seed, or non-package dirs) @@ -178,8 +180,9 @@ jobs: if: steps.api-diff.outputs.skip != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RESPONSE_FILE: ${{ steps.detect.outputs.response-file }} + PR: ${{ github.event.pull_request.number }} run: | - RESPONSE_FILE="${{ steps.detect.outputs.response-file }}" if [ -z "$RESPONSE_FILE" ] || [ ! -f "$RESPONSE_FILE" ]; then echo "::warning::Model response file is missing; skipping breaking label." exit 0 @@ -198,7 +201,6 @@ jobs: exit 0 fi BREAKING=$(jq -r '.breaking' "$RESPONSE_FILE") - PR=${{ github.event.pull_request.number }} if [ "$BREAKING" = "true" ]; then ITEMS=$(jq -r '.items[]' "$RESPONSE_FILE" | sed 's/^/- /') diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c1c2465..8066dd0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,6 @@ jobs: security: name: Security uses: ./.github/workflows/security.yml - secrets: inherit test: name: Test gate @@ -63,9 +62,11 @@ jobs: govulncheck ./... - name: Verify tag is on main + env: + COMMIT_SHA: ${{ github.sha }} run: | git fetch origin main - if ! git merge-base --is-ancestor "${{ github.sha }}" origin/main; then + if ! git merge-base --is-ancestor "$COMMIT_SHA" origin/main; then echo "Error: tag is not on the main branch" exit 1 fi @@ -83,9 +84,11 @@ jobs: fetch-depth: 0 - name: Verify tag is on main + env: + COMMIT_SHA: ${{ github.sha }} run: | git fetch origin main - if ! git merge-base --is-ancestor "${{ github.sha }}" origin/main; then + if ! git merge-base --is-ancestor "$COMMIT_SHA" origin/main; then echo "Error: tag is not on the main branch" exit 1 fi @@ -142,35 +145,23 @@ jobs: with: prompt-file: /tmp/prompt.yml - - name: Set changelog env - run: | - RESPONSE_FILE="${{ steps.ai-changelog.outputs.response-file }}" - if [ -n "$RESPONSE_FILE" ] && [ -f "$RESPONSE_FILE" ]; then - # Strip markdown code fences that AI models wrap around responses - sed -i '/^```\(markdown\)\?$/d' "$RESPONSE_FILE" - DELIM="CHANGELOG_DELIM_$(openssl rand -hex 8)" - { - echo "RELEASE_CHANGELOG<<${DELIM}" - cat "$RESPONSE_FILE" - echo "" - echo "${DELIM}" - } >> $GITHUB_ENV - else - echo "RELEASE_CHANGELOG=" >> $GITHUB_ENV - fi - - name: Create GitHub Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CHANGELOG_FILE: ${{ steps.ai-changelog.outputs.response-file }} + TAG: ${{ github.ref_name }} run: | - TAG="${{ github.ref_name }}" NOTES="" - if [ -n "$RELEASE_CHANGELOG" ]; then - NOTES="${RELEASE_CHANGELOG} + if [ -n "$CHANGELOG_FILE" ] && [ -f "$CHANGELOG_FILE" ]; then + sed -i '/^```\(markdown\)\?$/d' "$CHANGELOG_FILE" + CHANGELOG=$(cat "$CHANGELOG_FILE") + if [ -n "$CHANGELOG" ]; then + NOTES="${CHANGELOG} --- " + fi fi NOTES="${NOTES}### Install @@ -197,6 +188,7 @@ jobs: if: vars.SKILLS_APP_ID != '' continue-on-error: true timeout-minutes: 5 + environment: release concurrency: group: sync-skills cancel-in-progress: false @@ -232,4 +224,9 @@ jobs: - name: Sync skills if: steps.check.outputs.ready == 'true' - run: CLI_NAME=cli SKILLS_TOKEN=${{ steps.skills-token.outputs.token }} RELEASE_TAG=${{ github.ref_name }} SOURCE_SHA=${{ github.sha }} scripts/sync-skills.sh + env: + CLI_NAME: cli + SKILLS_TOKEN: ${{ steps.skills-token.outputs.token }} + RELEASE_TAG: ${{ github.ref_name }} + SOURCE_SHA: ${{ github.sha }} + run: scripts/sync-skills.sh diff --git a/seed/.github/prompts/classify-pr.prompt.yml b/seed/.github/prompts/classify-pr.prompt.yml index 0d87499..4ae0c75 100644 --- a/seed/.github/prompts/classify-pr.prompt.yml +++ b/seed/.github/prompts/classify-pr.prompt.yml @@ -2,7 +2,7 @@ messages: - role: system content: | You classify pull requests for release note categorization. - Respond with exactly one word: bug, enhancement, or documentation. + Respond with JSON: {"label": "bug"}, {"label": "enhancement"}, or {"label": "documentation"}. - bug: corrects wrong behavior, broken defaults, incorrect error codes, retry/backoff defects, auth handling bugs, compatibility regressions. @@ -19,6 +19,23 @@ messages: When a PR mixes categories: bug > enhancement > documentation. Prefer diff evidence over the PR title. model: openai/gpt-4o-mini +responseFormat: json_schema +jsonSchema: |- + { + "name": "classification", + "strict": true, + "schema": { + "type": "object", + "properties": { + "label": { + "type": "string", + "enum": ["bug", "enhancement", "documentation"] + } + }, + "required": ["label"], + "additionalProperties": false + } + } modelParameters: - maxCompletionTokens: 10 + maxCompletionTokens: 25 temperature: 0 diff --git a/seed/.github/workflows/ai-labeler.yml b/seed/.github/workflows/ai-labeler.yml index 3d1048a..c4813f4 100644 --- a/seed/.github/workflows/ai-labeler.yml +++ b/seed/.github/workflows/ai-labeler.yml @@ -23,8 +23,8 @@ jobs: - name: Build prompt env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR: ${{ github.event.pull_request.number }} run: | - PR=${{ github.event.pull_request.number }} gh pr diff "$PR" > /tmp/pr.diff gh pr view "$PR" --json title --jq .title > /tmp/pr-title.txt gh pr view "$PR" --json body --jq '.body // ""' > /tmp/pr-body.txt @@ -79,13 +79,15 @@ jobs: - name: Apply label env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RESPONSE_FILE: ${{ steps.classify.outputs.response-file }} + PR: ${{ github.event.pull_request.number }} run: | - LABEL=$(echo "${{ steps.classify.outputs.response }}" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]') + LABEL=$(jq -r '.label // empty' "$RESPONSE_FILE" 2>/dev/null || cat "$RESPONSE_FILE") + LABEL=$(printf '%s' "$LABEL" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]') case "$LABEL" in bug|enhancement|documentation) ;; *) echo "Unexpected: $LABEL — skipping"; exit 0 ;; esac - PR=${{ github.event.pull_request.number }} CURRENT=$(gh pr view "$PR" --json labels --jq '.labels[].name') for L in bug enhancement documentation; do if [ "$L" != "$LABEL" ] && echo "$CURRENT" | grep -qx "$L"; then @@ -105,9 +107,8 @@ jobs: id: cmd-diff env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR: ${{ github.event.pull_request.number }} run: | - PR=${{ github.event.pull_request.number }} - # CLI command surface files PATTERNS=( "internal/commands/*.go" @@ -184,8 +185,9 @@ jobs: if: steps.cmd-diff.outputs.skip != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RESPONSE_FILE: ${{ steps.detect.outputs.response-file }} + PR: ${{ github.event.pull_request.number }} run: | - RESPONSE_FILE="${{ steps.detect.outputs.response-file }}" if [ -z "$RESPONSE_FILE" ] || [ ! -f "$RESPONSE_FILE" ]; then echo "::warning::Model response file is missing; skipping breaking label." exit 0 @@ -204,7 +206,6 @@ jobs: exit 0 fi BREAKING=$(jq -r '.breaking' "$RESPONSE_FILE") - PR=${{ github.event.pull_request.number }} if [ "$BREAKING" = "true" ]; then ITEMS=$(jq -r '.items[]' "$RESPONSE_FILE" | sed 's/^/- /') diff --git a/seed/.github/workflows/release.yml b/seed/.github/workflows/release.yml index cd94b9f..cc9acaa 100644 --- a/seed/.github/workflows/release.yml +++ b/seed/.github/workflows/release.yml @@ -7,20 +7,23 @@ on: permissions: contents: write - id-token: write # keyless cosign signing via OIDC - security-events: write # SARIF upload in security scan + id-token: write + security-events: write pull-requests: read - models: read # AI changelog (when enabled) + models: read jobs: security: name: Security scan uses: ./.github/workflows/security.yml - secrets: inherit + secrets: + RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} test: name: Test before release runs-on: ubuntu-latest + permissions: + contents: read env: APPNAME_NO_KEYRING: "1" # Uncomment for private module access: GOPRIVATE: github.com/basecamp/ @@ -47,7 +50,9 @@ jobs: - name: Configure git for private modules if: steps.app-token.outputs.token != '' - run: git config --global url."https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/".insteadOf "https://github.com/" + env: + TOKEN: ${{ steps.app-token.outputs.token }} + run: git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "https://github.com/" - name: Install golangci-lint uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9 @@ -115,6 +120,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 45 environment: release + permissions: + contents: write + id-token: write + models: read env: HAS_MACOS_SIGNING: ${{ secrets.MACOS_SIGN_P12 && 'true' || '' }} HAS_AUR_KEY: ${{ secrets.AUR_SSH_KEY && 'true' || '' }} @@ -142,7 +151,9 @@ jobs: - name: Configure git for private modules if: steps.sdk-token.outputs.token != '' - run: git config --global url."https://x-access-token:${{ steps.sdk-token.outputs.token }}@github.com/".insteadOf "https://github.com/" + env: + TOKEN: ${{ steps.sdk-token.outputs.token }} + run: git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "https://github.com/" - name: Verify tag is on main run: | @@ -237,22 +248,15 @@ jobs: with: prompt-file: /tmp/prompt.yml - - name: Set changelog env + - name: Set changelog path + id: changelog if: vars.ENABLE_AI_CHANGELOG == 'true' + env: + RESPONSE_FILE: ${{ steps.ai-changelog.outputs.response-file }} run: | - RESPONSE_FILE="${{ steps.ai-changelog.outputs.response-file }}" if [ -n "$RESPONSE_FILE" ] && [ -f "$RESPONSE_FILE" ]; then - # Strip markdown code fences that AI models wrap around responses sed -i '/^```\(markdown\)\?$/d' "$RESPONSE_FILE" - DELIM="CHANGELOG_DELIM_$(openssl rand -hex 8)" - { - echo "RELEASE_CHANGELOG<<${DELIM}" - cat "$RESPONSE_FILE" - echo "" - echo "${DELIM}" - } >> "$GITHUB_ENV" - else - echo "RELEASE_CHANGELOG=" >> "$GITHUB_ENV" + echo "file=$RESPONSE_FILE" >> "$GITHUB_OUTPUT" fi # Configure secrets.MACOS_SIGN_P12 (plus MACOS_SIGN_PASSWORD, MACOS_NOTARY_KEY, @@ -278,13 +282,16 @@ jobs: MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} - - name: Run GoReleaser + - name: Install GoReleaser uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7 with: distribution: goreleaser version: '~> v2' - args: release --clean + install-only: true + + - name: Run GoReleaser env: + CHANGELOG_FILE: ${{ steps.changelog.outputs.file }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} HOMEBREW_TAP_TOKEN: ${{ steps.sdk-token.outputs.token }} MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} @@ -292,6 +299,13 @@ jobs: MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} + run: | + RELEASE_CHANGELOG="" + if [ -n "$CHANGELOG_FILE" ] && [ -f "$CHANGELOG_FILE" ]; then + RELEASE_CHANGELOG=$(cat "$CHANGELOG_FILE") + fi + export RELEASE_CHANGELOG + goreleaser release --clean # Configure secrets.AUR_SSH_KEY to enable Arch Linux AUR publishing - name: Publish to AUR @@ -321,6 +335,9 @@ jobs: cancel-in-progress: false runs-on: ubuntu-latest timeout-minutes: 10 + environment: release + permissions: + contents: read steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -357,9 +374,12 @@ jobs: if: failure() && steps.check-skills.outputs.found == 'true' env: GH_TOKEN: ${{ steps.skills-token.outputs.token }} + REF_NAME: ${{ github.ref_name }} + REPO: ${{ github.repository }} + RUN_ID: ${{ github.run_id }} run: | TITLE="Skills sync failure" - BODY="The automatic skills sync from [${{ github.ref_name }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) failed. Check the workflow run for details." + BODY="The automatic skills sync from [${REF_NAME}](https://github.com/${REPO}/actions/runs/${RUN_ID}) failed. Check the workflow run for details." # Check for existing open issue before creating a new one existing=$(gh issue list --repo basecamp/skills --state open --search "in:title $TITLE" --json number,title --jq '[.[] | select(.title == "'"$TITLE"'")][0].number // empty' 2>/dev/null || true) @@ -370,4 +390,4 @@ jobs: fi # Always emit annotation so the failure is visible in the workflow summary - echo "::error::Skills sync to basecamp/skills failed for ${{ github.ref_name }}. See https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + echo "::error::Skills sync to basecamp/skills failed for ${REF_NAME}. See https://github.com/${REPO}/actions/runs/${RUN_ID}" diff --git a/seed/.github/workflows/security.yml b/seed/.github/workflows/security.yml index a7dde00..a5dc882 100644 --- a/seed/.github/workflows/security.yml +++ b/seed/.github/workflows/security.yml @@ -9,6 +9,9 @@ on: # Weekly scan on Monday at 6am UTC - cron: '0 6 * * 1' workflow_call: # Allow release.yml to invoke the full security suite + secrets: + RELEASE_APP_PRIVATE_KEY: + required: false workflow_dispatch: permissions: @@ -83,7 +86,9 @@ jobs: - name: Configure git for private modules if: steps.app-token.outputs.token != '' - run: git config --global url."https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/".insteadOf "https://github.com/" + env: + TOKEN: ${{ steps.app-token.outputs.token }} + run: git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "https://github.com/" - name: Run gosec run: | @@ -131,7 +136,9 @@ jobs: - name: Configure git for private modules if: steps.app-token.outputs.token != '' - run: git config --global url."https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/".insteadOf "https://github.com/" + env: + TOKEN: ${{ steps.app-token.outputs.token }} + run: git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "https://github.com/" - name: Initialize CodeQL uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4 diff --git a/seed/.github/workflows/test.yml b/seed/.github/workflows/test.yml index 4d70709..3c88e8b 100644 --- a/seed/.github/workflows/test.yml +++ b/seed/.github/workflows/test.yml @@ -38,7 +38,9 @@ jobs: - name: Configure git for private modules if: steps.app-token.outputs.token != '' - run: git config --global url."https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/".insteadOf "https://github.com/" + env: + TOKEN: ${{ steps.app-token.outputs.token }} + run: git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "https://github.com/" - name: Check module tidiness run: make tidy-check @@ -78,7 +80,9 @@ jobs: - name: Configure git for private modules if: steps.app-token.outputs.token != '' - run: git config --global url."https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/".insteadOf "https://github.com/" + env: + TOKEN: ${{ steps.app-token.outputs.token }} + run: git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "https://github.com/" - name: Run golangci-lint uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9 @@ -107,7 +111,9 @@ jobs: - name: Configure git for private modules if: steps.app-token.outputs.token != '' - run: git config --global url."https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/".insteadOf "https://github.com/" + env: + TOKEN: ${{ steps.app-token.outputs.token }} + run: git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "https://github.com/" - name: Run govulncheck # @latest intentional — pinning delays scanning improvements and @@ -141,7 +147,9 @@ jobs: - name: Configure git for private modules if: steps.app-token.outputs.token != '' - run: git config --global url."https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/".insteadOf "https://github.com/" + env: + TOKEN: ${{ steps.app-token.outputs.token }} + run: git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "https://github.com/" - name: Run tests with race detector run: go test -race -v ./... @@ -171,7 +179,9 @@ jobs: - name: Configure git for private modules if: steps.app-token.outputs.token != '' - run: git config --global url."https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/".insteadOf "https://github.com/" + env: + TOKEN: ${{ steps.app-token.outputs.token }} + run: git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "https://github.com/" - name: Cache BATS id: cache-bats @@ -216,7 +226,9 @@ jobs: - name: Configure git for private modules if: steps.app-token.outputs.token != '' - run: git config --global url."https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/".insteadOf "https://github.com/" + env: + TOKEN: ${{ steps.app-token.outputs.token }} + run: git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "https://github.com/" - name: Build and snapshot PR surface run: | @@ -280,7 +292,9 @@ jobs: - name: Configure git for private modules if: steps.filter.outputs.bench == 'true' && steps.app-token.outputs.token != '' - run: git config --global url."https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/".insteadOf "https://github.com/" + env: + TOKEN: ${{ steps.app-token.outputs.token }} + run: git config --global url."https://x-access-token:${TOKEN}@github.com/".insteadOf "https://github.com/" - name: Run benchmarks if: steps.filter.outputs.bench == 'true'