diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 546da4a..abc0723 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -6,6 +6,8 @@ Closes # + + ## Scope @@ -46,7 +48,9 @@ Release notes: ## Checklist +- [ ] I read `docs/contributing/wave-architecture.md` for Stellar Wave work - [ ] I stayed within the agreed issue scope +- [ ] I checked for overlapping open contributor PRs - [ ] I did not include secrets or sensitive data - [ ] I reviewed new/updated dependencies if any - [ ] I considered failure/error paths, not only the happy path diff --git a/.github/workflows/wave-claim.yml b/.github/workflows/wave-claim.yml new file mode 100644 index 0000000..7dcf61c --- /dev/null +++ b/.github/workflows/wave-claim.yml @@ -0,0 +1,86 @@ +name: Wave Claim + +on: + issue_comment: + types: [created] + +permissions: + contents: read + issues: write + +jobs: + claim: + name: Process contributor claim command + if: github.event.issue.pull_request == null + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Claim or release Wave issue + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const issueNumber = context.issue.number; + const actor = context.actor; + const command = (context.payload.comment.body || '').trim().toLowerCase(); + if (!['/claim', '/unclaim'].includes(command)) return; + + const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number: issueNumber }); + const labels = new Set(issue.labels.map((label) => label.name)); + if (!labels.has('Stellar Wave')) { + core.setFailed('Claim commands are limited to Stellar Wave issues.'); + return; + } + + if (command === '/claim') { + if (!labels.has('intake: ready') || labels.has('blocked')) { + await github.rest.issues.createComment({ + owner, repo, issue_number: issueNumber, + body: 'This issue is not currently open for claims. Please choose an issue labeled intake: ready.', + }); + return; + } + if (issue.assignees.length > 0 && !issue.assignees.some((a) => a.login === actor)) { + await github.rest.issues.createComment({ + owner, repo, issue_number: issueNumber, + body: 'This issue is already assigned. Please choose another ready issue.', + }); + return; + } + await github.rest.issues.addAssignees({ owner, repo, issue_number: issueNumber, assignees: [actor] }); + await github.rest.issues.addLabels({ owner, repo, issue_number: issueNumber, labels: ['status: in progress'] }); + for (const name of ['help wanted', 'status: stale']) { + try { + await github.rest.issues.removeLabel({ owner, repo, issue_number: issueNumber, name }); + } catch (error) { + if (error.status !== 404) throw error; + } + } + await github.rest.issues.createComment({ + owner, repo, issue_number: issueNumber, + body: 'Assigned to @' + actor + '. Keep the PR focused on this issue, link it in the PR body, and follow the frozen contributor architecture.', + }); + return; + } + + if (!issue.assignees.some((a) => a.login === actor)) { + await github.rest.issues.createComment({ + owner, repo, issue_number: issueNumber, + body: '@' + actor + ', you can only unclaim an issue currently assigned to you.', + }); + return; + } + await github.rest.issues.removeAssignees({ owner, repo, issue_number: issueNumber, assignees: [actor] }); + try { + await github.rest.issues.removeLabel({ owner, repo, issue_number: issueNumber, name: 'status: in progress' }); + } catch (error) { + if (error.status !== 404) throw error; + } + await github.rest.issues.addLabels({ + owner, repo, issue_number: issueNumber, labels: ['help wanted', 'intake: ready'], + }); + await github.rest.issues.createComment({ + owner, repo, issue_number: issueNumber, + body: '@' + actor + ' released this issue. It is available for another contributor.', + }); diff --git a/.github/workflows/wave-intake.yml b/.github/workflows/wave-intake.yml new file mode 100644 index 0000000..75cb4c0 --- /dev/null +++ b/.github/workflows/wave-intake.yml @@ -0,0 +1,156 @@ +name: Wave Intake + +on: + push: + branches: + - main + paths: + - '.github/workflows/wave-intake.yml' + - 'docs/contributing/wave-backlog-plan.md' + issues: + types: [closed, reopened, edited] + workflow_dispatch: + +permissions: + contents: read + issues: write + +concurrency: + group: wave-intake + cancel-in-progress: false + +jobs: + sync: + name: Sync Wave dependency and intake state + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Reconcile Wave issues + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const READY_LIMIT = 16; + const FIRST_BATCH = new Set([ + 'SW-001','SW-002','SW-003','SW-004','SW-005','SW-006', + 'SW-007','SW-008','SW-009','SW-010','SW-011','SW-012', + 'SW-013','SW-014','SW-019','SW-020' + ]); + + const managedLabels = [ + { name: 'intake: ready', color: '0e8a16', description: 'Open for contributor claims' }, + { name: 'intake: queued', color: 'c5def5', description: 'Wave backlog not yet open for claims' }, + { name: 'blocked', color: 'b60205', description: 'Blocked by an unresolved dependency' }, + { name: 'status: in progress', color: 'fbca04', description: 'Claimed and actively being worked on' }, + { name: 'status: stale', color: 'd4c5f9', description: 'Needs contributor or maintainer follow-up' }, + ]; + + for (const label of managedLabels) { + try { + await github.rest.issues.getLabel({ owner, repo, name: label.name }); + } catch (error) { + if (error.status !== 404) throw error; + await github.rest.issues.createLabel({ owner, repo, ...label }); + } + } + + const issues = await github.paginate(github.rest.issues.listForRepo, { + owner, + repo, + state: 'open', + per_page: 100, + }); + + const wave = issues + .filter((issue) => !issue.pull_request && /^SW-\d{3}\b/.test(issue.title)) + .sort((a, b) => a.title.localeCompare(b.title)); + + const byId = new Map( + wave.map((issue) => [issue.title.match(/^SW-\d{3}/)[0], issue]), + ); + + function dependencies(issue) { + const section = (issue.body || '').split(/## Dependencies/i)[1] || ''; + const bounded = section.split(/\n---|\n## /)[0]; + return [...new Set(bounded.match(/SW-\d{3}/g) || [])]; + } + + const state = []; + for (const issue of wave) { + const id = issue.title.match(/^SW-\d{3}/)[0]; + const deps = dependencies(issue); + const unresolved = deps.filter((dep) => { + const target = byId.get(dep); + return target && target.state === 'open'; + }); + state.push({ issue, id, unresolved }); + } + + const seedReady = state.filter(({ issue, id, unresolved }) => { + const labels = new Set(issue.labels.map((label) => label.name)); + return FIRST_BATCH.has(id) && + unresolved.length === 0 && + !labels.has('status: in progress'); + }).length; + const nonSeedReady = state.filter(({ issue, id, unresolved }) => { + const labels = new Set(issue.labels.map((label) => label.name)); + return !FIRST_BATCH.has(id) && + unresolved.length === 0 && + labels.has('intake: ready') && + !labels.has('status: in progress'); + }).length; + + let capacity = Math.max(0, READY_LIMIT - seedReady - nonSeedReady); + + for (const entry of state) { + const { issue, id, unresolved } = entry; + const labels = new Set(issue.labels.map((label) => label.name)); + const inProgress = labels.has('status: in progress'); + const shouldSeed = FIRST_BATCH.has(id); + const mayPromote = + !inProgress && + unresolved.length === 0 && + !labels.has('intake: ready') && + (shouldSeed || capacity > 0); + + const add = []; + const remove = []; + + if (unresolved.length > 0) { + if (!labels.has('blocked')) add.push('blocked'); + if (labels.has('intake: ready')) remove.push('intake: ready'); + if (!labels.has('intake: queued')) add.push('intake: queued'); + if (labels.has('help wanted')) remove.push('help wanted'); + } else { + if (labels.has('blocked')) remove.push('blocked'); + + if (labels.has('intake: ready') || mayPromote) { + if (!labels.has('intake: ready')) add.push('intake: ready'); + if (labels.has('intake: queued')) remove.push('intake: queued'); + if (!inProgress && !labels.has('help wanted')) add.push('help wanted'); + if (!labels.has('intake: ready') && !shouldSeed) capacity -= 1; + } else { + if (!labels.has('intake: queued')) add.push('intake: queued'); + if (labels.has('help wanted')) remove.push('help wanted'); + } + } + + if (add.length) { + await github.rest.issues.addLabels({ + owner, repo, issue_number: issue.number, labels: add, + }); + } + for (const name of remove) { + try { + await github.rest.issues.removeLabel({ + owner, repo, issue_number: issue.number, name, + }); + } catch (error) { + if (error.status !== 404) throw error; + } + } + } + + core.info(`Reconciled ${wave.length} Wave issues; ready limit ${READY_LIMIT}.`); diff --git a/.github/workflows/wave-pr-guard.yml b/.github/workflows/wave-pr-guard.yml new file mode 100644 index 0000000..a08f173 --- /dev/null +++ b/.github/workflows/wave-pr-guard.yml @@ -0,0 +1,117 @@ +name: Contributor PR Guard + +on: + pull_request: + types: [opened, edited, reopened, synchronize] + +permissions: + contents: read + issues: read + pull-requests: read + +jobs: + guard: + name: Wave PR scope and ownership + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Validate linked Wave issue and protected scope + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const pr = context.payload.pull_request; + const body = pr.body || ''; + 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) { + core.setFailed('Wave contributor PRs must use exactly one closing reference such as Closes #123.'); + return; + } + const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number: uniqueRefs[0] }); + const labels = new Set(issue.labels.map((label) => label.name)); + if (!labels.has('Stellar Wave')) { + core.setFailed('#' + uniqueRefs[0] + ' is not a Stellar Wave issue.'); + return; + } + if (issue.state !== 'open') { + core.setFailed('Linked Wave issue is not open.'); + return; + } + + const dependencySection = (issue.body || '').split(/## Dependencies/i)[1] || ''; + const dependencyText = dependencySection.split(/\n---|\n## /)[0]; + const dependencyIds = [...new Set(dependencyText.match(/SW-\d{3}/g) || [])]; + if (dependencyIds.length) { + const openIssues = await github.paginate(github.rest.issues.listForRepo, { + owner, repo, state: 'open', per_page: 100, + }); + const openWaveIds = new Set( + openIssues + .filter((item) => !item.pull_request && /^SW-\d{3}\b/.test(item.title)) + .map((item) => item.title.match(/^SW-\d{3}/)[0]), + ); + const unresolved = dependencyIds.filter((id) => openWaveIds.has(id)); + if (unresolved.length) { + core.setFailed('Linked issue still has unresolved dependencies: ' + unresolved.join(', ')); + return; + } + } + + if (!issue.assignees.some((a) => a.login === pr.user.login)) { + core.setFailed('PR author must claim and be assigned to the linked Wave issue before merge.'); + return; + } + + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, repo, pull_number: pr.number, per_page: 100, + }); + const protectedPatterns = [ + /^\.github\/workflows\//, /^\.github\/CODEOWNERS$/, /^\.changeset\//, + /^docs\/adr\//, /^docs\/architecture\//, + /^src\/(?:errors|output|config|process|deployment)\//, + /^src\/generator\/(?:validation|template-generation|template-registry|template-sources)\.ts$/, + /^src\/dev\/(?:orchestrator|environment)\.ts$/, + /^(?:package|package-lock)\.json$/, /^SECURITY\.md$/, + ]; + const protectedFiles = files.map((f) => f.filename) + .filter((name) => protectedPatterns.some((pattern) => pattern.test(name))); + + const currentNames = new Set(files.map((file) => file.filename)); + const openPulls = await github.paginate(github.rest.pulls.list, { + owner, repo, state: 'open', per_page: 100, + }); + const conflicts = []; + for (const other of openPulls) { + if (other.number === pr.number || other.draft) continue; + const otherFiles = await github.paginate(github.rest.pulls.listFiles, { + owner, repo, pull_number: other.number, per_page: 100, + }); + const overlap = otherFiles + .map((file) => file.filename) + .filter((name) => currentNames.has(name)) + .filter((name) => + protectedPatterns.some((pattern) => pattern.test(name)) || + name.startsWith('src/'), + ); + if (overlap.length) conflicts.push('#' + other.number + ': ' + overlap.join(', ')); + } + if (conflicts.length) { + core.setFailed('Potential contributor conflict with open PRs: ' + conflicts.join(' | ')); + return; + } + + if (protectedFiles.length && !labels.has('protected-surface')) { + core.setFailed('PR touches protected surfaces not declared by the linked issue: ' + protectedFiles.join(', ')); + return; + } + if (!/##\s+(?:Testing|Tests)/i.test(body)) { + core.setFailed('PR body must include a Testing/Tests section.'); + return; + } + if (!/##\s+Security/i.test(body)) { + core.setFailed('PR body must include a Security section.'); + return; + } + core.info('Validated Wave issue #' + issue.number + ' for PR #' + pr.number + '.'); diff --git a/.github/workflows/wave-stale-prs.yml b/.github/workflows/wave-stale-prs.yml new file mode 100644 index 0000000..2573ca5 --- /dev/null +++ b/.github/workflows/wave-stale-prs.yml @@ -0,0 +1,62 @@ +name: Contributor PR Stale Review + +on: + schedule: + - cron: '17 6 * * *' + workflow_dispatch: + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + stale: + name: Review inactive contributor PRs + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Warn and close stale Wave PRs + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const now = Date.now(); + const DAY = 24 * 60 * 60 * 1000; + const WARN_AFTER = 7 * DAY; + const CLOSE_AFTER = 21 * DAY; + const pulls = await github.paginate(github.rest.pulls.list, { owner, repo, state: 'open', per_page: 100 }); + + for (const pr of pulls) { + if (pr.draft) continue; + const age = now - new Date(pr.updated_at).getTime(); + const labels = new Set(pr.labels.map((label) => label.name)); + if (labels.has('do-not-close')) continue; + + const ref = (pr.body || '').match(/(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/i); + if (!ref) continue; + let issue; + try { + ({ data: issue } = await github.rest.issues.get({ owner, repo, issue_number: Number(ref[1]) })); + } catch { + continue; + } + if (!issue.labels.some((label) => label.name === 'Stellar Wave')) continue; + + if (age >= CLOSE_AFTER) { + await github.rest.issues.createComment({ + owner, repo, issue_number: pr.number, + body: 'Closing this contributor PR after 21 days without activity. The linked issue can be reclaimed. A maintainer can reopen the PR if work resumes.', + }); + await github.rest.pulls.update({ owner, repo, pull_number: pr.number, state: 'closed' }); + continue; + } + if (age >= WARN_AFTER && !labels.has('status: stale')) { + await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: ['status: stale'] }); + await github.rest.issues.createComment({ + owner, repo, issue_number: pr.number, + body: 'This PR has had no activity for 7 days. Please post an update or push progress. It may be closed after 21 days of inactivity.', + }); + } + } diff --git a/docs/contributing/wave-maintainer-baseline.md b/docs/contributing/wave-maintainer-baseline.md new file mode 100644 index 0000000..56ebdb1 --- /dev/null +++ b/docs/contributing/wave-maintainer-baseline.md @@ -0,0 +1,88 @@ +# Stellar Wave Maintainer Baseline + +**Frozen:** 2026-09-18 +**Status:** contributor intake baseline + +This document records the maintainer handoff from MVP implementation to contributor-led stabilization. + +## Current product baseline + +The implemented v1 stabilization surface is frozen by `wave-architecture.md`: + +- `stellarforge new` +- `stellarforge doctor` +- `stellarforge dev` +- `stellarforge test` +- `stellarforge deploy` (Testnet only) +- global help/version +- bundled templates: `basic-app`, `full-stack`, `smart-contract`, `api-service` + +The MVP smoke workflow and protected release automation are already implemented. CLI-037 and CLI-038 are closed. The subsequent v1 readiness and installation reconciliation work is also merged. + +## Contributor backlog baseline + +Maintainer conformance audit on 2026-09-18 verified: + +- GitHub issues #96 through #215 map to exactly one `SW-001` through `SW-120` issue each; +- every Wave issue has `Stellar Wave`, type, area, priority, difficulty, points, and workstream labels; +- the canonical 120-node dependency graph contains no missing IDs and no cycles; +- protected/high-conflict surfaces are sequenced in the canonical plan; +- the first batch and reserve set are explicitly identified. + +The contributor plan contains exactly 120 IDs, `SW-001` through `SW-120`. The canonical dependency graph is `wave-backlog-plan.md`. + +Maintainer policy: + +- contributor work extends the frozen architecture; it does not casually redesign it; +- protected surfaces require explicit issue scope and maintainer review; +- dependencies are represented by SW IDs in issue bodies; +- only issues carrying `intake: ready` are open for new claims; +- `intake: queued` issues remain visible backlog but should not be claimed yet; +- `blocked` means at least one declared SW dependency is still open; +- automation maintains a bounded ready pool rather than exposing the entire backlog at once. + +## First contribution batch + +The initial ready batch is: + +`SW-001`–`SW-014`, `SW-019`, and `SW-020`. + +`SW-015`–`SW-018` are reserve template-documentation issues. Later issues are promoted as capacity opens and their dependencies are satisfied. + +## Maintainer-owned work + +Maintainers retain responsibility for: + +- architecture and security decisions; +- protected/core surfaces; +- issue dependency and conflict sequencing; +- repository/ruleset administration; +- release credentials/environments and npm Trusted Publishing; +- vulnerability handling; +- final merge decisions; +- backlog conformance audits. + +## External repository administration + +Some controls cannot be established by repository files alone and must be verified in GitHub settings: + +- branch/ruleset enforcement; +- required status checks and CODEOWNERS enforcement; +- Dependency Graph / Dependency Review availability; +- release environment protection; +- npm Trusted Publishing; +- repository milestones. + +Repository labels are the authoritative automation state even when a GitHub milestone is also used. + +## Automation state model + +Automation uses these labels: + +- `intake: ready` — contributor may claim; +- `intake: queued` — visible backlog, not currently open for claim; +- `blocked` — declared dependency still open; +- `status: in progress` — assigned contributor has claimed the issue; +- `status: stale` — inactivity warning has been issued. + +The automation keeps at most 20 unassigned ready Wave issues at a time. Dependency-free queued issues are promoted in SW-ID order as capacity becomes available.