diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc147a1..995446d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eec4bbc..d32bec6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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" @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1829d8b..822f048 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 4e52de3..7a58da8 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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."* diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..229b53a --- /dev/null +++ b/action.yml @@ -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" diff --git a/action/check.mjs b/action/check.mjs new file mode 100644 index 0000000..c6dab0b --- /dev/null +++ b/action/check.mjs @@ -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), '```', ''] : []), + `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)`, +].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; diff --git a/tests/action.test.ts b/tests/action.test.ts new file mode 100644 index 0000000..eceeee9 --- /dev/null +++ b/tests/action.test.ts @@ -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); + }); +}); diff --git a/tests/manifests.test.ts b/tests/manifests.test.ts index 67bcd96..1f7c63f 100644 --- a/tests/manifests.test.ts +++ b/tests/manifests.test.ts @@ -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} `); + }); +});