diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b744ab8..a00f618 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,7 +44,7 @@ jobs: - name: Select Changesets mode id: mode - uses: changesets/action/select-mode@0138f456ec3d73906fcd11169ce59502d8d241c1 + uses: changesets/action/select-mode@22ccf9aa43179fe9e27dc62e575971d28cce197c # v2.0.0 version: name: Create or update release PR @@ -72,7 +72,7 @@ jobs: run: npm ci --ignore-scripts --no-audit --no-fund - name: Create or update release PR - uses: changesets/action/version@0138f456ec3d73906fcd11169ce59502d8d241c1 + uses: changesets/action/version@22ccf9aa43179fe9e27dc62e575971d28cce197c # v2.0.0 with: github-token: ${{ github.token }} pr-title: 'release: version packages' @@ -117,7 +117,7 @@ jobs: - name: Pack exact Changesets publish artifacts id: pack - uses: changesets/action/pack@0138f456ec3d73906fcd11169ce59502d8d241c1 + uses: changesets/action/pack@22ccf9aa43179fe9e27dc62e575971d28cce197c # v2.0.0 with: publish-plan-artifact-id: ${{ needs.select-mode.outputs.publish-plan-artifact-id }} @@ -157,7 +157,7 @@ jobs: - name: Publish packed artifacts id: publish - uses: changesets/action/publish@0138f456ec3d73906fcd11169ce59502d8d241c1 + uses: changesets/action/publish@22ccf9aa43179fe9e27dc62e575971d28cce197c # v2.0.0 with: github-token: ${{ github.token }} pack-dir-artifact-id: ${{ needs.pack.outputs.pack-dir-artifact-id }} diff --git a/.github/workflows/wave-pr-guard.yml b/.github/workflows/wave-pr-guard.yml index a08f173..0179666 100644 --- a/.github/workflows/wave-pr-guard.yml +++ b/.github/workflows/wave-pr-guard.yml @@ -23,6 +23,19 @@ jobs: const repo = context.repo.repo; const pr = context.payload.pull_request; const body = pr.body || ''; + const trustedAssociations = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); + if (trustedAssociations.has(pr.author_association)) { + core.info('Skipping contributor-only Wave checks for trusted maintainer PR.'); + return; + } + const { data: collaborator } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, repo, username: pr.user.login, + }); + const trustedPermissions = new Set(['admin', 'maintain', 'write']); + if (trustedPermissions.has(collaborator.permission)) { + core.info('Skipping contributor-only Wave checks for repository collaborator PR.'); + return; + } const refs = [...body.matchAll(/(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/gi)].map((m) => Number(m[1])); const uniqueRefs = [...new Set(refs)]; if (uniqueRefs.length !== 1) { diff --git a/docs/contributing/changesets.md b/docs/contributing/changesets.md index e804795..ce5bbf9 100644 --- a/docs/contributing/changesets.md +++ b/docs/contributing/changesets.md @@ -1,6 +1,6 @@ # Changesets Contributor Workflow -StellarForge CLI uses Changesets to record release intent close to the pull request that introduces a user-visible package change. The project is still pre-release and the npm package remains private, so this workflow currently supports local version and changelog generation only. It does not publish packages or create release tags. +StellarForge CLI uses Changesets to record release intent close to the pull request that introduces a user-visible package change. The package metadata is public-ready, while actual publication remains gated by npm Trusted Publishing, the protected `npm-release` environment, and the `NPM_PUBLISH_ENABLED` repository variable. ## When a Changeset Is Required @@ -31,7 +31,7 @@ Review the generated file before committing it. A typical file for this reposito ```markdown --- -"@stellarforge/cli": patch +"@diginodes/stellarforge-cli": patch --- Describe the user-visible change clearly. @@ -71,28 +71,8 @@ This command consumes pending changeset files and updates package versions/chang For ordinary feature pull requests, contributors should normally commit the changeset file itself rather than committing generated release-version changes. -## Private Package Behavior +## Protected Publishing -`@stellarforge/cli` is currently marked `private: true`. The Changesets configuration therefore explicitly enables private-package versioning while keeping private-package tags disabled: +The package is marked `private: false` and the Changesets configuration declares public access. That metadata alone does not authorize publication. The release workflow publishes only when npm Trusted Publishing is configured, the protected `npm-release` environment approves the job, and `NPM_PUBLISH_ENABLED` is exactly `true`. -```json -"privatePackages": { - "version": true, - "tag": false -} -``` - -This allows us to prove version/changelog generation during development without enabling package publication or release tagging. - -## Publishing Is Out of Scope - -The following are intentionally not part of the current Changesets integration: - -- `changeset publish`; -- npm publication; -- npm credentials or tokens; -- Git tags; -- GitHub Releases; -- release-publishing GitHub Actions. - -Protected publication automation will be implemented separately only after package identity, security gates, trusted publishing, and release-readiness requirements are satisfied. +Publishing uses GitHub Actions OIDC rather than a long-lived `NPM_TOKEN`. Maintainers must not run routine releases manually after the one-time registry bootstrap. diff --git a/docs/contributing/release-process.md b/docs/contributing/release-process.md index d068d38..27fe016 100644 --- a/docs/contributing/release-process.md +++ b/docs/contributing/release-process.md @@ -6,7 +6,7 @@ StellarForge CLI uses Semantic Versioning, Changesets, npm Trusted Publishing, a Release automation is intentionally merged in a non-publishing state. The workflow can create release PRs and validate/pack release artifacts, but the npm publish job runs only when **all** of these controls are in place: -1. the `@stellarforge/cli` npm scope/package is owned by the StellarForge maintainers; +1. the `@diginodes/stellarforge-cli` npm scope/package is owned by the StellarForge maintainers through the `diginodes` npm organization; 2. npm Trusted Publishing is configured for: - GitHub organization: `DigiNodes` - repository: `stellarforge-cli` @@ -68,14 +68,15 @@ From an approved maintainer workstation: 1. clone `DigiNodes/stellarforge-cli` and check out the bootstrap commit above; 2. run `npm ci --ignore-scripts --no-audit --no-fund`, `npm run build`, and `npm run release:dry-run`; -3. authenticate interactively to the npm account/organization that owns the `@stellarforge` scope, using the required 2FA flow; -4. publish `@stellarforge/cli@0.0.0` once with a non-default bootstrap tag, for example `npm publish --access public --tag bootstrap`; -5. configure npm Trusted Publishing on the newly existing `@stellarforge/cli` package for organization `DigiNodes`, repository `stellarforge-cli`, workflow `release.yml`, environment `npm-release`, with direct `npm publish` allowed; -6. create/protect the GitHub `npm-release` environment with required maintainer review; -7. set repository Actions variable `NPM_PUBLISH_ENABLED=true`; -8. enable GitHub Dependency Graph so the existing Dependency Review workflow becomes enforceable; -9. merge a reviewed release-activation change to `main` (or another approved `main` push) to trigger the protected publish path for the already-versioned `0.1.0` package; -10. approve the `npm-release` environment deployment and verify npm, Git tag, GitHub Release, and provenance all identify `0.1.0`. +3. apply the reviewed package-identity patch that changes only the npm package name to `@diginodes/stellarforge-cli` while retaining bootstrap version `0.0.0`; +4. authenticate interactively as an owner of the `diginodes` npm organization, using the required 2FA flow; +5. publish `@diginodes/stellarforge-cli@0.0.0` once with a non-default bootstrap tag, for example `npm publish --access public --tag bootstrap`; +6. configure npm Trusted Publishing on the newly existing `@diginodes/stellarforge-cli` package for organization `DigiNodes`, repository `stellarforge-cli`, workflow `release.yml`, environment `npm-release`, with direct `npm publish` allowed; +7. create/protect the GitHub `npm-release` environment with required maintainer review; +8. set repository Actions variable `NPM_PUBLISH_ENABLED=true`; +9. verify GitHub Dependency Graph keeps the existing Dependency Review workflow enforceable; +10. merge a reviewed release-activation change to `main` (or another approved `main` push) to trigger the protected publish path for the already-versioned `0.1.0` package; +11. approve the `npm-release` environment deployment and verify npm, Git tag, GitHub Release, and provenance all identify `0.1.0`. Do **not** republish, rewrite, or downgrade the `0.1.0` commit on `main` merely to bootstrap the npm namespace. @@ -89,7 +90,7 @@ The Changesets publish action is responsible for package publication, the packag After publication, verify: -- npm shows `@stellarforge/cli@`; +- npm shows `@diginodes/stellarforge-cli@`; - Git contains the matching package tag; - the GitHub Release points to the same version/tag; - npm displays provenance for the public package. diff --git a/docs/guides/installation.md b/docs/guides/installation.md index a0fcbdf..8333879 100644 --- a/docs/guides/installation.md +++ b/docs/guides/installation.md @@ -2,7 +2,7 @@ StellarForge CLI has completed its initial MVP implementation and the repository contains protected release automation. The source is versioned at `0.1.0`, but the first public npm publication is still pending the one-time registry and Trusted Publishing setup tracked in REL-001. -Until that publication is verified, use a source checkout for development and evaluation rather than assuming `@stellarforge/cli` is available from the npm registry. +Until that publication is verified, use a source checkout for development and evaluation rather than assuming `@diginodes/stellarforge-cli` is available from the npm registry. ## Contributor prerequisites diff --git a/package-lock.json b/package-lock.json index ce00fc2..14dab16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@stellarforge/cli", + "name": "@diginodes/stellarforge-cli", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@stellarforge/cli", + "name": "@diginodes/stellarforge-cli", "version": "0.1.0", "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 91c8dbd..ed25c8f 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { - "name": "@stellarforge/cli", + "name": "@diginodes/stellarforge-cli", "version": "0.1.0", "private": false, "description": "Command-line interface for building production-ready Stellar applications.", "type": "module", "packageManager": "npm@10.9.2", "bin": { - "stellarforge": "./dist/cli.js" + "stellarforge": "dist/cli.js" }, "engines": { "node": ">=22.13.0 <25", diff --git a/tests/api-service-template.test.ts b/tests/api-service-template.test.ts index 9b6c780..d981106 100644 --- a/tests/api-service-template.test.ts +++ b/tests/api-service-template.test.ts @@ -20,57 +20,61 @@ function runNpmScript(projectRoot: string, script: string) { } describe('API Service template', () => { - it('generates a runnable dependency-light service with isolated Stellar configuration', () => - withTempDirectory((root) => { - const captured = createCapturedTerminalOutput(); - const command = createNewCommand({ - cwd: () => root, - output: captured.output, - }); + it( + 'generates a runnable dependency-light service with isolated Stellar configuration', + () => + withTempDirectory((root) => { + const captured = createCapturedTerminalOutput(); + const command = createNewCommand({ + cwd: () => root, + output: captured.output, + }); - command.parse(['node', 'new', 'demo-api', '--template', 'api-service']); + command.parse(['node', 'new', 'demo-api', '--template', 'api-service']); - const projectRoot = join(root, 'demo-api'); - expect(existsSync(join(projectRoot, 'src', 'server.js'))).toBe(true); - expect(existsSync(join(projectRoot, 'src', 'stellar-client.js'))).toBe( - true, - ); - expect(existsSync(join(projectRoot, 'test', 'server.test.js'))).toBe( - true, - ); - expect(captured.stdoutText()).toContain( - 'Created demo-api from api-service', - ); + const projectRoot = join(root, 'demo-api'); + expect(existsSync(join(projectRoot, 'src', 'server.js'))).toBe(true); + expect(existsSync(join(projectRoot, 'src', 'stellar-client.js'))).toBe( + true, + ); + expect(existsSync(join(projectRoot, 'test', 'server.test.js'))).toBe( + true, + ); + expect(captured.stdoutText()).toContain( + 'Created demo-api from api-service', + ); - const packageManifest = readFileSync( - join(projectRoot, 'package.json'), - 'utf8', - ); - expect(packageManifest).toContain('"name": "demo-api"'); - expect(packageManifest).not.toContain('dependencies'); + const packageManifest = readFileSync( + join(projectRoot, 'package.json'), + 'utf8', + ); + expect(packageManifest).toContain('"name": "demo-api"'); + expect(packageManifest).not.toContain('dependencies'); - const environmentExample = readFileSync( - join(projectRoot, '.env.example'), - 'utf8', - ); - expect(environmentExample).toContain('STELLAR_NETWORK=TESTNET'); - expect(environmentExample).toContain('STELLAR_HORIZON_URL=https://'); - expect(environmentExample).toContain('STELLAR_RPC_URL=https://'); - expect(environmentExample).not.toMatch(/SECRET|SEED|PRIVATE_KEY/); + const environmentExample = readFileSync( + join(projectRoot, '.env.example'), + 'utf8', + ); + expect(environmentExample).toContain('STELLAR_NETWORK=TESTNET'); + expect(environmentExample).toContain('STELLAR_HORIZON_URL=https://'); + expect(environmentExample).toContain('STELLAR_RPC_URL=https://'); + expect(environmentExample).not.toMatch(/SECRET|SEED|PRIVATE_KEY/); - const check = runNpmScript(projectRoot, 'check'); - expect(check.status, check.stderr).toBe(0); + const check = runNpmScript(projectRoot, 'check'); + expect(check.status, check.stderr).toBe(0); - const test = runNpmScript(projectRoot, 'test'); - expect(test.status, test.stderr).toBe(0); + const test = runNpmScript(projectRoot, 'test'); + expect(test.status, test.stderr).toBe(0); - const generatedReadme = readFileSync( - join(projectRoot, 'README.md'), - 'utf8', - ); - expect(generatedReadme).toContain('GET /health'); - expect(generatedReadme).toContain('src/stellar-client.js'); - expect(generatedReadme).toContain('Never commit secret keys'); - expect(generatedReadme).toContain('official Stellar documentation'); - })); + const generatedReadme = readFileSync( + join(projectRoot, 'README.md'), + 'utf8', + ); + expect(generatedReadme).toContain('GET /health'); + expect(generatedReadme).toContain('src/stellar-client.js'); + expect(generatedReadme).toContain('Never commit secret keys'); + expect(generatedReadme).toContain('official Stellar documentation'); + }), + 15_000, + ); }); diff --git a/tests/basic-app-template.test.ts b/tests/basic-app-template.test.ts index fa6b99e..562dea9 100644 --- a/tests/basic-app-template.test.ts +++ b/tests/basic-app-template.test.ts @@ -20,41 +20,49 @@ function runNpmScript(projectRoot: string, script: string) { } describe('Basic App template', () => { - it('generates through the default new command and passes its documented smoke checks', () => - withTempDirectory((root) => { - const captured = createCapturedTerminalOutput(); - const command = createNewCommand({ - cwd: () => root, - output: captured.output, - }); - - command.parse(['node', 'new', 'demo-app']); - - const projectRoot = join(root, 'demo-app'); - const packageJson = JSON.parse( - readFileSync(join(projectRoot, 'package.json'), 'utf8'), - ) as { name: string; dependencies?: unknown; devDependencies?: unknown }; - - expect(packageJson.name).toBe('demo-app'); - expect(packageJson.dependencies).toBeUndefined(); - expect(packageJson.devDependencies).toBeUndefined(); - expect(captured.stdoutText()).toContain( - 'Created demo-app from basic-app', - ); - - const check = runNpmScript(projectRoot, 'check'); - expect(check.status, check.stderr).toBe(0); - - const test = runNpmScript(projectRoot, 'test'); - expect(test.status, test.stderr).toBe(0); - - const generatedReadme = readFileSync( - join(projectRoot, 'README.md'), - 'utf8', - ); - expect(generatedReadme).toContain('# demo-app'); - expect(generatedReadme).toContain( - 'does not require or generate wallet secret keys', - ); - })); + it( + 'generates through the default new command and passes its documented smoke checks', + () => + withTempDirectory((root) => { + const captured = createCapturedTerminalOutput(); + const command = createNewCommand({ + cwd: () => root, + output: captured.output, + }); + + command.parse(['node', 'new', 'demo-app']); + + const projectRoot = join(root, 'demo-app'); + const packageJson = JSON.parse( + readFileSync(join(projectRoot, 'package.json'), 'utf8'), + ) as { + name: string; + dependencies?: unknown; + devDependencies?: unknown; + }; + + expect(packageJson.name).toBe('demo-app'); + expect(packageJson.dependencies).toBeUndefined(); + expect(packageJson.devDependencies).toBeUndefined(); + expect(captured.stdoutText()).toContain( + 'Created demo-app from basic-app', + ); + + const check = runNpmScript(projectRoot, 'check'); + expect(check.status, check.stderr).toBe(0); + + const test = runNpmScript(projectRoot, 'test'); + expect(test.status, test.stderr).toBe(0); + + const generatedReadme = readFileSync( + join(projectRoot, 'README.md'), + 'utf8', + ); + expect(generatedReadme).toContain('# demo-app'); + expect(generatedReadme).toContain( + 'does not require or generate wallet secret keys', + ); + }), + 15_000, + ); }); diff --git a/tests/executable.test.ts b/tests/executable.test.ts index 1b3f29e..e621f4c 100644 --- a/tests/executable.test.ts +++ b/tests/executable.test.ts @@ -21,7 +21,7 @@ describe('StellarForge CLI executable', () => { bin?: Record; }; - expect(packageJson.bin?.stellarforge).toBe('./dist/cli.js'); + expect(packageJson.bin?.stellarforge).toBe('dist/cli.js'); }); it('preserves the Node.js shebang in the built artifact', () => { diff --git a/tests/full-stack-template.test.ts b/tests/full-stack-template.test.ts index c90e7b9..6db840a 100644 --- a/tests/full-stack-template.test.ts +++ b/tests/full-stack-template.test.ts @@ -20,50 +20,60 @@ function runNpmScript(projectRoot: string, script: string) { } describe('Full Stack template', () => { - it('generates the documented frontend, backend, and contracts boundaries and passes smoke checks', () => - withTempDirectory((root) => { - const captured = createCapturedTerminalOutput(); - const command = createNewCommand({ - cwd: () => root, - output: captured.output, - }); + it( + 'generates the documented frontend, backend, and contracts boundaries and passes smoke checks', + () => + withTempDirectory((root) => { + const captured = createCapturedTerminalOutput(); + const command = createNewCommand({ + cwd: () => root, + output: captured.output, + }); - command.parse(['node', 'new', 'demo-stack', '--template', 'full-stack']); + command.parse([ + 'node', + 'new', + 'demo-stack', + '--template', + 'full-stack', + ]); - const projectRoot = join(root, 'demo-stack'); - expect(existsSync(join(projectRoot, 'frontend', 'src', 'app.js'))).toBe( - true, - ); - expect(existsSync(join(projectRoot, 'backend', 'src', 'server.js'))).toBe( - true, - ); - expect(existsSync(join(projectRoot, 'contracts', 'README.md'))).toBe( - true, - ); - expect(captured.stdoutText()).toContain( - 'Created demo-stack from full-stack', - ); + const projectRoot = join(root, 'demo-stack'); + expect(existsSync(join(projectRoot, 'frontend', 'src', 'app.js'))).toBe( + true, + ); + expect( + existsSync(join(projectRoot, 'backend', 'src', 'server.js')), + ).toBe(true); + expect(existsSync(join(projectRoot, 'contracts', 'README.md'))).toBe( + true, + ); + expect(captured.stdoutText()).toContain( + 'Created demo-stack from full-stack', + ); - const environmentExample = readFileSync( - join(projectRoot, '.env.example'), - 'utf8', - ); - expect(environmentExample).toContain('STELLAR_NETWORK=TESTNET'); - expect(environmentExample).not.toMatch(/SECRET|SEED|PRIVATE_KEY/); + const environmentExample = readFileSync( + join(projectRoot, '.env.example'), + 'utf8', + ); + expect(environmentExample).toContain('STELLAR_NETWORK=TESTNET'); + expect(environmentExample).not.toMatch(/SECRET|SEED|PRIVATE_KEY/); - const check = runNpmScript(projectRoot, 'check'); - expect(check.status, check.stderr).toBe(0); + const check = runNpmScript(projectRoot, 'check'); + expect(check.status, check.stderr).toBe(0); - const test = runNpmScript(projectRoot, 'test'); - expect(test.status, test.stderr).toBe(0); + const test = runNpmScript(projectRoot, 'test'); + expect(test.status, test.stderr).toBe(0); - const generatedReadme = readFileSync( - join(projectRoot, 'README.md'), - 'utf8', - ); - expect(generatedReadme).toContain('frontend/'); - expect(generatedReadme).toContain('backend/'); - expect(generatedReadme).toContain('contracts/'); - expect(generatedReadme).toContain('no credentials'); - })); + const generatedReadme = readFileSync( + join(projectRoot, 'README.md'), + 'utf8', + ); + expect(generatedReadme).toContain('frontend/'); + expect(generatedReadme).toContain('backend/'); + expect(generatedReadme).toContain('contracts/'); + expect(generatedReadme).toContain('no credentials'); + }), + 15_000, + ); }); diff --git a/tests/release-workflow.test.ts b/tests/release-workflow.test.ts index 31eac36..48626e3 100644 --- a/tests/release-workflow.test.ts +++ b/tests/release-workflow.test.ts @@ -30,16 +30,16 @@ describe('release automation policy', () => { it('pins release actions and separates OIDC from versioning', () => { expect(workflow).toContain( - 'changesets/action/select-mode@0138f456ec3d73906fcd11169ce59502d8d241c1', + 'changesets/action/select-mode@22ccf9aa43179fe9e27dc62e575971d28cce197c', ); expect(workflow).toContain( - 'changesets/action/version@0138f456ec3d73906fcd11169ce59502d8d241c1', + 'changesets/action/version@22ccf9aa43179fe9e27dc62e575971d28cce197c', ); expect(workflow).toContain( - 'changesets/action/pack@0138f456ec3d73906fcd11169ce59502d8d241c1', + 'changesets/action/pack@22ccf9aa43179fe9e27dc62e575971d28cce197c', ); expect(workflow).toContain( - 'changesets/action/publish@0138f456ec3d73906fcd11169ce59502d8d241c1', + 'changesets/action/publish@22ccf9aa43179fe9e27dc62e575971d28cce197c', ); expect(workflow.match(/id-token: write/g)).toHaveLength(1); expect(workflow).toContain('environment: npm-release');