Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
26 changes: 26 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Normalize line endings to LF in the repository and working tree. scc runs on
# Windows/WSL where autocrlf otherwise makes the entire tree show as Modified and
# breaks gofmt/line-ending-sensitive checks on Windows CI. LF everywhere keeps
# manifest hashes, Go source, and shell hooks consistent across platforms.
* text=auto eol=lf

# Go, docs, shell hooks, and config are always LF.
*.go text eol=lf
*.mod text eol=lf
*.sum text eol=lf
*.sh text eol=lf
*.md text eol=lf
*.json text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.tmpl text eol=lf
*.mjs text eol=lf
*.js text eol=lf

# Binary assets must never be line-ending converted.
*.png binary
*.jpg binary
*.gif binary
*.ico binary
*.gz binary
*.zip binary
100 changes: 100 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
name: ci

# Test + validate gate. Runs on:
# - every pull request — validate changes before they can merge.
# - push to main — validate the merge result on the default branch, since the
# release workflow is manual (workflow_dispatch) and does not run on push,
# so a merge would otherwise go unverified.
# - workflow_call — reusable, so the manual release pipeline requires this gate
# before building or publishing any binaries.
on:
push:
branches: [main]
pull_request:
workflow_call:

permissions:
contents: read

jobs:
test:
name: test (${{ matrix.os }})
# Windows is a first-class target and the tool is path/line-ending heavy, so
# exercise the suite on all three OSes. fail-fast is off so one platform's
# failure still lets the others report.
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

wc -l .github/workflows/ci.yml
cat -n .github/workflows/ci.yml | sed -n '1,220p'

Repository: protonspy/spec-claude-code

Length of output: 4730


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

p = Path('.github/workflows/ci.yml')
text = p.read_text()
print("checkout occurrences:", text.count('actions/checkout'))
for m in re.finditer(r'actions/checkout@[^\s#]+(?:\s*#\s*([^\n]+))?', text):
    start = text.rfind('\n', 0, m.start()) + 1
    end = text.find('\n', m.start())
    print(text[start:end])
PY

Repository: protonspy/spec-claude-code

Length of output: 350


🌐 Web query:

actions/checkout v5 persist-credentials default token .git/config

💡 Result:

In actions/checkout v5, the persist-credentials input defaults to true [1][2]. When persist-credentials is set to true (the default), actions/checkout configures the GitHub Actions runner to authenticate Git commands by writing credential information to a file [3][4]. Crucially, in v5.0.1 and later versions of the v5 release, this behavior was updated: rather than writing credentials directly into the local .git/config file, the action stores them in a separate configuration file located within the $RUNNER_TEMP directory [4]. The main .git/config is then updated with an include.path directive to reference this temporary file [4]. This change was implemented to improve security by isolating the credentials from the repository's main configuration and preventing accidental leakage [4]. The credentials are removed during the post-job cleanup process [3]. If you wish to prevent this behavior, you can explicitly set persist-credentials: false in your workflow step [3].

Citations:


Disable persisted checkout credentials on both checkout steps. The default checkout config leaves a repo-scoped credential available to later steps; set persist-credentials: false here and at line 72 so PR code can’t reuse it.

🧰 Tools
🪛 zizmor (1.28.0)

[warning] 31-31: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml at line 31, Update both actions/checkout steps in
the CI workflow, including the step pinned to v5.0.1 and the checkout step near
line 72, to set persist-credentials to false. Keep the existing checkout
references and other configuration unchanged.

Source: Linters/SAST tools


- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
# Build/test with a current, patched toolchain. Keep this pin AHEAD of
# go.mod's directive and do NOT switch to go-version-file: go.mod —
# install-compat with older toolchains rides on GOTOOLCHAIN=auto.
# Pinned to .12 for the GO-2026-5856 crypto/tls fix; the float '1.25'
# still resolves to .11 on hosted runners (manifest lag). Float back
# once runners catch up.
go-version: '1.25.12'
cache: true

- name: go vet
run: go vet ./...

- name: build
run: go build ./...

# The race detector needs a C toolchain + external linker, which the
# Windows runners don't provide out of the box, so run race+coverage on
# Linux/macOS and a plain (still cgo-free) test pass on Windows.
- name: test (race + coverage)
if: runner.os != 'Windows'
run: go test -race -coverprofile=coverage.out ./...

- name: test (coverage, no race)
if: runner.os == 'Windows'
# Force bash so the coverprofile arg parses like Linux/macOS; the Windows
# runner's default PowerShell mangles `-coverprofile=coverage.out ./...`
# into a bogus `.out` package pattern.
shell: bash
run: go test -coverprofile=coverage.out ./...

lint:
name: lint & vulncheck
# gofmt and the static analyzers only need to run once; keep them on Linux
# (the Windows runner checks code out with CRLF endings, which gofmt -l would
# flag on every file).
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1

- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version: '1.25.12' # GO-2026-5856 crypto/tls fix; see the test job's note
cache: true

- name: gofmt
run: |
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
echo "::error::these files are not gofmt-clean:"
echo "$unformatted"
exit 1
fi

# Scan the module (and its dependencies) for known-vulnerable, *reachable*
# code paths. This is meant to fail the build — shipping binaries built on a
# flagged stdlib/dep is exactly what we're guarding against.
- name: govulncheck
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
"$(go env GOPATH)/bin/govulncheck" ./...

# golangci-lint runs the conservative set configured in .golangci.yml.
- name: golangci-lint
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
with:
version: v2.12.2
222 changes: 222 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
name: release

# Manual, versioned release — the only way a new version ships.
#
# Trigger from the Actions tab ("Run workflow") or:
# gh workflow run release.yml --field version=v0.1.0
# (or: make release VERSION=v0.1.0)
#
# It runs the full pipeline for the given version: CI gate → cross-platform
# binaries → publish every npm package (the launcher + 6 platform binaries, all
# at the same version) → tag the commit + GitHub Release. Nothing is released on
# push; releasing is always a deliberate manual action.
on:
workflow_dispatch:
inputs:
version:
description: 'Release version (vX.Y.Z)'
required: true
type: string

# Least privilege at the workflow level: only the final "release" job needs to
# write (create the tag + GitHub Release). validate/build/npm-publish keep the
# read-only default and override to what they actually need.
permissions:
contents: read

jobs:
validate:
name: validate version
runs-on: ubuntu-latest
steps:
- name: Check format
env:
VERSION: ${{ inputs.version }}
run: |
case "$VERSION" in
v[0-9]*.[0-9]*.[0-9]*) echo "releasing $VERSION" ;;
*) echo "::error::version must look like vX.Y.Z (got '$VERSION')"; exit 1 ;;
esac
Comment on lines +36 to +39

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use one strict release-version rule. Both checks accept malformed values such as v1.2.3-extra; npm packaging may then fail only after CI/build work has completed. Require the documented exact vX.Y.Z format with an anchored numeric regex.

  • .github/workflows/release.yml#L36-L39: replace the shell glob with an anchored numeric validation.
  • Makefile#L64-L66: enforce the same anchored numeric validation for local release targets.
📍 Affects 2 files
  • .github/workflows/release.yml#L36-L39 (this comment)
  • Makefile#L64-L66
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/release.yml around lines 36 - 39, Replace the shell glob
validation in the release workflow at .github/workflows/release.yml lines 36-39
with an anchored numeric regex that accepts only the exact vX.Y.Z format; update
the corresponding release-version check in Makefile lines 64-66 to use the
identical rule so CI and local release targets behave consistently.


- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
fetch-depth: 0 # tags — needed to tell a retry from a re-release

# A version is immutable. Re-dispatching an already-released version from a
# NEW commit is the one failure this pipeline cannot report: npm-publish
# skips every package that is already on the registry, the tag stays put,
# and the whole run goes green having shipped nothing. Fail loudly instead.
#
# Re-dispatching the SAME commit is still allowed: that is the intended way
# to resume a run that died between npm-publish and the tag/Release step.
- name: Refuse to re-release an existing version from a different commit
env:
VERSION: ${{ inputs.version }}
SHA: ${{ github.sha }}
run: |
set -euo pipefail
if ! git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then
echo "$VERSION is unreleased — proceeding"
exit 0
fi
tagged=$(git rev-list -n1 "refs/tags/$VERSION")
if [ "$tagged" != "$SHA" ]; then
echo "::error::$VERSION is already released and points at $tagged, but this run is on $SHA."
echo "::error::Publishing is idempotent, so re-running would ship nothing. Bump the version instead."
exit 1
fi
echo "$VERSION already tagged at this exact commit — resuming a partial release"

# Reuse the full CI gate (gofmt, vet, build, race tests, vulncheck) — no
# binaries are built or published unless this passes.
ci:
needs: validate
uses: ./.github/workflows/ci.yml

build:
name: build ${{ matrix.goos }}/${{ matrix.goarch }}
needs: ci
runs-on: ubuntu-latest
# Cross-compile only; no write access needed. Keep the read-only default
# (inherited from the workflow-level permissions above) explicit here.
permissions:
contents: read
strategy:
fail-fast: true
matrix:
include:
- { goos: linux, goarch: amd64 }
- { goos: linux, goarch: arm64 }
- { goos: darwin, goarch: amd64 }
- { goos: darwin, goarch: arm64 }
- { goos: windows, goarch: amd64 }
- { goos: windows, goarch: arm64 }
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1

- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version: '1.25.12' # see ci.yml's note before changing
cache: true

- name: Build & package
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: '0'
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
bin=scc
[ "$GOOS" = "windows" ] && bin=scc.exe
mkdir -p dist
go build -trimpath \
-ldflags "-s -w -X github.com/protonspy/spec-claude-code/internal/cli.version=${VERSION}" \
-o "dist/${bin}" ./cmd/scc
name="scc_${VERSION}_${GOOS}_${GOARCH}"
cd dist
if [ "$GOOS" = "windows" ]; then
zip -q "${name}.zip" "${bin}"
else
tar -czf "${name}.tar.gz" "${bin}"
fi
Comment on lines +112 to +122

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Make release archives and checksums reproducible.

The tar/zip metadata captures build-time timestamps, so rebuilding the same commit/version changes release assets and checksums.txt. Derive SOURCE_DATE_EPOCH from the target commit, normalize file/archive metadata, use zip -X, and explicitly sort checksum output.

As per coding guidelines, “Maintain deterministic generated artifacts by normalizing newlines to LF and sorting before serialization.”

Also applies to: 212-213

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/release.yml around lines 112 - 122, Update the release
archive and checksum steps around the tar/zip commands and checksum generation
to produce deterministic artifacts. Derive and export SOURCE_DATE_EPOCH from the
target commit, normalize archive file timestamps and metadata, use zip
metadata-free mode, and explicitly sort checksum input/output. Normalize
generated checksum content to LF before serialization while preserving the
existing archive naming and packaging behavior.

Source: Coding guidelines

rm -f "${bin}"

# Upload only what this run built, never `dist/*` — a stale tarball sitting
# in the checked-out dist/ would otherwise ride along into every Release.
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: scc-${{ matrix.goos }}-${{ matrix.goarch }}
path: dist/scc_${{ inputs.version }}_*
if-no-files-found: error
retention-days: 7

# Publish every npm package at the requested version. Authenticates with the
# NPM_TOKEN secret (must be an Automation token, which bypasses 2FA);
# id-token: write lets --provenance attach a signed build attestation.
npm-publish:
name: publish npm packages
needs: build
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # required for npm --provenance attestation
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1

- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'

# Trusted publishing requires npm >= 11.5.1 (newer than what ships with
# Node). Pin the major line so the release toolchain is reproducible.
- name: Upgrade npm
run: npm install -g npm@11
Comment on lines +152 to +155

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail
npm view npm@11.5.1 version engines dist.integrity --json

Repository: protonspy/spec-claude-code

Length of output: 676


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

sed -n '140,170p' .github/workflows/release.yml

Repository: protonspy/spec-claude-code

Length of output: 1347


Pin npm to 11.5.1. npm@11 will keep moving to newer 11.x patches, which breaks release reproducibility and can change the publishing toolchain without review.

🧰 Tools
🪛 zizmor (1.28.0)

[warning] 155-155: ad-hoc installation of packages (adhoc-packages): installs a package outside of a lockfile

(adhoc-packages)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/release.yml around lines 152 - 155, Update the “Upgrade
npm” workflow step to install the exact npm version 11.5.1 instead of the
floating npm@11 major-line reference, preserving the existing release toolchain
setup.

Source: Linters/SAST tools


- uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
path: artifacts
merge-multiple: true

- name: Assemble npm packages
env:
VERSION: ${{ inputs.version }}
run: node npm/scripts/build-packages.mjs "$VERSION" artifacts

- name: Publish (platform packages first, then the launcher)
env:
# Automation token (bypasses 2FA). setup-node wrote an .npmrc that
# reads this. --provenance still attaches a signed attestation via the
# job's id-token: write permission.
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
# Idempotent publish: skip any package@version already on the registry so
# re-running after a partial failure can finish instead of hard-failing on
# the first already-published package (mirrors the Makefile's npm-publish).
publish() {
local dir="$1"
local name ver
name=$(node -p "require('./$dir/package.json').name")
ver=$(node -p "require('./$dir/package.json').version")
if npm view "$name@$ver" version >/dev/null 2>&1; then
echo "skip $name@$ver (already published)"
return 0
fi
echo ">> publishing $name@$ver"
npm publish "$dir" --access public --provenance
}
# The platform packages must land before the launcher that depends on them.
for d in npm/dist/scc-*/; do
publish "${d%/}"
done
publish npm/dist/scc

# Tag the released commit and create the GitHub Release — only after npm
# publishing succeeds, so a failed publish never leaves a dangling tag/release.
release:
name: tag + GitHub release
needs: npm-publish
runs-on: ubuntu-latest
# This is the only job that mutates the repo (creates the tag + Release), so
# scope write access here instead of granting it workflow-wide.
permissions:
contents: write
steps:
- uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
path: dist
merge-multiple: true

- name: Checksums
run: cd dist && sha256sum * | tee checksums.txt

- name: Create tag + release
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
tag_name: ${{ inputs.version }}
name: ${{ inputs.version }}
target_commitish: ${{ github.sha }}
make_latest: 'true'
files: dist/*
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,13 @@ go.work.sum
# Editor/IDE
# .idea/
# .vscode/

# The locally built binary (`make build`).
/scc
/scc.exe

# Release/publish staging: cross-compiled tarballs, downloaded CI artifacts, and
# the assembled npm publish tree. All reproducible from a tag — never committed.
/dist/
/artifacts/
npm/dist/
32 changes: 32 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# golangci-lint configuration (v2 schema — golangci-lint v2.x).
#
# Deliberately conservative: only the standard, high-signal analyzers are
# enabled so CI stays green-friendly instead of drowning in style nits. Add more
# linters here as the codebase grows.
version: "2"

linters:
default: none
enable:
- errcheck # unchecked errors
- govet # go vet's analyzers
- ineffassign # assignments never read
- staticcheck # staticcheck (SA/S/ST) suite
- unused # unused code
exclusions:
rules:
# Test code is intentionally lax about unchecked errors (Close/Chdir/…) and
# uses patterns staticcheck false-positives on (e.g. Hash("a") != Hash("a")
# to assert determinism).
- path: _test\.go
linters:
- errcheck
- staticcheck
# QF1001 (apply De Morgan's law) and S1016 (struct literal -> conversion)
# are style suggestions where the explicit form reads clearer; keep it.
- linters:
- staticcheck
text: "QF1001"
- linters:
- staticcheck
text: "S1016"
Loading
Loading