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
23 changes: 13 additions & 10 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ name: Release
on:
push:
branches: [main]
workflow_dispatch:

permissions:
contents: write
issues: write
pull-requests: write
packages: write
id-token: write

jobs:
next-version:
Expand All @@ -21,9 +24,9 @@ jobs:
fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: npm
registry-url: https://npm.pkg.github.com
- run: npm install -g npm@^11.5.1
- run: npm ci
- id: resolve
run: |
Expand All @@ -32,8 +35,7 @@ jobs:
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_PACKAGES_TOKEN: ${{ secrets.GITHUB_TOKEN }}

build-binaries:
name: Build ${{ matrix.asset_name }}
Expand All @@ -59,8 +61,9 @@ jobs:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: npm
- run: npm install -g npm@^11.5.1
- run: npm ci
- run: npm run build
env:
Expand All @@ -85,8 +88,9 @@ jobs:
fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: npm
- run: npm install -g npm@^11.5.1
- run: npm ci
- uses: actions/download-artifact@v7
with:
Expand Down Expand Up @@ -119,9 +123,9 @@ jobs:
fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: npm
registry-url: https://npm.pkg.github.com
- run: npm install -g npm@^11.5.1
- run: npm ci
- uses: actions/download-artifact@v7
with:
Expand All @@ -140,5 +144,4 @@ jobs:
run: npx semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_PACKAGES_TOKEN: ${{ secrets.GITHUB_TOKEN }}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ out
# Nuxt.js build / generate output
.nuxt
dist
.release/

# Gatsby files
.cache/
Expand Down
3 changes: 2 additions & 1 deletion .releaserc.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
[
"@semantic-release/exec",
{
"prepareCmd": "GH_ATTACH_BUILD_VERSION=${nextRelease.version} npm run build"
"prepareCmd": "GH_ATTACH_BUILD_VERSION=${nextRelease.version} npm run build && GH_ATTACH_RELEASE_VERSION=${nextRelease.version} npm run prepare:github-package",
"publishCmd": "npm run publish:github-package"
}
],
"@semantic-release/npm",
Expand Down
47 changes: 34 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,24 @@ GitHub doesn't provide an official API for attaching images to issues and pull r

## Install

For most users, install from the public npm registry — no npm authentication is required.

### Standalone CLI (npm)

```bash
# Install globally from GitHub Packages
npm install -g @addono/gh-attach --registry=https://npm.pkg.github.com
# Install globally from public npm
npm install -g gh-attach
```

Run it as `gh-attach ...`.

### Optional: GitHub Packages mirror

```bash
# Install the scoped mirror from GitHub Packages (requires GitHub Packages auth)
npm install -g @addono/gh-attach --registry=https://npm.pkg.github.com
```

### GitHub CLI extension

```bash
Expand All @@ -53,17 +62,17 @@ Run it as `gh-attach ...`.

```bash
# Upload an image
npx --registry=https://npm.pkg.github.com @addono/gh-attach upload ./screenshot.png --target owner/repo#42
npx gh-attach upload ./screenshot.png --target owner/repo#42

# Start the MCP server
npx --registry=https://npm.pkg.github.com @addono/gh-attach mcp --transport stdio
npx gh-attach mcp --transport stdio
```

## Keeping gh-attach up to date

```bash
# npm install
npm install -g @addono/gh-attach@latest --registry=https://npm.pkg.github.com
npm install -g gh-attach@latest

# gh extension install
gh extension upgrade Addono/gh-attach
Expand All @@ -72,7 +81,7 @@ gh extension upgrade Addono/gh-attach
If you run via `npx`, there is nothing to upgrade locally — each invocation resolves the published package. Pin a version explicitly if you do not want the latest release:

```bash
npx --registry=https://npm.pkg.github.com @addono/gh-attach@<version> mcp --transport stdio
npx gh-attach@<version> mcp --transport stdio
```

If you installed a standalone release binary, download the newest matching asset from the latest GitHub release and replace your existing `gh-attach` executable.
Expand Down Expand Up @@ -124,12 +133,12 @@ Commits images to an orphan branch. Works with any token.

Choose the MCP command that matches how you installed `gh-attach`:

| Install method | MCP command |
| ------------------------- | ----------------------------------------------------------------------------------- |
| Standalone npm install | `gh-attach mcp --transport stdio` |
| Standalone release binary | `gh-attach mcp --transport stdio` |
| `gh` extension | `gh attach mcp --transport stdio` |
| `npx` | `npx --registry=https://npm.pkg.github.com @addono/gh-attach mcp --transport stdio` |
| Install method | MCP command |
| ------------------------- | ------------------------------------- |
| Standalone npm install | `gh-attach mcp --transport stdio` |
| Standalone release binary | `gh-attach mcp --transport stdio` |
| `gh` extension | `gh attach mcp --transport stdio` |
| `npx` | `npx gh-attach mcp --transport stdio` |

When the MCP client supports elicitation, `upload_image` can prompt for a GitHub token during the same tool call and continue the upload without requiring a separate `login` step first.

Expand Down Expand Up @@ -222,7 +231,7 @@ Add to `.vscode/settings.json`:

This wrapper requires `bash` and an authenticated GitHub CLI session (`gh auth login`). It resolves the token at startup instead of storing it in the config file, but the token is still present in the MCP server process environment while it is running. If `bash` is unavailable, use the standalone CLI setup instead.

If you prefer `npx`, use `command: "npx"` and prepend `--registry=https://npm.pkg.github.com`, `@addono/gh-attach` to the `args` array.
If you prefer `npx`, use `command: "npx"` and prepend `gh-attach` to the `args` array.

## Configuration

Expand Down Expand Up @@ -280,6 +289,18 @@ npm run typecheck # TypeScript strict mode
npm run lint # ESLint
```

### Release automation

- Public npm releases publish the unscoped package as `gh-attach`.
- GitHub Packages keeps a scoped mirror at `@addono/gh-attach`.
- GitHub Actions publishes to npm via Trusted Publishing (OIDC), so the release workflow does not need an `NPM_TOKEN` repository secret.
- Configure npm trusted publishing for package `gh-attach` with:
- **Organization or user:** `Addono`
- **Repository:** `gh-attach`
- **Workflow filename:** `release.yml`
- **Environment name:** leave empty unless you later protect releases with a GitHub Actions environment
- After the first trusted publish succeeds, npm recommends enabling **Require two-factor authentication and disallow tokens** in the package publishing access settings.

### Branch Protection (Recommended)

For production repositories, configure the following protections on the `main` branch via **Settings → Branches → Branch protection rules**:
Expand Down
13 changes: 13 additions & 0 deletions openspec/specs/ci-cd/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,19 @@ The system SHALL publish release artifacts.
- WHEN the release workflow completes
- THEN it SHALL publish the package to npm as `gh-attach`

#### Scenario: GitHub Packages mirror publish

- GIVEN a new version is released
- WHEN the release workflow completes
- THEN it SHALL publish a mirror package to GitHub Packages as `@addono/gh-attach`

#### Scenario: release credentials

- GIVEN the release workflow
- WHEN it publishes packages
- THEN it SHALL use GitHub Actions trusted publishing with OIDC for public npm
- AND it SHALL use the workflow `GITHUB_TOKEN` with `packages: write` for GitHub Packages

#### Scenario: GitHub Release

- GIVEN a new version is released
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "@addono/gh-attach",
"name": "gh-attach",
"version": "1.5.7",
"description": "CLI tool and MCP server for attaching images to GitHub issues, PRs, and comments",
"type": "module",
Expand Down Expand Up @@ -41,6 +41,8 @@
"test:coverage": "vitest run --coverage",
"test:watch": "vitest --project unit --project integration",
"package": "node scripts/package-binaries.mjs",
"prepare:github-package": "node scripts/prepare-github-package.mjs",
"publish:github-package": "node scripts/publish-github-package.mjs",
"prepare": "npm run build"
},
"pkg": {
Expand Down Expand Up @@ -69,10 +71,10 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/Addono/gh-attach.git"
"url": "git+https://github.com/Addono/gh-attach.git"
},
"publishConfig": {
"registry": "https://npm.pkg.github.com"
"registry": "https://registry.npmjs.org"
},
"engines": {
"node": ">=20"
Expand Down
54 changes: 54 additions & 0 deletions scripts/prepare-github-package.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import {
cpSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const outputDir = resolve(rootDir, ".release", "github-package");
const pkgPath = resolve(rootDir, "package.json");
const version = process.env.GH_ATTACH_RELEASE_VERSION;

if (!version) {
throw new Error("GH_ATTACH_RELEASE_VERSION is required.");
}

const sourcePackage = JSON.parse(readFileSync(pkgPath, "utf8"));
const mirrorPackage = {
name: "@addono/gh-attach",
version,
description: sourcePackage.description,
type: sourcePackage.type,
main: sourcePackage.main,
types: sourcePackage.types,
bin: sourcePackage.bin,
exports: sourcePackage.exports,
files: sourcePackage.files,
keywords: sourcePackage.keywords,
author: sourcePackage.author,
license: sourcePackage.license,
repository: sourcePackage.repository,
publishConfig: {
registry: "https://npm.pkg.github.com",
},
engines: sourcePackage.engines,
dependencies: sourcePackage.dependencies,
};

rmSync(outputDir, { recursive: true, force: true });
mkdirSync(outputDir, { recursive: true });

for (const entry of mirrorPackage.files) {
cpSync(resolve(rootDir, entry), resolve(outputDir, entry), {
recursive: true,
});
}

writeFileSync(
resolve(outputDir, "package.json"),
`${JSON.stringify(mirrorPackage, null, 2)}\n`,
);
52 changes: 52 additions & 0 deletions scripts/publish-github-package.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { spawnSync } from "node:child_process";
import { existsSync, rmSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const packageDir = resolve(rootDir, ".release", "github-package");
const npmrcPath = resolve(packageDir, ".npmrc");
const token = process.env.GITHUB_PACKAGES_TOKEN;
const npmPath = process.platform === "win32" ? "npm.cmd" : "npm";

if (!existsSync(packageDir)) {
throw new Error("GitHub Packages mirror has not been prepared.");
}

if (!token) {
throw new Error("GITHUB_PACKAGES_TOKEN is required.");
}

writeFileSync(
npmrcPath,
[
"@addono:registry=https://npm.pkg.github.com",
`//npm.pkg.github.com/:_authToken=${token}`,
"",
].join("\n"),
);

try {
const result = spawnSync(
npmPath,
["publish", "--registry", "https://npm.pkg.github.com"],
{
cwd: packageDir,
stdio: "inherit",
env: {
...process.env,
NPM_CONFIG_USERCONFIG: npmrcPath,
},
},
);

if (result.error) {
throw result.error;
}

if (result.status !== 0) {
process.exit(result.status ?? 1);
}
} finally {
rmSync(npmrcPath, { force: true });
}