From e5a13253c91864fe3133bdb97e194b90d3a6ec72 Mon Sep 17 00:00:00 2001 From: Naveen Kumar Date: Mon, 21 Sep 2026 14:13:12 +0800 Subject: [PATCH 1/3] Standardize cost-aware PR checks and authorized release flow --- .github/RELEASE-POLICY.md | 29 ++++++++++++ .github/dependabot.yml | 41 +++++++---------- .github/workflows/ci.yml | 66 ++++++++++++++++++++++++--- .github/workflows/codeql.yml | 17 ++++--- .github/workflows/govulncheck.yml | 17 +++++-- .github/workflows/lint.yml | 59 +++++++++++++++++++++--- .github/workflows/pages.yml | 9 ++-- .github/workflows/release.yml | 74 ++++++++++++++++++++++++------- .github/workflows/web.yml | 70 +++++++++++++++++++++++++---- .goreleaser.yaml | 5 +++ AGENTS.md | 3 ++ 11 files changed, 314 insertions(+), 76 deletions(-) create mode 100644 .github/RELEASE-POLICY.md create mode 100644 AGENTS.md diff --git a/.github/RELEASE-POLICY.md b/.github/RELEASE-POLICY.md new file mode 100644 index 00000000..4f253560 --- /dev/null +++ b/.github/RELEASE-POLICY.md @@ -0,0 +1,29 @@ +# Changes and releases + +All code changes go through pull requests. Draft PRs skip expensive validation; +mark the PR ready to run relevant checks on the current revision. Superseded PR +runs cancel. Do not bypass required checks. + +## Prepare a release + +Update release notes and applicable version examples, and write the tag (for example `v1.2.3`) to `.github/release-request`. Its merge publishes CLI and desktop assets from that exact commit. + +Use a local authenticated `gh` session, directly or through an LLM, to create a +branch named `release/`, make the version and release-note changes, and open a draft PR with +`gh pr create --draft`. Mark it ready with `gh pr ready` when preparation is +complete. Wait for required checks before merging. + +"Prepare a release" stops at the PR. A human merge, or an explicit instruction +to an LLM to release, authorizes merging that PR and publishing. Never merge a +release or dependency PR unattended. Never create a new version merely to retry +a failed publisher: inspect the failed run and its published artifacts first. + +The local gh path needs no new secrets. A future Prepare release Action must +have permission to create PRs; token-created PRs need a human ready event (or +a separately authorized token) to trigger normal PR CI. No such token is assumed. + +Routine dependency updates share one weekly multi-ecosystem group. Security updates +remain eligible immediately and are not held for the routine weekly batch. + +Tap publishing tokens need both Contents and Pull requests write permission on +the destination tap. Tap updates require review and merge after binary publication. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0bed17fd..87a67457 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,28 +1,21 @@ version: 2 updates: - - package-ecosystem: gomod - directory: "/" +- package-ecosystem: gomod + directory: / + patterns: + - '*' + multi-ecosystem-group: routine +- package-ecosystem: gomod + directory: /desktop + patterns: + - '*' + multi-ecosystem-group: routine +- package-ecosystem: github-actions + directory: / + patterns: + - '*' + multi-ecosystem-group: routine +multi-ecosystem-groups: + routine: schedule: interval: weekly - groups: - go-deps: - patterns: ["*"] - # desktop/ is a SEPARATE module. Dependabot v2 does not auto-discover nested - # modules, so without this entry it never received an update PR — and no root - # check reaches it either (govulncheck/vet/golangci/CodeQL all run from the - # root, where `go list ./...` returns zero desktop packages). That gap is why - # x/net, x/crypto and x/text there have needed hand-written catch-up bumps. - - package-ecosystem: gomod - directory: "/desktop" - schedule: - interval: weekly - groups: - desktop-go-deps: - patterns: ["*"] - - package-ecosystem: github-actions - directory: "/" - schedule: - interval: weekly - groups: - actions: - patterns: ["*"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63e2ddd8..520b1097 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,16 +1,48 @@ name: CI on: - push: - branches: [main] pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + pull-requests: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + changes: + if: github.event_name != 'pull_request' || !github.event.pull_request.draft + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + code: ${{ steps.filter.outputs.code }} + steps: + - id: filter + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + if [ "$GITHUB_EVENT_NAME" != pull_request ]; then + echo 'code=true' >> "$GITHUB_OUTPUT" + exit 0 + fi + total=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq .changed_files) + gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" > "$RUNNER_TEMP/changed-files.json" + code=$(jq --argjson total "$total" 'add | if length == 0 or length != $total then true else any(.[]; ([.filename, (.previous_filename // .filename)] | any(.[]; test("^(docs/.*\\.md|[^/]+\\.md|LICENSE)$") | not))) end' "$RUNNER_TEMP/changed-files.json") + echo "code=$code" >> "$GITHUB_OUTPUT" + test: + needs: changes + if: needs.changes.outputs.code == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) runs-on: ubuntu-latest + timeout-minutes: 20 steps: - - uses: actions/checkout@v7 - - uses: actions/setup-go@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version-file: go.mod cache: true @@ -37,6 +69,8 @@ jobs: run: cd desktop && go build ./... web: + needs: changes + if: needs.changes.outputs.code == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) # Run on Linux (case-sensitive) AND macOS (case-insensitive). The macOS leg # catches case-only filename collisions — e.g. a Foo.svelte component next to # a foo.svelte.ts runes module — which resolve fine on Linux but break @@ -44,14 +78,15 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] + os: [macos-latest] runs-on: ${{ matrix.os }} + timeout-minutes: 20 defaults: run: working-directory: internal/web/frontend steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 cache: npm @@ -61,3 +96,20 @@ jobs: run: npm run check - name: vitest run: npm test + + result: + name: ${{ github.workflow }} result + if: always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) + needs: [changes, test, web] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - env: + RESULTS: ${{ toJSON(needs) }} + run: | + python3 - <<'PYTHON' + import json, os + results = json.loads(os.environ['RESULTS']) + assert results['changes']['result'] == 'success', 'Change detection did not succeed' + assert all(job['result'] in ('success', 'skipped') for job in results.values()), results + PYTHON diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d1f5d74a..d59afcfe 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,26 +1,31 @@ name: CodeQL on: - push: - branches: [main] pull_request: + types: [opened, synchronize, reopened, ready_for_review] branches: [main] schedule: - cron: "0 9 * * 1" # weekly +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: analyze: + if: github.event_name != 'pull_request' || !github.event.pull_request.draft runs-on: ubuntu-latest + timeout-minutes: 20 permissions: security-events: write actions: read contents: read steps: - - uses: actions/checkout@v7 - - uses: github/codeql-action/init@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4 with: languages: go - - uses: github/codeql-action/autobuild@v4 - - uses: github/codeql-action/analyze@v4 + - uses: github/codeql-action/autobuild@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4 + - uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4 with: category: "/language:go" diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml index 9e286c8b..cb2bdc3c 100644 --- a/.github/workflows/govulncheck.yml +++ b/.github/workflows/govulncheck.yml @@ -1,18 +1,27 @@ name: Vulncheck on: - push: - branches: [main] pull_request: + types: [opened, synchronize, reopened, ready_for_review] schedule: - cron: "0 9 * * 1" # weekly, to catch newly-disclosed CVEs in deps +permissions: + contents: read + pull-requests: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: govulncheck: + if: github.event_name != 'pull_request' || !github.event.pull_request.draft runs-on: ubuntu-latest + timeout-minutes: 20 steps: - - uses: actions/checkout@v7 - - uses: actions/setup-go@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version-file: go.mod # Install + run govulncheck directly instead of golang/govulncheck-action, diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 860e8a15..8a3164d8 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,23 +1,72 @@ name: Lint on: - push: - branches: [main] pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + pull-requests: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + changes: + if: github.event_name != 'pull_request' || !github.event.pull_request.draft + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + code: ${{ steps.filter.outputs.code }} + steps: + - id: filter + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + if [ "$GITHUB_EVENT_NAME" != pull_request ]; then + echo 'code=true' >> "$GITHUB_OUTPUT" + exit 0 + fi + total=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq .changed_files) + gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" > "$RUNNER_TEMP/changed-files.json" + code=$(jq --argjson total "$total" 'add | if length == 0 or length != $total then true else any(.[]; ([.filename, (.previous_filename // .filename)] | any(.[]; test("^(docs/.*\\.md|[^/]+\\.md|LICENSE)$") | not))) end' "$RUNNER_TEMP/changed-files.json") + echo "code=$code" >> "$GITHUB_OUTPUT" + golangci: + needs: changes + if: needs.changes.outputs.code == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) runs-on: ubuntu-latest + timeout-minutes: 20 steps: - - uses: actions/checkout@v7 - - uses: actions/setup-go@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version-file: go.mod cache: true - - uses: golangci/golangci-lint-action@v9 + - uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9 with: version: v2.12.2 # Don't reuse the action's analysis cache: a stale cache was pinning # SA5011 false positives that a fresh run (and local v2.12.2) doesn't # report. Re-analyze each run for correct, reproducible results. skip-cache: true + + result: + name: ${{ github.workflow }} result + if: always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) + needs: [changes, golangci] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - env: + RESULTS: ${{ toJSON(needs) }} + run: | + python3 - <<'PYTHON' + import json, os + results = json.loads(os.environ['RESULTS']) + assert results['changes']['result'] == 'success', 'Change detection did not succeed' + assert all(job['result'] in ('success', 'skipped') for job in results.values()), results + PYTHON diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 201d483f..ed5dcc2b 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -27,14 +27,15 @@ concurrency: jobs: deploy: runs-on: ubuntu-latest + timeout-minutes: 20 environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - - uses: actions/checkout@v7 - - uses: actions/configure-pages@v6 - - uses: actions/upload-pages-artifact@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6 + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5 with: path: site - id: deployment - uses: actions/deploy-pages@v5 + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4b4fb4ce..f10726f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,8 @@ name: Release # binaries + checksums, creates the GitHub Release, and updates the Homebrew tap. on: push: + branches: [main] + paths: ['.github/release-request'] tags: ["v*"] # Manual release valve: when a tag can't be pushed directly (e.g. from a # restricted/sandboxed clone where `git push origin ` is blocked), @@ -19,35 +21,72 @@ on: permissions: contents: write + pull-requests: read + +concurrency: + group: release + cancel-in-progress: false jobs: goreleaser: + outputs: + tag: ${{ steps.release.outputs.tag }} runs-on: ubuntu-latest + timeout-minutes: 60 steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 # full history so GoReleaser can build the changelog + - name: Require an authorized release PR + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if [ "$GITHUB_EVENT_NAME" = workflow_dispatch ] && [ "$GITHUB_REF" != refs/heads/main ]; then + echo 'Manual releases must run from main'; exit 1 + fi + gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" --paginate --slurp > "$RUNNER_TEMP/release-prs.json" + jq -e --arg sha "$GITHUB_SHA" --arg repo "$GITHUB_REPOSITORY" 'add | any(.[]; .merged_at != null and .merge_commit_sha == $sha and .base.ref == "main" and .head.repo.full_name == $repo and (.head.ref | startswith("release/")))' "$RUNNER_TEMP/release-prs.json" + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 # On manual dispatch, mint the tag here (the default token may push refs # inside Actions) so GoReleaser sees a tagged HEAD. A tag pushed with the # default GITHUB_TOKEN does not re-trigger this workflow, so there's no # double release — this same run carries the build through. - - name: Create and push tag (manual dispatch) - if: github.event_name == 'workflow_dispatch' + with: + fetch-depth: 0 + + - name: Validate merged release and create its tag + id: release + env: + REQUESTED_TAG: ${{ inputs.tag }} run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git tag -a "${{ inputs.tag }}" -m "nt ${{ inputs.tag }}" - git push origin "${{ inputs.tag }}" - - uses: actions/setup-go@v7 + set -euo pipefail + git fetch origin main + git merge-base --is-ancestor "$GITHUB_SHA" origin/main + tag="$REQUESTED_TAG" + if [[ "$GITHUB_REF" == refs/tags/* ]]; then + tag="$GITHUB_REF_NAME" + elif [ -z "$tag" ]; then + tag="$(cat .github/release-request)" + fi + [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || { echo 'Invalid release tag'; exit 1; } + [ "$(cat .github/release-request)" = "$tag" ] || { echo "Release request and tag differ"; exit 1; } + if git rev-parse "refs/tags/$tag" >/dev/null 2>&1; then + [ "$(git rev-list -n 1 "$tag")" = "$GITHUB_SHA" ] || { echo 'Tag belongs to another commit'; exit 1; } + else + git tag "$tag" "$GITHUB_SHA" + git push origin "refs/tags/$tag" + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "RELEASE_TAG=$tag" >> "$GITHUB_ENV" + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version-file: go.mod cache: true - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 cache: npm cache-dependency-path: internal/web/frontend/package-lock.json - - uses: goreleaser/goreleaser-action@v7 + - uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7 with: version: "~> v2" args: release --clean @@ -82,6 +121,7 @@ jobs: platform: windows/amd64 tags: production runs-on: ${{ matrix.os }} + timeout-minutes: 60 defaults: run: working-directory: desktop @@ -97,17 +137,17 @@ jobs: # Build the tagged commit in both trigger modes: on a tag push that's # GITHUB_REF; on manual dispatch it's the tag the goreleaser job just # created (this job `needs` it, so the tag already exists on the remote). - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} + ref: ${{ needs.goreleaser.outputs.tag }} - - uses: actions/setup-go@v7 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version-file: desktop/go.mod cache: true cache-dependency-path: desktop/go.sum - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 cache: npm @@ -118,7 +158,7 @@ jobs: # On a tag push the ref name IS the tag; on dispatch take it from the # input. RELEASE_TAG is what `gh release upload` targets below. run: | - REF="${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}" + REF="${{ needs.goreleaser.outputs.tag }}" echo "VER=${REF#v}" >> "$GITHUB_ENV" echo "RELEASE_TAG=${REF}" >> "$GITHUB_ENV" diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 56042991..18047836 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -4,19 +4,51 @@ name: Web # committed dist/ — which is embedded into the Go binary so `go build`/`go install` # work without Node — is up to date with the source. on: - push: - branches: [main] pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + pull-requests: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + changes: + if: github.event_name != 'pull_request' || !github.event.pull_request.draft + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + code: ${{ steps.filter.outputs.code }} + steps: + - id: filter + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + if [ "$GITHUB_EVENT_NAME" != pull_request ]; then + echo 'code=true' >> "$GITHUB_OUTPUT" + exit 0 + fi + total=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq .changed_files) + gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" > "$RUNNER_TEMP/changed-files.json" + code=$(jq --argjson total "$total" 'add | if length == 0 or length != $total then true else any(.[]; ([.filename, (.previous_filename // .filename)] | any(.[]; test("^(docs/.*\\.md|[^/]+\\.md|LICENSE)$") | not))) end' "$RUNNER_TEMP/changed-files.json") + echo "code=$code" >> "$GITHUB_OUTPUT" + web: + needs: changes + if: needs.changes.outputs.code == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) runs-on: ubuntu-latest + timeout-minutes: 20 defaults: run: working-directory: internal/web/frontend steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 cache: npm @@ -37,17 +69,20 @@ jobs: fi e2e: + needs: changes + if: needs.changes.outputs.code == 'true' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) runs-on: ubuntu-latest + timeout-minutes: 20 defaults: run: working-directory: internal/web/frontend steps: - - uses: actions/checkout@v7 - - uses: actions/setup-go@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version-file: go.mod cache: true - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22 cache: npm @@ -59,9 +94,26 @@ jobs: run: npx playwright install --with-deps chromium - name: Run e2e (real nt web --spa, seeded store) run: npm run e2e - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 if: ${{ !cancelled() }} with: + retention-days: 7 name: playwright-report path: internal/web/frontend/playwright-report/ - retention-days: 7 + + result: + name: ${{ github.workflow }} result + if: always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) + needs: [changes, web, e2e] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - env: + RESULTS: ${{ toJSON(needs) }} + run: | + python3 - <<'PYTHON' + import json, os + results = json.loads(os.environ['RESULTS']) + assert results['changes']['result'] == 'success', 'Change detection did not succeed' + assert all(job['result'] in ('success', 'skipped') for job in results.values()), results + PYTHON diff --git a/.goreleaser.yaml b/.goreleaser.yaml index c390b235..9bcf98a9 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -58,6 +58,11 @@ homebrew_casks: - repository: owner: navbytes name: homebrew-tap + branch: "release/nt-{{ .Version }}" + pull_request: + enabled: true + base: + branch: main token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" homepage: "https://github.com/navbytes/nt" description: "Terminal task & note manager — durable memory for AI coding sessions" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..81d266fb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,3 @@ +# Repository workflow + +For change and release authorization, follow [.github/RELEASE-POLICY.md](.github/RELEASE-POLICY.md). From 1c3f9136fda2317e86ecdd222b3602abcb45819c Mon Sep 17 00:00:00 2001 From: Naveen Kumar Date: Mon, 21 Sep 2026 14:20:04 +0800 Subject: [PATCH 2/3] Bind CI classification to event revisions and recover tap PR retries --- .github/workflows/ci.yml | 21 +++++++++++++++++---- .github/workflows/lint.yml | 21 +++++++++++++++++---- .github/workflows/web.yml | 21 +++++++++++++++++---- 3 files changed, 51 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 520b1097..ef0c8cd1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,16 +24,29 @@ jobs: env: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ github.event.pull_request.number }} + PR_HEAD: ${{ github.event.pull_request.head.sha }} + PR_BASE: ${{ github.event.pull_request.base.sha }} run: | set -euo pipefail if [ "$GITHUB_EVENT_NAME" != pull_request ]; then echo 'code=true' >> "$GITHUB_OUTPUT" exit 0 fi - total=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq .changed_files) - gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" > "$RUNNER_TEMP/changed-files.json" - code=$(jq --argjson total "$total" 'add | if length == 0 or length != $total then true else any(.[]; ([.filename, (.previous_filename // .filename)] | any(.[]; test("^(docs/.*\\.md|[^/]+\\.md|LICENSE)$") | not))) end' "$RUNNER_TEMP/changed-files.json") - echo "code=$code" >> "$GITHUB_OUTPUT" + endpoint="repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" + gh api "$endpoint" > "$RUNNER_TEMP/pr-before.json" + if ! jq -e --arg head "$PR_HEAD" --arg base "$PR_BASE" '.head.sha == $head and .base.sha == $base' "$RUNNER_TEMP/pr-before.json" >/dev/null; then + echo 'code=true' >> "$GITHUB_OUTPUT" + exit 0 + fi + total=$(jq -er '.changed_files' "$RUNNER_TEMP/pr-before.json") + gh api --paginate --slurp "$endpoint/files" > "$RUNNER_TEMP/changed-files.json" + gh api "$endpoint" > "$RUNNER_TEMP/pr-after.json" + if ! jq -e --arg head "$PR_HEAD" --arg base "$PR_BASE" '.head.sha == $head and .base.sha == $base' "$RUNNER_TEMP/pr-after.json" >/dev/null; then + echo 'code=true' >> "$GITHUB_OUTPUT" + exit 0 + fi + value=$(jq --argjson total "$total" 'add | if length == 0 or length != $total then true else any(.[]; ([.filename, (.previous_filename // .filename)] | any(.[]; test("^(docs/.*\\.md|[^/]+\\.md|LICENSE)$") | not))) end' "$RUNNER_TEMP/changed-files.json") + echo "code=$value" >> "$GITHUB_OUTPUT" test: needs: changes diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8a3164d8..4dd0de05 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -24,16 +24,29 @@ jobs: env: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ github.event.pull_request.number }} + PR_HEAD: ${{ github.event.pull_request.head.sha }} + PR_BASE: ${{ github.event.pull_request.base.sha }} run: | set -euo pipefail if [ "$GITHUB_EVENT_NAME" != pull_request ]; then echo 'code=true' >> "$GITHUB_OUTPUT" exit 0 fi - total=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq .changed_files) - gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" > "$RUNNER_TEMP/changed-files.json" - code=$(jq --argjson total "$total" 'add | if length == 0 or length != $total then true else any(.[]; ([.filename, (.previous_filename // .filename)] | any(.[]; test("^(docs/.*\\.md|[^/]+\\.md|LICENSE)$") | not))) end' "$RUNNER_TEMP/changed-files.json") - echo "code=$code" >> "$GITHUB_OUTPUT" + endpoint="repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" + gh api "$endpoint" > "$RUNNER_TEMP/pr-before.json" + if ! jq -e --arg head "$PR_HEAD" --arg base "$PR_BASE" '.head.sha == $head and .base.sha == $base' "$RUNNER_TEMP/pr-before.json" >/dev/null; then + echo 'code=true' >> "$GITHUB_OUTPUT" + exit 0 + fi + total=$(jq -er '.changed_files' "$RUNNER_TEMP/pr-before.json") + gh api --paginate --slurp "$endpoint/files" > "$RUNNER_TEMP/changed-files.json" + gh api "$endpoint" > "$RUNNER_TEMP/pr-after.json" + if ! jq -e --arg head "$PR_HEAD" --arg base "$PR_BASE" '.head.sha == $head and .base.sha == $base' "$RUNNER_TEMP/pr-after.json" >/dev/null; then + echo 'code=true' >> "$GITHUB_OUTPUT" + exit 0 + fi + value=$(jq --argjson total "$total" 'add | if length == 0 or length != $total then true else any(.[]; ([.filename, (.previous_filename // .filename)] | any(.[]; test("^(docs/.*\\.md|[^/]+\\.md|LICENSE)$") | not))) end' "$RUNNER_TEMP/changed-files.json") + echo "code=$value" >> "$GITHUB_OUTPUT" golangci: needs: changes diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 18047836..a9b9e0d2 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -27,16 +27,29 @@ jobs: env: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ github.event.pull_request.number }} + PR_HEAD: ${{ github.event.pull_request.head.sha }} + PR_BASE: ${{ github.event.pull_request.base.sha }} run: | set -euo pipefail if [ "$GITHUB_EVENT_NAME" != pull_request ]; then echo 'code=true' >> "$GITHUB_OUTPUT" exit 0 fi - total=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq .changed_files) - gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" > "$RUNNER_TEMP/changed-files.json" - code=$(jq --argjson total "$total" 'add | if length == 0 or length != $total then true else any(.[]; ([.filename, (.previous_filename // .filename)] | any(.[]; test("^(docs/.*\\.md|[^/]+\\.md|LICENSE)$") | not))) end' "$RUNNER_TEMP/changed-files.json") - echo "code=$code" >> "$GITHUB_OUTPUT" + endpoint="repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" + gh api "$endpoint" > "$RUNNER_TEMP/pr-before.json" + if ! jq -e --arg head "$PR_HEAD" --arg base "$PR_BASE" '.head.sha == $head and .base.sha == $base' "$RUNNER_TEMP/pr-before.json" >/dev/null; then + echo 'code=true' >> "$GITHUB_OUTPUT" + exit 0 + fi + total=$(jq -er '.changed_files' "$RUNNER_TEMP/pr-before.json") + gh api --paginate --slurp "$endpoint/files" > "$RUNNER_TEMP/changed-files.json" + gh api "$endpoint" > "$RUNNER_TEMP/pr-after.json" + if ! jq -e --arg head "$PR_HEAD" --arg base "$PR_BASE" '.head.sha == $head and .base.sha == $base' "$RUNNER_TEMP/pr-after.json" >/dev/null; then + echo 'code=true' >> "$GITHUB_OUTPUT" + exit 0 + fi + value=$(jq --argjson total "$total" 'add | if length == 0 or length != $total then true else any(.[]; ([.filename, (.previous_filename // .filename)] | any(.[]; test("^(docs/.*\\.md|[^/]+\\.md|LICENSE)$") | not))) end' "$RUNNER_TEMP/changed-files.json") + echo "code=$value" >> "$GITHUB_OUTPUT" web: needs: changes From d0dda645d93edb885e95fceb18a9f4875fe1cb44 Mon Sep 17 00:00:00 2001 From: Naveen Kumar Date: Mon, 21 Sep 2026 14:32:46 +0800 Subject: [PATCH 3/3] Run vulnerability scanner with its required Go 1.26 toolchain --- .github/workflows/govulncheck.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml index cb2bdc3c..1addb454 100644 --- a/.github/workflows/govulncheck.yml +++ b/.github/workflows/govulncheck.yml @@ -23,7 +23,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: - go-version-file: go.mod + go-version: '1.26.x' # Install + run govulncheck directly instead of golang/govulncheck-action, # whose internal git checkout was failing in CI with "unable to access … 400" # (git exit 128) on every run. This direct invocation is what we run locally