diff --git a/.github/workflows/revival-start.yml b/.github/workflows/revival-start.yml new file mode 100644 index 0000000..57a9e83 --- /dev/null +++ b/.github/workflows/revival-start.yml @@ -0,0 +1,71 @@ +# Reusable workflow: start a module revival. +# +# Call it from the module repo so the issues land there, where contributors look. +# Drop this caller into the module repo as .github/workflows/revival-start.yml, then run it +# from the Actions tab (or `gh workflow run revival-start.yml -f steward=`): +# +# name: Start module revival +# on: +# workflow_dispatch: +# inputs: +# steward: +# description: "GitHub login of the Steward" +# required: false +# permissions: +# issues: write +# contents: read +# jobs: +# start: +# uses: PowerShellOrg/.github/.github/workflows/revival-start.yml@main +# with: +# steward: ${{ inputs.steward }} +# +# Opens one "[REVIVAL] " tracking issue plus six phase sub-issues from +# docs/revival/*.md in PowerShellOrg/.github. Idempotent: re-running finds the existing +# tracking issue and stops. Runs on the caller's GITHUB_TOKEN; no secrets. + +name: Start module revival (reusable) + +on: + workflow_call: + inputs: + module: + description: "Module name for titles. Defaults to the calling repo's name." + type: string + required: false + steward: + description: "GitHub login of the Steward (assigned to every issue)" + type: string + required: false + source_ref: + description: "Branch or tag of PowerShellOrg/.github to read docs/revival from" + type: string + required: false + default: main + outputs: + tracking_issue: + description: "Number of the tracking issue (new or existing)" + value: ${{ jobs.create.outputs.tracking_issue }} + +permissions: + issues: write + contents: read + +jobs: + create: + runs-on: ubuntu-latest + outputs: + tracking_issue: ${{ steps.run.outputs.tracking_issue }} + steps: + - uses: actions/checkout@v5 + with: + repository: PowerShellOrg/.github + ref: ${{ inputs.source_ref }} + - id: run + uses: actions/github-script@v8 + env: + INPUTS: ${{ toJSON(inputs) }} + with: + script: | + const run = require('./scripts/create-revival.js'); + await run({ github, context, core, inputs: JSON.parse(process.env.INPUTS) }); diff --git a/docs/revival/labels.yml b/docs/revival/labels.yml new file mode 100644 index 0000000..35b7e6a --- /dev/null +++ b/docs/revival/labels.yml @@ -0,0 +1,79 @@ +# PowerShellOrg module-revival label manifest +# Lives at PowerShellOrg/.github/docs/revival/labels.yml (PR #16). First applied to BuildHelpers 2026-09-17. +# +# Convention: colon-space, lowercase. No emoji. No "/", "&", or apostrophes in names — +# they break label-sync tooling (vscode-powershell carries both "Area-Build & Release" +# and "Area-Build Release" from exactly this). +# +# One colour per facet. Only priority gets a gradient, and it lives in an Issue Field, +# not here. +# +# NOT in this file, deliberately: +# type: -> native org Issue Types (Task / Bug / Feature) — already enabled +# priority: -> org Issue Field "Priority" — Urgent / High / Medium / Low +# effort: -> dropped. Every human-estimated effort facet measured is dead or dying +# skill: -> dropped. Zero of 15 large repos surveyed track it + +# --------------------------------------------------------------------------- +# Discovery — EXACT strings GitHub indexes. Do not prefix, TitleCase, or emoji these. +# "good first issue" populates the repo Contribute page and feeds GitHub's +# approachability algorithm. Getting these wrong makes a revival invisible to the +# contributor funnel it exists to attract. +# --------------------------------------------------------------------------- +- name: "good first issue" + color: "7057ff" + description: "Newcomer-ready: solution explained, code identified, ready to test" +- name: "help wanted" + color: "008672" + description: "Maintainer welcomes a PR on this" + +# The revival-start workflow creates this if missing; listed so the colour matches. +- name: "revival" + color: "5319e7" + description: "Module revival tracking" + +# --------------------------------------------------------------------------- +# area: — what part of the project. Multi-valued. The four below are universal; +# the Steward adds module-specific ones (see the recipe in the playbook). +# --------------------------------------------------------------------------- +- name: "area: build" + color: "0052cc" + description: "CI, psake, packaging, release pipeline" +- name: "area: tests" + color: "0052cc" + description: "Pester tests and test infrastructure" +- name: "area: docs" + color: "0052cc" + description: "README, help text, examples, CONTRIBUTING" +- name: "area: triage" + color: "0052cc" + description: "Issue and PR triage, backlog cleanup" + +# --------------------------------------------------------------------------- +# status: — transient lifecycle state, often written by bots. Not a taxonomy. +# --------------------------------------------------------------------------- +- name: "status: needs-repro" + color: "bfc7d1" + description: "Awaiting reproduction steps or clarification from the reporter" +- name: "status: stale" + color: "bfc7d1" + description: "No recent activity; candidate for closing" + +# --------------------------------------------------------------------------- +# PRUNE — delete these on transfer. Each is superseded, not merely unfashionable. +# bug, enhancement -> native Issue Types (Bug, Feature) +# documentation -> area: docs +# duplicate -> native close reason "Duplicate" +# wontfix, invalid -> native close reason "Not planned" +# question -> GitHub Discussions (the playbook already routes these there) +# Prune only after checking usage; a label with real history may be worth migrating +# rather than deleting. +# --------------------------------------------------------------------------- +prune: + - "bug" + - "enhancement" + - "documentation" + - "duplicate" + - "wontfix" + - "invalid" + - "question" diff --git a/docs/revival/phase-0.md b/docs/revival/phase-0.md new file mode 100644 index 0000000..dd43ab3 --- /dev/null +++ b/docs/revival/phase-0.md @@ -0,0 +1,26 @@ +--- +title: "[REVIVAL] {{module}} - Phase 0: Inventory" +labels: revival +type: Task +role: Steward (PSGallery items: Org Admin) +--- + +Reference: [Playbook - Phase 0](https://github.com/PowerShellOrg/.github/blob/main/docs/revival-playbook.md#phase-0-take-inventory-week-1) + +## Transfer and access + +- [ ] Repo lives at `github.com/PowerShellOrg/{{module}}` +- [ ] Repo marked `status-incoming` +- [ ] Steward and maintainers have Write +- [ ] Branch protection on the default branch +- [ ] Default branch is `main` +- [ ] Labels match [`labels.yml`](https://github.com/PowerShellOrg/.github/blob/main/docs/revival/labels.yml) + +## PSGallery (Org Admin) + +- [ ] Package ownership transferred to the PowerShellOrg PSGallery account +- [ ] Scoped API key created: `PowerShellOrg-{{module}}-`, 365-day expiry +- [ ] `PSGALLERY_API_KEY` secret set on the repo +- [ ] Rotation reminder on the Org Admin's tracking issue + +Baseline metrics are in the tracking issue; fill in the PSGallery rows there. diff --git a/docs/revival/phase-1.md b/docs/revival/phase-1.md new file mode 100644 index 0000000..2cae965 --- /dev/null +++ b/docs/revival/phase-1.md @@ -0,0 +1,16 @@ +--- +title: "[REVIVAL] {{module}} - Phase 1: Issue triage" +labels: revival +type: Task +role: Steward +--- + +Reference: [Playbook - Phase 1](https://github.com/PowerShellOrg/.github/blob/main/docs/revival-playbook.md#phase-1-issue-triage-weeks-1-4) +(per-issue procedure and comment templates) + +Goal: every open issue is typed, acknowledged, and either closed or has a clear next step. +An issue with no Issue Type is untriaged. + +- [ ] Every open issue has an Issue Type +- [ ] Every open issue is either closed, waiting on the reporter (`status: needs-repro`), or has a next step +- [ ] Pre-adoption issues with no activity in 2 years are closed with the [stale comment](https://github.com/PowerShellOrg/.github/blob/main/docs/revival-playbook.md#comment-templates) diff --git a/docs/revival/phase-2.md b/docs/revival/phase-2.md new file mode 100644 index 0000000..4453bb2 --- /dev/null +++ b/docs/revival/phase-2.md @@ -0,0 +1,14 @@ +--- +title: "[REVIVAL] {{module}} - Phase 2: PR triage" +labels: revival +type: Task +role: Steward +--- + +Reference: [Playbook - Phase 2](https://github.com/PowerShellOrg/.github/blob/main/docs/revival-playbook.md#phase-2-pr-triage-weeks-1-4-parallel-with-phase-1) +(decision tree, take-over and close templates) + +Goal: no PR sits without a decision. Runs alongside Phase 1. + +- [ ] No open PR is older than 30 days without a maintainer comment +- [ ] Every open PR is merged, closed with a note, or actively under review diff --git a/docs/revival/phase-3.md b/docs/revival/phase-3.md new file mode 100644 index 0000000..bbb05b1 --- /dev/null +++ b/docs/revival/phase-3.md @@ -0,0 +1,51 @@ +--- +title: "[REVIVAL] {{module}} - Phase 3: Build modernization" +labels: revival +type: Task +role: Steward +--- + +Reference: [Playbook - Phase 3](https://github.com/PowerShellOrg/.github/blob/main/docs/revival-playbook.md#phase-3-build-modernization-weeks-2-6) +(standard stack, CI and release YAML) + +Goal: the repo builds with the standard stack and CI is green on all platforms. +Work in order; each step is its own PR. + +## 3a Pester + +- [ ] Tests exist and use Pester 5 +- [ ] `Invoke-Pester` passes locally + +## 3b psake + +- [ ] `psakeFile.ps1` defines `Init`, `Clean`, `Build`, `Test`, `Analyze`, `Publish` +- [ ] `Invoke-psake ?` lists them +- [ ] `Invoke-psake Test` passes + +## 3c PowerShellBuild + +- [ ] Build references PowerShellBuild for shared task logic +- [ ] `Test-ModuleManifest` passes +- [ ] `Invoke-psake Build` stages a clean module in `output/` + +## 3d PSScriptAnalyzer + +- [ ] `Invoke-psake Analyze` runs the org ruleset +- [ ] Zero warnings, or each suppression carries a justifying comment + +## 3e CI + +- [ ] `.github/workflows/ci.yml` calls the org reusable workflow +- [ ] CI green on `main` across Win/PS5.1, Win/PS7, Linux/PS7, macOS/PS7 +- [ ] Branch protection requires the CI check + +## 3f Release workflow + +- [ ] `.github/workflows/release.yml` calls the org reusable workflow +- [ ] A pre-release tag (`v*-beta.1`) ran the workflow end to end + +## 3g Coverage + +- [ ] Coverage report generated +- [ ] Baseline percentage recorded in the tracking issue +- [ ] Critical public functions have tests; gaps filed as issues diff --git a/docs/revival/phase-4.md b/docs/revival/phase-4.md new file mode 100644 index 0000000..c5bfdcf --- /dev/null +++ b/docs/revival/phase-4.md @@ -0,0 +1,27 @@ +--- +title: "[REVIVAL] {{module}} - Phase 4: First clean release" +labels: revival +type: Task +role: Steward +--- + +Reference: [Playbook - Phase 4](https://github.com/PowerShellOrg/.github/blob/main/docs/revival-playbook.md#phase-4-first-clean-release-weeks-4-8) +(release steps) + +Goal: a release under the PowerShellOrg banner you are proud to put your name on. + +## Release gate + +- [ ] `CHANGELOG.md` covers everything since the last release +- [ ] Version bumped: patch / minor / major as the changes warrant +- [ ] CI green and PSScriptAnalyzer clean +- [ ] Manifest accurate: description, author or org, copyright, tags, URLs +- [ ] `README.md` current: install, working examples, badges +- [ ] Open critical bugs fixed or deferred with a written reason +- [ ] Release PR reviewed by one other maintainer (or Steward if solo) + +## Exit + +- [ ] Release on PSGallery under PowerShellOrg +- [ ] GitHub Release with human-readable notes +- [ ] PSGallery package description says PowerShellOrg diff --git a/docs/revival/phase-5.md b/docs/revival/phase-5.md new file mode 100644 index 0000000..5d709c1 --- /dev/null +++ b/docs/revival/phase-5.md @@ -0,0 +1,16 @@ +--- +title: "[REVIVAL] {{module}} - Phase 5: Transition to active" +labels: revival +type: Task +role: Steward +--- + +Reference: [Playbook - Phase 5](https://github.com/PowerShellOrg/.github/blob/main/docs/revival-playbook.md#phase-5-ongoing-maintenance-and-transition-to-active) +(cadence table, graduation criteria) + +Closing this closes the revival. Ongoing cadence and graduation are in the playbook. + +- [ ] Repo marked `status-active` (was `status-incoming`) +- [ ] `README.md` status badge updated +- [ ] Org Admin notified +- [ ] Announced in the Council channel diff --git a/docs/revival/tracking.md b/docs/revival/tracking.md new file mode 100644 index 0000000..0e58bd6 --- /dev/null +++ b/docs/revival/tracking.md @@ -0,0 +1,34 @@ +--- +title: "[REVIVAL] {{module}}" +labels: revival +type: Task +--- + +# {{module}} revival + +Tracking issue for bringing **{{module}}** from `status-incoming` to `status-active`. +Steward: {{steward}}. Repo: {{repo_url}}. + +Each phase is a sub-issue below. Phases overlap; pace to the state of the repo, not the +week numbers. Reference material (comment templates, decision tree, YAML, release steps) is in +the [Revival Playbook](https://github.com/PowerShellOrg/.github/blob/main/docs/revival-playbook.md). + +## Baseline (captured at transfer) + +| Metric | Value | +|---|---| +| Transfer date | {{today}} | +| Last commit | {{last_push}} | +| Open issues | {{open_issues}} | +| Open PRs | {{open_prs}} | +| Stars / forks | {{stars}} / {{forks}} | +| Existing CI | {{ci}} | +| Last PSGallery release | _fill in: version and date_ | +| PSGallery downloads | _fill in_ | + +These numbers go in the first release announcement. + +## Working with an AI assistant + +Point it at the playbook and this issue. It drafts; you post. Comments on other people's +issues, PR closes, and merges are yours to click. diff --git a/scripts/create-revival.js b/scripts/create-revival.js new file mode 100644 index 0000000..998d064 --- /dev/null +++ b/scripts/create-revival.js @@ -0,0 +1,138 @@ +// Opens one [REVIVAL] tracking issue plus six phase sub-issues from docs/revival/*.md. +// Runs inside the reusable workflow, so context.repo is the CALLING module repo: issues are +// created there and the baseline is read from there. Idempotent: re-running for the same +// module finds every existing issue by title, creates only what is missing, and attaches +// any phase not yet linked. Caller's GITHUB_TOKEN, issues: write. +const fs = require('fs'); +const path = require('path'); + +const SOURCE_DIR = 'docs/revival'; +const PHASES = ['phase-0', 'phase-1', 'phase-2', 'phase-3', 'phase-4', 'phase-5']; +const TRACKING_LABEL = 'revival'; + +function parseSource(file) { + const raw = fs.readFileSync(path.join(process.cwd(), SOURCE_DIR, `${file}.md`), 'utf8'); + const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/); + if (!m) throw new Error(`${file}.md has no frontmatter`); + const meta = {}; + for (const line of m[1].split(/\r?\n/)) { + const kv = line.match(/^(\w+):\s*(.*)$/); + if (kv) meta[kv[1]] = kv[2].replace(/^"(.*)"$/, '$1').trim(); + } + const labels = (meta.labels || '').split(',').map(s => s.trim()).filter(Boolean); + return { title: meta.title, type: meta.type || null, labels, body: raw.slice(m[0].length) }; +} + +function fill(text, vars) { + return text.replace(/\{\{(\w+)\}\}/g, (_, k) => (vars[k] ?? `_${k}_`)); +} + +const CALLER_PATH = '.github/workflows/revival-start.yml'; + +async function baseline(github, owner, repo) { + const r = (await github.rest.repos.get({ owner, repo })).data; + // open_issues_count includes PRs; subtract an exact PR count. + const open_prs = (await github.paginate(github.rest.pulls.list, { owner, repo, state: 'open', per_page: 100 })).length; + + // CI detection. The caller workflow lives in .github/workflows, so its presence alone is not CI. + const ci = []; + try { + const entries = (await github.rest.repos.getContent({ owner, repo, path: '.github/workflows' })).data; + if (Array.isArray(entries) && entries.some(e => `.github/workflows/${e.name}` !== CALLER_PATH && /\.ya?ml$/.test(e.name))) ci.push('GitHub Actions'); + } catch (e) { if (e.status !== 404) throw e; } + for (const [p, name] of [['appveyor.yml', 'AppVeyor'], ['azure-pipelines.yml', 'Azure Pipelines'], ['.travis.yml', 'Travis']]) { + try { await github.rest.repos.getContent({ owner, repo, path: p }); ci.push(name); } catch (e) { if (e.status !== 404) throw e; } + } + + // Last real commit: skip commits that only touch the caller file. + let last_push = r.pushed_at.slice(0, 10); + const recent = (await github.rest.repos.listCommits({ owner, repo, per_page: 5 })).data; + for (const c of recent) { + const files = (await github.rest.repos.getCommit({ owner, repo, ref: c.sha })).data.files || []; + if (files.length && files.every(f => f.filename === CALLER_PATH)) continue; + last_push = c.commit.committer.date.slice(0, 10); + break; + } + + return { + repo_url: r.html_url, last_push, open_issues: r.open_issues_count - open_prs, open_prs, + stars: r.stargazers_count, forks: r.forks_count, ci: ci.length ? ci.join(', ') : 'none', + }; +} + +async function revivalIssues(github, owner, repo) { + const issues = await github.paginate(github.rest.issues.listForRepo, { owner, repo, labels: TRACKING_LABEL, state: 'all', per_page: 100 }); + return new Map(issues.filter(i => !i.pull_request).map(i => [i.title, i])); +} + +async function subIssueIds(github, owner, repo, parentNumber) { + const subs = await github.paginate('GET /repos/{owner}/{repo}/issues/{issue_number}/sub_issues', { owner, repo, issue_number: parentNumber, per_page: 100 }); + return new Set(subs.map(s => s.id)); +} + +async function ensureLabel(github, owner, repo) { + try { await github.rest.issues.getLabel({ owner, repo, name: TRACKING_LABEL }); } + catch (e) { + if (e.status !== 404) throw e; + await github.rest.issues.createLabel({ owner, repo, name: TRACKING_LABEL, color: '5319e7', description: 'Module revival tracking' }); + } +} + +async function createIssue(github, owner, repo, src, vars) { + const params = { owner, repo, title: fill(src.title, vars), body: fill(src.body, vars), labels: src.labels }; + if (src.type) params.type = src.type; + if (vars.steward !== 'unassigned') params.assignees = [vars.steward.slice(1)]; + try { + return (await github.request('POST /repos/{owner}/{repo}/issues', params)).data; + } catch (e) { + // Issue type unknown to this org, or steward not assignable: retry bare rather than fail the run. + if (e.status !== 422) throw e; + console.warn(`422 creating "${params.title}" (${e.message}); retrying without type/assignees`); + delete params.type; delete params.assignees; + return (await github.request('POST /repos/{owner}/{repo}/issues', params)).data; + } +} + +module.exports = async function run({ github, context, core, inputs }) { + const { owner, repo } = context.repo; + const module = (inputs.module || repo).trim(); + const tracking = parseSource('tracking'); + + const vars = { + module, steward: inputs.steward ? '@' + inputs.steward.trim().replace(/^@/, '') : 'unassigned', + today: new Date().toISOString().slice(0, 10), ...(await baseline(github, owner, repo)), + }; + await ensureLabel(github, owner, repo); + const byTitle = await revivalIssues(github, owner, repo); + const findOrCreate = async (src) => { + const title = fill(src.title, vars); + if (byTitle.has(title)) { core.info(`exists ${title} -> #${byTitle.get(title).number}`); return { issue: byTitle.get(title), created: false }; } + const issue = await createIssue(github, owner, repo, src, vars); + byTitle.set(title, issue); + core.info(`created ${title} -> #${issue.number}`); + return { issue, created: true }; + }; + + // Find-or-create the parent, then reconcile every phase and every attachment. A re-run after a + // partial failure completes the set instead of stopping at "parent exists". + const { issue: parent, created: parentCreated } = await findOrCreate(tracking); + const attached = await subIssueIds(github, owner, repo, parent.number); + const children = []; let createdCount = parentCreated ? 1 : 0, attachedCount = 0; + for (const name of PHASES) { + const { issue: child, created } = await findOrCreate(parseSource(name)); + if (created) createdCount++; + if (!attached.has(child.id)) { + // sub_issue_id is the database id, not the issue number. + await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/sub_issues', { owner, repo, issue_number: parent.number, sub_issue_id: child.id }); + attachedCount++; + } + children.push(child); + } + + const verb = createdCount === 0 && attachedCount === 0 ? 'Already complete' : `Reconciled (${createdCount} created, ${attachedCount} attached)`; + core.setOutput('tracking_issue', parent.number); + core.summary.addHeading(`Revival: ${module}`) + .addRaw(`${verb}. Tracking issue [#${parent.number}](${parent.html_url}) with ${children.length} phase sub-issues.`) + .addList(children.map(c => `#${c.number} ${c.title}`)) + .write(); +};