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
16 changes: 16 additions & 0 deletions .claude/knowledge/deployment-release.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Deployment & Release

How code gets to production. Release processes, environment promotion, rollback procedures, gotchas.

## Release flow

- Stable releases: push to `main` → `cd.yml` runs `release-please` → when a release PR merges, the `stable-release` job calls the **reusable** `release.yml` (`workflow_call`) with `prerelease: false`. Prereleases go through `cd.yml`'s `prepare-prerelease` → `release.yml` with `prerelease: true`.
- `release.yml` has **no `on: push: tags` trigger** — this is deliberate. Tags are minted only through the release-please approval flow (or a `create_missing_tag: true` call from the trusted `cd.yml` caller). Do not add a tag-push trigger; it would let anyone pushing a tag bypass the approval flow.
- GoReleaser (`.goreleaser.yaml`) builds only **darwin + linux on amd64/arm64** — no Windows. Archives are `tar.gz` named `codeguard_v<version>_<os>_<arch>.tar.gz` (note the literal `v` before the version).

## npm + PyPI packaging (packaging/)

- npm/PyPI ship thin wrappers around the prebuilt GoReleaser binaries — no Go toolchain at install time. `packaging/extract-binaries.sh` downloads release assets, `npm/build.sh` and `pypi/build_wheels.py` assemble artifacts. `publish-npm`/`publish-pypi` jobs in `release.yml` run for stable releases only. See `packaging/README.md`.
- **GOTCHA — trusted publishing + reusable workflow:** the publish jobs live in `release.yml`, but npm and PyPI OIDC trusted publishing match the **top-level *calling* workflow**, not the reusable one. So the trusted publisher on npmjs.com / PyPI must be configured with workflow filename **`cd.yml`**, NOT `release.yml`. PyPI additionally forbids naming a reusable workflow as the publisher outright (warehouse#11096). `id-token: write` must be present on both the `cd.yml` caller jobs (it is) and the `release.yml` publish jobs.
- First automated publish needs one-time manual bootstrap: `packaging/npm/bootstrap-publish.sh` (npm requires a package to exist before a trusted publisher can be added) and a PyPI *pending publisher* for project `devr-codeguard`. Both are documented step-by-step in `packaging/README.md`.
- **PyPI project name is `devr-codeguard`, NOT `codeguard`** (the plain name was already taken on PyPI). The npm scope `@devr-tools/codeguard` is unaffected. The installed command is still `codeguard` regardless — in `build_wheels.py`, `PROJECT`/`DIST` (distribution name, hyphen vs. PEP 427 escaped `devr_codeguard`) are decoupled from `BIN`/the data-script name (`codeguard`), so `pip install devr-codeguard` yields a `codeguard` command.
122 changes: 122 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -409,3 +409,125 @@ jobs:
echo "- SHA256: `$FORMULA_SHA256`"
echo "- Suggested branch: `$FORMULA_BRANCH`"
} >>"$GITHUB_STEP_SUMMARY"

publish-npm:
needs: build-release
if: needs.build-release.outputs.prerelease != 'true'
runs-on: ubuntu-latest
permissions:
contents: read
# Required for npm trusted publishing (OIDC) — no NPM_TOKEN needed.
# Matches the top-level caller (cd.yml) configured as the trusted
# publisher; the id-token: write on cd.yml's stable-release job propagates
# here because release.yml is called from it.
id-token: write
steps:
- name: Check out code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7

- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "22"

- name: Upgrade npm for trusted publishing
# Trusted publishing (OIDC) requires npm >= 11.5.1; the runner ships an
# older npm by default.
run: npm install -g npm@latest

- name: Resolve package version
id: version
shell: bash
env:
RELEASE_TAG: ${{ needs.build-release.outputs.tag }}
run: |
set -euo pipefail
if [[ "$RELEASE_TAG" == *-v* ]]; then
version="${RELEASE_TAG##*-v}"
else
version="${RELEASE_TAG#v}"
fi
echo "version=$version" >>"$GITHUB_OUTPUT"

- name: Stage release binaries
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
./packaging/extract-binaries.sh "${{ needs.build-release.outputs.tag }}" "${{ steps.version.outputs.version }}"

- name: Build npm packages
run: ./packaging/npm/build.sh "${{ steps.version.outputs.version }}"

- name: Publish to npm
# Authentication is via OIDC trusted publishing (id-token: write above);
# no NODE_AUTH_TOKEN/.npmrc. Provenance is attached automatically.
shell: bash
run: |
set -euo pipefail

publish_dir() {
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 "$name@$ver already published, skipping"
else
npm publish "$dir" --access public
fi
}

# Platform packages first so the launcher's optional deps resolve.
for dir in packaging/npm/dist/codeguard-*/package; do
publish_dir "$dir"
done
publish_dir packaging/npm/dist/codeguard/package

publish-pypi:
needs: build-release
if: needs.build-release.outputs.prerelease != 'true'
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # PyPI trusted publishing (OIDC) — no PYPI_API_TOKEN needed.
steps:
- name: Check out code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7

- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"

- name: Resolve package version
id: version
shell: bash
env:
RELEASE_TAG: ${{ needs.build-release.outputs.tag }}
run: |
set -euo pipefail
if [[ "$RELEASE_TAG" == *-v* ]]; then
version="${RELEASE_TAG##*-v}"
else
version="${RELEASE_TAG#v}"
fi
echo "version=$version" >>"$GITHUB_OUTPUT"

- name: Stage release binaries
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
./packaging/extract-binaries.sh "${{ needs.build-release.outputs.tag }}" "${{ steps.version.outputs.version }}"

- name: Build wheels
run: |
set -euo pipefail
python3 packaging/pypi/build_wheels.py "${{ steps.version.outputs.version }}" packaging/.staging packaging/pypi/dist

- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1
with:
packages-dir: packaging/pypi/dist
skip-existing: true
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ coverage.*
*.coverprofile
profile.cov
dist/
packaging/.staging/
.gocache/
.gomodcache/
.codeguard/cache.json
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,21 @@ Other install paths:
- Homebrew: `brew install devr-tools/tap/codeguard`
- GitHub Marketplace Action: `Devr Codeguard`

npm (installs a prebuilt binary, no Go toolchain required):

```bash
npm install -g @devr-tools/codeguard
codeguard version
```

pip (installs a prebuilt binary per platform; the project is `devr-codeguard`
because the plain `codeguard` name is taken, but the command is still `codeguard`):

```bash
pip install devr-codeguard
codeguard version
```

```yaml
- name: Devr Codeguard
uses: devr-tools/codeguard@v0.2.0
Expand Down
133 changes: 133 additions & 0 deletions packaging/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Packaging: npm and PyPI

`codeguard` is a Go binary released by GoReleaser to GitHub Releases. The npm and
PyPI packages here are thin wrappers that ship those **prebuilt binaries** — no
Go toolchain, and no network access, at install time.

| Registry | Install | Package(s) |
| --- | --- | --- |
| npm | `npm install -g @devr-tools/codeguard` | `@devr-tools/codeguard` (launcher) + one `@devr-tools/codeguard-<os>-<cpu>` per platform |
| PyPI | `pip install devr-codeguard` | one `devr_codeguard-<ver>-py3-none-<platform>.whl` per platform |

Both are published automatically by `.github/workflows/release.yml` for **stable**
releases only (prereleases are skipped, matching the Homebrew job).

## How it works

- **npm** uses esbuild-style `optionalDependencies`. The `@devr-tools/codeguard`
launcher declares one optional dependency per platform (each constrained by
`os`/`cpu`). npm installs only the package matching the host, and the
`bin/codeguard` launcher `require.resolve`s that package's binary and execs it.
- **PyPI** ships one wheel per platform. The project is named **`devr-codeguard`**
(the plain `codeguard` name is already taken), but the installed command is
still `codeguard`: each wheel carries the binary as a *data script*
(`devr_codeguard-<ver>.data/scripts/codeguard`), so pip drops `codeguard`
straight onto PATH. There is no Python module and no shim.

## Layout

```
packaging/
extract-binaries.sh # download + extract release binaries into .staging/
npm/
launcher/bin/codeguard # the launcher shim (committed source of truth)
launcher/README.md # README published with the main npm package
build.sh # generate dist/ from .staging/ + version
bootstrap-publish.sh # one-time manual publish to seed trusted publishing
pypi/
build_wheels.py # generate dist/*.whl from .staging/ + version
```

## Build and test locally

```bash
# 1. Stage binaries from a published release (needs an authenticated gh).
./packaging/extract-binaries.sh v0.7.0 0.7.0

# 2. npm packages -> packaging/npm/dist/
./packaging/npm/build.sh 0.7.0

# 3. wheels -> packaging/pypi/dist/
python3 packaging/pypi/build_wheels.py 0.7.0 packaging/.staging packaging/pypi/dist
```

`npm publish --dry-run <dir>` and `pipx run twine check packaging/pypi/dist/*.whl`
validate the artifacts without publishing.

## Release flow and the reusable-workflow caveat

Unlike a repo that triggers `release.yml` directly on a tag push, codeguard's
`release.yml` is a **reusable workflow** (`workflow_call` only) invoked by
`cd.yml`:

```
push to main -> cd.yml (release-please) -> stable-release job -> release.yml
```

The `publish-npm` and `publish-pypi` jobs live inside `release.yml` (where the
release binaries are built), but **npm and PyPI trusted publishing match the
top-level *calling* workflow, not the reusable one.** So the trusted publisher
must be configured against **`cd.yml`**, and `id-token: write` is set on both the
`cd.yml` caller jobs and the `release.yml` publish jobs.

> PyPI does not allow a *reusable* workflow to be named as the trusted publisher
> (warehouse#11096); naming the `cd.yml` caller is the supported path.

## One-time prerequisites (before the first automated release)

These are external-registry setup steps the CI cannot perform for you. Both
registries use OIDC trusted publishing — no long-lived tokens live in CI.

1. **npm org + scope.** Create/own the `@devr-tools` npm org (or org scope) so
the scoped packages can be published publicly.

2. **npm trusted publishing (OIDC).** npm requires each package to *already
exist* before you can add a trusted publisher, and trusted publishers are
configured **per package**. So:

a. **Bootstrap once** — publish all five packages from your machine (needs
`npm login` or a token in `~/.npmrc` with publish rights to
`@devr-tools`):

```bash
./packaging/npm/bootstrap-publish.sh v0.7.0 0.7.0
```

b. **Configure a trusted publisher** for each package on npmjs.com
(package → Settings → Trusted Publisher → GitHub Actions):
- Organization/user: `devr-tools`
- Repository: `codeguard`
- Workflow filename: `cd.yml` ← the caller, not release.yml
- Environment: *(leave blank)*
- Allowed actions: `npm publish`

Packages: `@devr-tools/codeguard` plus
`@devr-tools/codeguard-{darwin-x64,darwin-arm64,linux-x64,linux-arm64}`.

After that, the `publish-npm` job publishes via OIDC (`id-token: write`,
npm ≥ 11.5.1 which the job installs). No `NPM_TOKEN` needed, and provenance
is attached automatically.

3. **PyPI trusted publisher (OIDC).** On PyPI, add a *pending publisher* for
project `devr-codeguard` (the plain `codeguard` name is taken; the installed
command is still `codeguard`):
- Owner / repo: `devr-tools/codeguard`
- Workflow filename: `cd.yml` ← the caller, not release.yml
- Environment: *(leave blank — the job sets none)*

This lets the `publish-pypi` job authenticate via `id-token: write` with no
long-lived token. (Alternatively, set a `PYPI_API_TOKEN` secret and pass it
to `pypa/gh-action-pypi-publish` with `password:`.)

## Platform matrix

Keep this in sync with the `.goreleaser.yaml` build matrix. Currently:

| GOOS/GOARCH | npm package | wheel platform tag(s) |
| --- | --- | --- |
| darwin/amd64 | `@devr-tools/codeguard-darwin-x64` | `macosx_10_9_x86_64` |
| darwin/arm64 | `@devr-tools/codeguard-darwin-arm64` | `macosx_11_0_arm64` |
| linux/amd64 | `@devr-tools/codeguard-linux-x64` | `manylinux2014_x86_64`, `musllinux_1_1_x86_64` |
| linux/arm64 | `@devr-tools/codeguard-linux-arm64` | `manylinux2014_aarch64`, `musllinux_1_1_aarch64` |

GoReleaser builds no Windows target, so there is no Windows wrapper.
52 changes: 52 additions & 0 deletions packaging/extract-binaries.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
#
# Download the GoReleaser archives for a release tag and extract each
# `codeguard` binary into a stable staging layout that the npm and PyPI
# builders consume:
#
# <staging>/<goos>_<goarch>/codeguard
#
# Usage: extract-binaries.sh <tag> <version> [staging_dir]
#
# tag release tag, e.g. v0.7.0 or codeguard-v0.7.0
# version bare semver used in asset names, e.g. 0.7.0
# staging_dir output dir (default: packaging/.staging)
#
# Requires: gh (authenticated), tar.
set -euo pipefail

tag="${1:?tag required}"
version="${2:?version required}"
staging="${3:-"$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.staging"}"

# goos goarch -> archive suffix. Keep in sync with .goreleaser.yaml build matrix.
# codeguard builds darwin/linux on amd64/arm64 only (no windows), all tar.gz.
targets=(
"darwin amd64 tar.gz"
"darwin arm64 tar.gz"
"linux amd64 tar.gz"
"linux arm64 tar.gz"
)

workdir="$(mktemp -d)"
trap 'rm -rf "$workdir"' EXIT

rm -rf "$staging"
mkdir -p "$staging"

for target in "${targets[@]}"; do
read -r goos goarch ext <<<"$target"
# Matches .goreleaser.yaml name_template:
# "{{ .ProjectName }}_v{{ .Version }}_{{ .Os }}_{{ .Arch }}"
asset="codeguard_v${version}_${goos}_${goarch}.${ext}"
dest="$staging/${goos}_${goarch}"
mkdir -p "$dest"

echo "==> $asset"
gh release download "$tag" --repo devr-tools/codeguard --pattern "$asset" --dir "$workdir" --clobber

tar -xzf "$workdir/$asset" -C "$dest" codeguard
chmod +x "$dest/codeguard"
done

echo "Binaries staged in $staging"
Loading
Loading