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
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,17 @@ jobs:
pull-requests: write
issues: write
id-token: write

# Dogfood the GitHub Action on this repository (expects: already tokenless).
action:
name: Action self-check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: ./
id: check
- run: test "$STATUS" = already-tokenless
env:
STATUS: ${{ steps.check.outputs.status }}
7 changes: 6 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ jobs:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
BUNDLE: ${{ steps.attest.outputs.bundle-path }}
AS_DRAFT: ${{ vars.RELEASE_AS_DRAFT }}
run: |
TGZ=$(ls go-tokenless-*.tgz)
cp "$BUNDLE" "$TGZ.intoto.jsonl"
Expand All @@ -82,7 +83,11 @@ jobs:
' > notes.md
gh release create "$TAG" "$TGZ" "$TGZ.intoto.jsonl" --repo "$GITHUB_REPOSITORY" \
--draft --verify-tag --title "go-tokenless $TAG" --notes-file notes.md
gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false --latest
# Set the repository variable RELEASE_AS_DRAFT=true to leave the release as a draft,
# e.g. to publish it by hand with "Publish this Action to the GitHub Marketplace" ticked.
if [ "$AS_DRAFT" != "true" ]; then
gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false --latest
fi

mcp-registry:
needs: npm
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## Unreleased

- GitHub Action (`uses: continuous-actions/go-tokenless@v1`): check mode fails CI when npm publishing still relies on a stored token, with annotations and a job summary listing the fixes; `mode: report` only annotates.

## 1.0.0

First stable release. The CLI options, exit codes, `--json` plan shape and MCP tool inputs will not change incompatibly within 1.x.
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
- [What it changes](#what-it-changes)
- [Supported release setups](#supported-release-setups)
- [Private packages](#private-packages)
- [Keep it tokenless in CI](#keep-it-tokenless-in-ci)
- [Use it with AI agents](#use-it-with-ai-agents)
- [Options](#options)
- [What it accesses](#what-it-accesses)
Expand Down Expand Up @@ -124,6 +125,27 @@ npx go-tokenless apply --read-token NPM_READ_TOKEN

Every install step in the release workflow gets it, including separate build and test jobs. Create a granular token on npmjs.com with read-only access to your packages and save it with `gh secret set NPM_READ_TOKEN`.

## Keep it tokenless in CI

Add the Action to CI. It fails the job if a token creeps back into a publish workflow, and the job summary lists the exact fixes:

```yaml
# .github/workflows/ci.yml
jobs:
tokenless:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v5
- uses: continuous-actions/go-tokenless@v1
# with:
# mode: report # annotate only, never fail
# read-token: NPM_READ_TOKEN # if installs use a read-only token
```

It runs `go-tokenless --json` read-only and needs no token. Its `status` output is `already-tokenless`, `ready`, `blocked` or `no-publish-workflow`.

## Use it with AI agents

go-tokenless is built to be run by coding agents: `--json` output, clear exit codes, an MCP server and an Agent Skill. Ask your agent: *"Move our npm publishing to trusted publishing."*
Expand Down
47 changes: 47 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: go-tokenless
description: Fail CI when npm publishing still relies on a stored NPM_TOKEN instead of trusted publishing (OIDC). Shows the exact fixes.
author: continuous-actions
branding:
icon: shield
color: blue

inputs:
mode:
description: "`check` fails the job when publishing still uses a token or needs a human decision; `report` only writes the summary and annotations."
required: false
default: check
working-directory:
description: Repository root to check (default the workspace).
required: false
default: .
read-token:
description: Name of the read-only token secret your install steps use for private packages (same as the CLI's --read-token), so it isn't reported.
required: false
default: ''

outputs:
status:
description: "already-tokenless, ready (token still used), blocked, or no-publish-workflow."
value: ${{ steps.check.outputs.status }}

runs:
using: composite
steps:
- name: Plan the trusted publishing migration (read-only)
shell: bash
env:
REPO: ${{ github.repository }}
WORKDIR: ${{ inputs.working-directory }}
READ_TOKEN: ${{ inputs.read-token }}
run: |
args=(--json --repo "$REPO" --cwd "$(cd "$WORKDIR" && pwd)")
if [ -n "$READ_TOKEN" ]; then args+=(--read-token "$READ_TOKEN"); fi
# Run from outside the repository so npx always uses the published package.
cd "$RUNNER_TEMP"
npx --yes go-tokenless@1.0.0 "${args[@]}" > "$RUNNER_TEMP/go-tokenless-plan.json" || [ $? -eq 1 ]
- name: Report
id: check
shell: bash
env:
MODE: ${{ inputs.mode }}
run: node "$GITHUB_ACTION_PATH/action/check.mjs" "$RUNNER_TEMP/go-tokenless-plan.json" "$MODE"
47 changes: 47 additions & 0 deletions action/check.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Turns a `go-tokenless --json` plan into a CI result: annotations, a job summary,
// and a non-zero exit when npm publishing still depends on a stored token.
// Plain Node (no dependencies) so the composite action can run it directly.
import { appendFileSync, readFileSync } from 'node:fs';

const [planFile, mode = 'check'] = process.argv.slice(2);
const plan = JSON.parse(readFileSync(planFile, 'utf8'));

const esc = (s) => String(s).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
const prop = (s) => esc(s).replace(/:/g, '%3A').replace(/,/g, '%2C');

for (const f of plan.findings ?? []) {
if (f.level === 'ok' || f.level === 'info') continue;
const kind = f.level === 'error' ? 'error' : 'warning';
const loc = f.file && !f.file.endsWith('/') && f.file !== '.' ? ` file=${prop(f.file)}${f.line ? `,line=${f.line}` : ''},` : ' ';
console.log(`::${kind}${loc}title=${prop(`go-tokenless: ${f.code}`)}::${esc(f.message)}`);
}
for (const c of plan.changes ?? []) {
console.log(`::warning file=${prop(c.file)},title=${prop('go-tokenless: still token-based')}::${esc(c.description)}`);
}

const headline = {
'already-tokenless': '✅ npm publishing uses trusted publishing (no stored token).',
'no-publish-workflow': 'ℹ️ No workflow in this repository publishes to npm.',
ready: '❌ npm publishing still depends on a stored token. Run `npx go-tokenless apply` to fix it.',
blocked: '❌ go-tokenless found a problem that needs a human decision.',
}[plan.status] ?? `go-tokenless status: ${plan.status}`;

const summary = [
'## go-tokenless',
'',
headline,
'',
...(plan.changes?.length ? ['### Changes needed', '', ...plan.changes.map((c) => `- \`${c.file}\`: ${c.description}`), ''] : []),
...((plan.findings ?? []).filter((f) => f.level === 'error' || f.level === 'warning').length
? ['### Notes', '', ...plan.findings.filter((f) => f.level === 'error' || f.level === 'warning').map((f) => `- **${f.level}** \`${f.file}${f.line ? `:${f.line}` : ''}\`: ${f.message}`), '']
: []),
...(plan.trust?.length && plan.status !== 'already-tokenless' ? ['### npm side (needs your npm login)', '', '```', ...plan.trust.map((t) => t.command), '```', ''] : []),
`<sub>Checked by [go-tokenless](https://github.com/continuous-actions/go-tokenless). Fix guides: [ENEEDAUTH](https://continuous-actions.github.io/go-tokenless/errors/eneedauth.html) · [E404 PUT](https://continuous-actions.github.io/go-tokenless/errors/e404-put.html) · [E422](https://continuous-actions.github.io/go-tokenless/errors/e422-repository-url.html)</sub>`,
].join('\n');

if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`);
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `status=${plan.status}\n`);
console.log(headline);

const failing = plan.status === 'ready' || plan.status === 'blocked';
process.exitCode = mode === 'check' && failing ? 1 : 0;
43 changes: 43 additions & 0 deletions tests/action.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { makeRepo, pkg, run } from './helpers.ts';

const CHECK = resolve('action/check.mjs');
const wf = 'on: push\njobs:\n rel:\n runs-on: ubuntu-latest\n steps:\n - run: npm publish\n env:\n NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}\n';

function check(root: string, mode = 'check') {
const dir = mkdtempSync(join(tmpdir(), 'go-tokenless-action-'));
const planFile = join(dir, 'plan.json');
writeFileSync(planFile, run(root, '--json').stdout);
const summary = join(dir, 'summary.md');
const output = join(dir, 'output');
writeFileSync(summary, '');
writeFileSync(output, '');
const r = spawnSync(process.execPath, [CHECK, planFile, mode], { encoding: 'utf8', env: { ...process.env, GITHUB_STEP_SUMMARY: summary, GITHUB_OUTPUT: output } });
return { code: r.status, stdout: r.stdout, summary: readFileSync(summary, 'utf8'), output: readFileSync(output, 'utf8') };
}

describe('action check script', () => {
it('fails and annotates when publishing still uses a token', () => {
const r = check(makeRepo({ '.github/workflows/release.yml': wf, 'package.json': pkg({ name: 'p', version: '1.0.0' }) }));
expect(r.code).toBe(1);
expect(r.stdout).toMatch(/::warning file=\.github\/workflows\/release\.yml,title=go-tokenless%3A still token-based::/);
expect(r.summary).toContain('npm publishing still depends on a stored token');
expect(r.summary).toContain('npm trust github p');
expect(r.output).toContain('status=ready');
});

it('report mode never fails', () => {
const r = check(makeRepo({ '.github/workflows/release.yml': wf, 'package.json': pkg({ name: 'p', version: '1.0.0' }) }), 'report');
expect(r.code).toBe(0);
});

it('passes when already tokenless or nothing publishes', () => {
const ok = wf.replace(' steps:\n', ' permissions:\n id-token: write\n steps:\n').replace(' env:\n NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}\n', '');
expect(check(makeRepo({ '.github/workflows/release.yml': ok, 'package.json': pkg({ name: 'p', version: '1.0.0', repository: { type: 'git', url: 'git+https://github.com/acme/widgets.git' } }) })).code).toBe(0);
expect(check(makeRepo({ 'package.json': pkg({ name: 'p' }) })).code).toBe(0);
});
});
7 changes: 7 additions & 0 deletions tests/manifests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,10 @@ describe('Claude plugin folder', () => {
expect(json('.claude-plugin/marketplace.json').plugins[0].source).toBe('./claude-plugin');
});
});

describe('GitHub Action', () => {
it('pins the CLI to the package version', () => {
const { version } = json('package.json');
expect(readFileSync('action.yml', 'utf8')).toContain(`npx --yes go-tokenless@${version} `);
});
});
Loading