diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..111e867 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,47 @@ +name: Bug report +description: Report a reproducible problem in a Flutter Forge package or tool. +title: 'fix: ' +body: + - type: input + id: package + attributes: + label: Package and version + placeholder: 'nonstop_cli 0.0.8+2' + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment + description: Include Flutter/Dart versions, operating system and affected platforms. + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Steps and minimal reproduction + description: Provide steps and a minimal code example or public reproduction repository. Remove credentials and private data. + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior and logs + description: Include the complete relevant error or stack trace, with secrets removed. + validations: + required: true + - type: checkboxes + id: checks + attributes: + label: Before submitting + options: + - label: I searched existing issues for this problem. + required: true + - label: This is not a security vulnerability (see SECURITY.md for private reporting). + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..7be77ce --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Report a security vulnerability privately + url: https://github.com/nonstopio/flutter_forge/blob/main/SECURITY.md + about: Follow the private reporting instructions; do not open a public vulnerability issue. + - name: Release setup and troubleshooting + url: https://github.com/nonstopio/flutter_forge/blob/main/docs/RELEASING.md + about: Authentication, release commands, and recovering a failed publication. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..1f4879c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,29 @@ +name: Feature request +description: Suggest an improvement to a package, plugin or development tool. +title: 'feat: ' +body: + - type: input + id: package + attributes: + label: Affected package or tool + validations: + required: true + - type: textarea + id: problem + attributes: + label: What problem would this solve? + description: Describe the use case and the current limitation. + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed behavior + description: Include a small API or usage example if useful. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives and compatibility + description: Mention workarounds, alternatives, and any expected breaking changes. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..7059415 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,20 @@ +## What changes? + +Describe the problem and resulting behavior. Link the related issue (for example, `Fixes #123`). + +## Validation + +List the commands you ran and their results. For UI changes, include screenshots. + +## Compatibility + +List affected packages, breaking changes and migration steps, or write “None”. + +## Checklist + +- [ ] The PR title follows Conventional Commits, for example `fix(ns_utils): handle empty input`. +- [ ] Relevant analysis and tests pass; behavior changes have appropriate tests. +- [ ] Documentation/examples are updated where needed. +- [ ] Package versions and changelogs are left to Melos. +- [ ] New public packages include a README, LICENSE, tests and release setup from `docs/RELEASING.md`. +- [ ] No credentials, private data or generated build output are included. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..87c0249 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + open-pull-requests-limit: 5 + commit-message: + prefix: chore + groups: + github-actions: + patterns: ['*'] diff --git a/.github/scripts/release-failure.mjs b/.github/scripts/release-failure.mjs new file mode 100644 index 0000000..4eb6c94 --- /dev/null +++ b/.github/scripts/release-failure.mjs @@ -0,0 +1,62 @@ +const failures = new Set(['failure', 'timed_out', 'cancelled', 'action_required', 'startup_failure']); + +export function diagnostic(text) { + return String(text) + .replace(/\x1b\[[0-9;]*m/g, '') + .replace(/(?:gh[pousr]_[A-Za-z0-9_]+|github_pat_[A-Za-z0-9_]+|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/g, '[REDACTED]') + .replace(/(authorization\s*[:=]\s*(?:bearer|basic)\s+)\S+/gi, '$1[REDACTED]') + .replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/g, '$1[REDACTED]@') + .replace(/`/g, "'") + .replace(/@/g, '@\u200b') + .slice(-7000); +} + +export async function reportFailure({ github, context, core }) { + const run = context.payload.workflow_run; + if (!failures.has(run.conclusion)) return; + const repo = context.repo; + const marker = ``; + const body = [marker, `## ${run.name} failed`, '', + `- Run: [${run.id}, attempt ${run.run_attempt}](${run.html_url})`, + `- Commit: \`${run.head_sha}\``, `- Ref: \`${diagnostic(run.head_branch)}\``, + `- Result: **${run.conclusion}**`, '', '### Failure details', '']; + try { + const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRunAttempt, { + ...repo, run_id: run.id, attempt_number: run.run_attempt, per_page: 100, + }); + const failed = jobs.filter((job) => failures.has(job.conclusion)); + if (!failed.length) body.push('No failed job details are available. The run may have failed during startup; see the run link for GitHub’s diagnostic.'); + for (const job of failed) { + body.push(`#### [${diagnostic(job.name)}](${job.html_url}) — ${job.conclusion}`); + for (const step of job.steps || []) if (failures.has(step.conclusion)) body.push(`- Step ${step.number}: **${diagnostic(step.name)}** (${step.conclusion})`); + try { + const checkId = Number(job.check_run_url?.split('/').at(-1)); + if (checkId) { + const annotations = await github.paginate(github.rest.checks.listAnnotations, { ...repo, check_run_id: checkId, per_page: 100 }); + for (const annotation of annotations.filter((item) => item.annotation_level === 'failure').slice(0, 5)) { + body.push(`\n\`\`\`text\n${diagnostic(annotation.message)}\n\`\`\``); + } + } + const logs = await github.rest.actions.downloadJobLogsForWorkflowRun({ ...repo, job_id: job.id }); + const text = typeof logs.data === 'string' ? logs.data : Buffer.from(logs.data).toString('utf8'); + body.push(`\n
Job log tail (GitHub-masked)\n\n\`\`\`text\n${diagnostic(text.split('\n').slice(-45).join('\n'))}\n\`\`\`\n
`); + } catch (error) { + core.warning(`Could not retrieve some diagnostics for job ${job.id}: ${error.message}`); + body.push('Some annotations/logs were unavailable. Open the job link above for the full error.'); + } + } + } catch (error) { + core.warning(`Could not list failed jobs: ${error.message}`); + body.push('GitHub did not return job details. Open the run link above to inspect its startup or runner error.'); + } + // Leave room under GitHub's issue body limit even for many failed jobs. + const details = body.join('\n').slice(0, 55_000); + const finalBody = `${details}\n\n### Recovery\n\nFix the reported cause, then follow [release recovery](https://github.com/${repo.owner}/${repo.repo}/blob/main/docs/RELEASING.md#recover-a-failed-release). Already published versions are skipped on retry. Never move a published tag or reuse a version.\n`; + const issues = await github.paginate(github.rest.issues.listForRepo, { ...repo, state: 'all', creator: 'github-actions[bot]', per_page: 100 }); + const existing = issues.find((issue) => !issue.pull_request && issue.body?.includes(marker)); + const fields = { ...repo, title: `[release] ${run.name} failed (run ${run.id})`, body: finalBody }; + const result = existing + ? await github.rest.issues.update({ ...fields, issue_number: existing.number, state: 'open' }) + : await github.rest.issues.create(fields); + core.notice(`Release failure issue: ${result.data.html_url}`); +} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..53ecd75 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,46 @@ +name: Publish to pub.dev + +on: + push: + tags: ['*-v[0-9]*'] + +permissions: + contents: read + +concurrency: + group: publish-${{ github.ref }} + cancel-in-progress: false + +jobs: + publish: + if: github.event.deleted == false + runs-on: ubuntu-latest + timeout-minutes: 45 + environment: pub.dev + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '22' + # setup-dart configures short-lived pub.dev OIDC credentials. Install + # Flutter afterward so all commands use Flutter's bundled Dart SDK. + - name: Authenticate to pub.dev with OIDC + uses: dart-lang/setup-dart@v1 + with: + sdk: '3.13.4' + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.47.5' + cache: true + - name: Resolve workspace tools + run: flutter pub get --enforce-lockfile + - name: Validate and publish tagged package + env: + RELEASE_TAG: ${{ github.ref_name }} + run: node tools/release/release.mjs publish "$RELEASE_TAG" --execute diff --git a/.github/workflows/release-failure.yml b/.github/workflows/release-failure.yml new file mode 100644 index 0000000..8e5667e --- /dev/null +++ b/.github/workflows/release-failure.yml @@ -0,0 +1,37 @@ +name: Report release failure + +on: + workflow_run: + workflows: ['Prepare pub.dev release', 'Publish to pub.dev'] + types: [completed] + +permissions: + contents: read + actions: read + checks: read + issues: write + +concurrency: + group: release-report-${{ github.event.workflow_run.id }} + cancel-in-progress: false + +jobs: + report: + if: >- + github.event.workflow_run.repository.full_name == github.repository && + (github.event.workflow_run.event == 'push' || github.event.workflow_run.event == 'workflow_dispatch') && + contains(fromJSON('["failure", "timed_out", "cancelled", "action_required", "startup_failure"]'), github.event.workflow_run.conclusion) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + # workflow_run is privileged: execute only the default branch's reporter, + # never scripts/artifacts checked out from the failed run's head SHA. + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + - uses: actions/github-script@v7 + with: + script: | + const { reportFailure } = await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/release-failure.mjs`); + await reportFailure({ github, context, core }); diff --git a/.github/workflows/release-tooling.yml b/.github/workflows/release-tooling.yml new file mode 100644 index 0000000..0a70406 --- /dev/null +++ b/.github/workflows/release-tooling.yml @@ -0,0 +1,25 @@ +name: Release tooling + +on: + pull_request: + paths: ['tools/release/**', '.github/scripts/**', '.github/workflows/**', '.github/ISSUE_TEMPLATE/**'] + push: + branches: [main] + paths: ['tools/release/**', '.github/scripts/**', '.github/workflows/**', '.github/ISSUE_TEMPLATE/**'] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: node --test tools/release/*.test.mjs + - name: Validate GitHub Actions workflows + # actionlint also checks expressions, action inputs, and embedded shell. + run: | + docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:1.7.7 -color .github/workflows/release.yml .github/workflows/publish.yml .github/workflows/release-failure.yml .github/workflows/release-tooling.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..83d0ee8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,71 @@ +name: Prepare pub.dev release + +on: + push: + branches: [main] + workflow_dispatch: + inputs: + recovery_sha: + description: 'Optional full release commit SHA: retry only missing tags' + type: string + required: false + +permissions: + contents: read + +concurrency: + group: release-main + cancel-in-progress: false + +jobs: + prepare: + # The App's version commit must not recursively version the workspace. + if: >- + github.event_name == 'workflow_dispatch' || + !startsWith(github.event.head_commit.message, 'chore(release): publish packages') + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Create release App token + id: app + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + permission-contents: write + - uses: actions/checkout@v4 + with: + # Read main when this serialized job starts, so queued merges accumulate. + ref: main + fetch-depth: 0 + token: ${{ steps.app.outputs.token }} + - uses: actions/setup-node@v4 + with: + node-version: '22' + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.47.5' + cache: true + - name: Install release tools + run: | + flutter pub get --enforce-lockfile + dart run melos --version + - name: Configure release author + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + - name: Test release tooling + env: + RELEASE_INTEGRATION: '1' + run: node --test tools/release/*.test.mjs + - name: Version and validate changed packages + if: inputs.recovery_sha == '' + run: node tools/release/release.mjs prepare + - name: Push release commit and individual tags + if: inputs.recovery_sha == '' + run: node tools/release/release.mjs push + - name: Recover missing release tags + if: inputs.recovery_sha != '' + env: + RECOVERY_SHA: ${{ inputs.recovery_sha }} + run: node tools/release/release.mjs push-tags "$RECOVERY_SHA" diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..f60aa36 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,17 @@ +# Code of conduct + +We welcome contributors with different backgrounds and levels of experience. +Treat people respectfully, give constructive feedback, and focus disagreement +on ideas and code. Respect privacy and credit others' work. + +Harassment, discriminatory language, threats, personal attacks, sexualized +content, and sharing private information without consent are not acceptable. +These expectations apply to this repository's issues, pull requests and other +project spaces. + +Report concerns privately to [ajay.kumar@nonstopio.com](mailto:ajay.kumar@nonstopio.com), +the maintainer contact listed in the repository metadata. Include links and +context where possible. Maintainers will review reports, protect reporter +privacy as far as possible, and may remove content, limit participation, or +exclude contributors according to the circumstances. Maintainers are expected +to apply these standards fairly. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..62d57d8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,50 @@ +# Contributing to Flutter Forge + +Use an issue to describe a bug or proposed feature before a large change. Keep +each PR focused, include a reproducible example when relevant, and follow our +[Code of Conduct](CODE_OF_CONDUCT.md). + +## Local setup + +Install Flutter and Node 22+ (for release tooling), then from the repository root: + +```sh +flutter pub get --enforce-lockfile +dart run melos --version +dart run melos bootstrap --scope=PACKAGE_NAME --include-dependencies +``` + +Replace `PACKAGE_NAME` with the package you are changing. All CI and release +workflows use Flutter 3.47.5 and the pinned Melos 8.8.0 workspace dependency. + +Run `dart analyze --fatal-infos` inside the affected package. Use `flutter test` +for Flutter packages, or `dart test` for Dart-only packages. Format changed Dart +files with `dart format`. Do not format Mason `__brick__` sources as ordinary +Dart; they contain template syntax. For NonStop templates, also run the contract +and generation checks described in `packages/nonstop_cli`. + +For release tooling changes: + +```sh +node --test tools/release/*.test.mjs +``` + +## Pull requests and releases + +Use Conventional Commits for commits and PR titles, such as +`fix(timer_button): cancel timer on dispose` or +`feat(ns_utils): add date helper`. Describe breaking changes using the convention's +`!` or `BREAKING CHANGE:` notation. Maintainers should preserve these messages +when merging so Melos can determine version changes. + +Do not manually bump package versions or edit generated release changelogs. +Melos handles both when the PR merges to `main`. Include tests for behavior +changes, documentation for public APIs, and migration instructions for breaking +changes. Keep dependencies and generated assets scoped to the change. + +New packages need their own README, LICENSE, pubspec metadata and tests. Mark +examples and internal tools with `publish_to: none`. Follow +[the release guide](docs/RELEASING.md) for the first publication and pub.dev +automation setup. Package licenses are defined by their individual LICENSE files. + +Report security issues through [SECURITY.md](SECURITY.md), not public bug reports. diff --git a/README.md b/README.md index fedd890..0401454 100644 --- a/README.md +++ b/README.md @@ -107,3 +107,10 @@ Run `python3 tools/coverage.py` (or `dart run melos run test`) to test every wor + +## Contributing and releases + +See [CONTRIBUTING.md](CONTRIBUTING.md) for development and PR guidelines, +[the release guide](docs/RELEASING.md) for automatic pub.dev publishing and authentication, +[SECURITY.md](SECURITY.md) for private vulnerability reports, and +[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for community expectations. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..cc8e02e --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,19 @@ +# Security policy + +Please report suspected vulnerabilities privately. Include the affected package +and version, impact, reproduction steps, and any suggested mitigation. Remove +credentials and personal data from attachments. + +Use GitHub's **Security → Report a vulnerability** when private vulnerability +reporting is available. Otherwise contact the maintainer at +[ajay.kumar@nonstopio.com](mailto:ajay.kumar@nonstopio.com) with the subject +“Flutter Forge security report”. This is the maintainer contact listed in the +repository metadata. Do not open a public issue with exploit details. + +Maintainers prioritize fixes on the latest release of an affected package. +Older versions are assessed case by case; no response-time or backport guarantee +is made. We will coordinate disclosure after assessing the report and preparing +an appropriate fix. + +Repository administrators should enable **Settings → Code security → Private +vulnerability reporting** so reports can be handled through GitHub advisories. diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..8055fa1 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,241 @@ +# Automated pub.dev releases + +Merging into `main` starts **Prepare pub.dev release**. Melos 8.8.0 determines +version changes from Conventional Commits, updates affected dependents and +changelogs, and runs the existing NonStop CLI generation hooks. The release CLI +analyzes and tests the selected packages, creates one release commit, pushes it +to `main`, then pushes each package tag separately. Unchanged packages, examples, +brick hooks and other private packages are not published. + +Each tag starts **Publish to pub.dev**. It checks the tag against the package +version and committed release manifest, waits for workspace dependencies to be +available on pub.dev, resolves public dependencies without Melos overrides, +runs analysis/tests and `dart pub publish --dry-run`, then uploads. Versions +already on pub.dev are skipped, so rerunning a partially completed release is safe. + +The independent **Report release failure** workflow creates or updates one issue +per failed run with the commit, failed job/step, available annotations, a bounded +log excerpt, and recovery links. It also handles timeouts and cancellations. +Reporting itself requires working GitHub Actions/API access and enabled Issues; +an outage, disabled Actions, or revoked issue permissions cannot be reported by +the same unavailable service. Check the reporter workflow if no issue appears. + +## One-time authentication setup + +There are two separate identities: a GitHub App pushes commits/tags, and GitHub +OIDC authenticates each tag job to pub.dev. **No pub.dev password, refresh token, +or `credentials.json` is stored in GitHub.** + +### 1. Check package ownership and first publication + +Sign in to pub.dev as an uploader, or an administrator of the package's verified +publisher. All current public packages already exist on pub.dev. For any new +package, publish its first version interactively before enabling automation: + +```sh +cd packages/your_package +flutter pub get +dart pub publish --dry-run +dart pub publish +``` + +Follow the Google sign-in prompt. That login is for the local first publication; +do not copy its credentials into CI. Keep the initial package version/tag aligned +with the repository before merging subsequent changes. + +### 2. Create the GitHub publishing environment + +In `nonstopio/flutter_forge`, open **Settings → Environments → New environment** +and create **`pub.dev`**. Allow deployment tags matching `*-v*` (choose selected +branches and tags and add a **tag** rule). The job declares `environment: pub.dev`. +Leave required reviewers disabled for fully automatic publishing; adding a +reviewer intentionally introduces an approval step for every publication. + +### 3. Enable pub.dev automated publishing for every public package + +Open each package's **Admin → Automated publishing → Enable publishing from +GitHub Actions**, and enter: + +- Repository: **`nonstopio/flutter_forge`** +- Tag pattern: the exact value below, including literal `{{version}}` +- Require GitHub Actions environment: **`pub.dev`** +- If the UI offers event choices, enable **push** events. +- Leave **workflow_dispatch** publishing disabled; retries use the original tag run. + +| Package / admin settings | Tag pattern | +| --- | --- | +| [cli_core](https://pub.dev/packages/cli_core/admin) | `cli_core-v{{version}}` | +| [connectivity_wrapper](https://pub.dev/packages/connectivity_wrapper/admin) | `connectivity_wrapper-v{{version}}` | +| [contact_permission](https://pub.dev/packages/contact_permission/admin) | `contact_permission-v{{version}}` | +| [dzod](https://pub.dev/packages/dzod/admin) | `dzod-v{{version}}` | +| [html_rich_text](https://pub.dev/packages/html_rich_text/admin) | `html_rich_text-v{{version}}` | +| [morse_tap](https://pub.dev/packages/morse_tap/admin) | `morse_tap-v{{version}}` | +| [nonstop_cli](https://pub.dev/packages/nonstop_cli/admin) | `nonstop_cli-v{{version}}` | +| [ns_firebase_utils](https://pub.dev/packages/ns_firebase_utils/admin) | `ns_firebase_utils-v{{version}}` | +| [ns_intl_phone_input](https://pub.dev/packages/ns_intl_phone_input/admin) | `ns_intl_phone_input-v{{version}}` | +| [ns_utils](https://pub.dev/packages/ns_utils/admin) | `ns_utils-v{{version}}` | +| [timer_button](https://pub.dev/packages/timer_button/admin) | `timer_button-v{{version}}` | + +To generate the current inventory locally: + +```sh +flutter pub get --enforce-lockfile +dart run melos --version +node tools/release/release.mjs auth +``` + +`auth` prints configuration instructions; it never collects or stores credentials. +During a tag job, `id-token: write` permits `dart-lang/setup-dart` to obtain and +configure a short-lived OIDC token. Pub.dev checks the repository, tag/version, +and environment. `dart pub publish` automatically uses that credential. A branch +push cannot directly authenticate this publishing flow. See [Dart's automated +publishing guide](https://dart.dev/tools/pub/automated-publishing). + +### 4. Create and install a GitHub release App + +In your organization settings, open **Developer settings → GitHub Apps → New +GitHub App**. Create an organization-owned App, for example `Flutter Forge Releases`: + +1. Use the repository URL as its homepage. Disable webhooks; this App only needs + an installation token generated by Actions. +2. Set repository **Contents: Read and write**. Metadata read access is implicit; + no organization permissions are needed. +3. Install it on **only `nonstopio/flutter_forge`**. +4. Copy the **App ID**, then generate and download a private key. +5. Under the repository's **Settings → Secrets and variables → Actions**, add + repository variable **`RELEASE_APP_ID`** and repository secret + **`RELEASE_APP_PRIVATE_KEY`** (the entire PEM file, including header/footer). + +The equivalent GitHub CLI setup, after creating/installing the App, is: + +```sh +gh variable set RELEASE_APP_ID --repo nonstopio/flutter_forge --body 'YOUR_APP_ID' +gh secret set RELEASE_APP_PRIVATE_KEY --repo nonstopio/flutter_forge < /secure/path/release-app.pem +``` + +The prepare workflow uses `actions/create-github-app-token` to get a temporary +installation token and passes it to checkout for authenticated Git pushes. +Do not replace this with `GITHUB_TOKEN`: GitHub suppresses new push-triggered +workflow runs for that token. [GitHub documents this restriction and the App +alternative](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/trigger-a-workflow). + +### 5. Configure repository rules and Actions + +- Keep Issues enabled. The reporter requests `issues: write`, `actions: read` + and `checks: read` using its own `GITHUB_TOKEN`; it does not use the release App. +- If organization policy restricts Actions or token permissions, allow the + actions referenced in the four release workflow files and those permissions. +- Allow the release App to push version commits to `main`. If `main` requires + pull requests/checks, add this App as a bypass actor on the applicable ruleset. + A rule requiring a PR for *every* change cannot also allow direct automatic + version commits without such an exception. Keep normal contributor PR rules. +- Protect package tags matching `*-v*` against unauthorized creation, updates, + and deletion; allow the release App to create them. Never force-update a + published tag. Pub.dev versions are immutable. +- Use squash merging with a Conventional Commit PR title, or preserve + Conventional Commit messages in the merge. `fix(package): ...` produces patch + releases, `feat(package): ...` minor releases, and breaking-change notation + drives major changes according to Melos's version rules. A package with only + non-versionable changes may produce no release. Dependents are updated by Melos. + +### 6. Enable and verify the first release + +Finish the preceding configuration before merging these workflow files. Merge +to `main` and inspect **Actions → Prepare pub.dev release**. Existing versionable +commits since the last package tags may be included in this first release. +Confirm its generated version commit, package tag runs, and pub.dev versions. +The version commit itself is excluded from recursive preparation. + +Release checks use Flutter **3.47.5** and the pinned **Melos 8.8.0** dependency +from the current Dart workspace. All release commands use `dart run melos`; +global Melos activation is not required. + +## CLI usage + +Run from a checkout with Node 22+, Flutter 3.47.5 and resolved workspace dependencies: + +```sh +node tools/release/release.mjs --help +node tools/release/release.mjs list +node --test tools/release/*.test.mjs +# Also exercise real Melos versioning and dependency updates in a temporary repo: +RELEASE_INTEGRATION=1 node --test tools/release/*.test.mjs +``` + +`prepare` operates only on a clean `main` exactly matching `origin/main`. It +changes versions/changelogs, runs hooks and validation, and creates a local +commit containing `.github/release-manifest.json`. It does not upload packages. +`push` pushes that commit normally (never with force) and one tag per request. +GitHub may omit push events when more than three tags are pushed together, so +do not replace the individual pushes with `git push --tags`/`--follow-tags`. + +For a local preparation/review, use a separate clean clone on `main`, configure +your Git author, install root dependencies, and run: + +```sh +node tools/release/release.mjs prepare +git show --stat +# Only when ready to start publishing using your authenticated Git remote: +node tools/release/release.mjs push +``` + +For a publication dry run, check out the prepared release commit in a fresh +checkout and use its package tag: + +```sh +node tools/release/release.mjs publish nonstop_cli-v0.0.9 +``` + +Replace the example tag with an actual prepared version. Uploading additionally +requires `--execute` and a matching GitHub Actions tag-push context. Do not run +Melos bootstrap in a publishing checkout. The CLI temporarily resets workspace +resolution using `pubspec_overrides.yaml` containing only `resolution:` so +published dependencies are checked independently. It removes the override even +on failure. The isolated check analyzes `lib`, `bin`, and `test`; example apps +and brick hooks are checked in the workspace during preparation and CI. It uses +`pub get --no-example` and `flutter test --no-pub` to avoid resolving those +separate workspaces while a package is isolated. Run `flutter pub get` at the +root after a local dry run to restore the shared package resolution. + +## Recover a failed release + +Read the automatically created issue first. Its run/job links are authoritative +if the bounded log tail lacks the first error. Failure issues are updated on +failed retries; close them after verifying recovery. + +- **Preparation failed before pushing:** fix the reported checks/configuration + and merge the fix, or run **Prepare pub.dev release → Run workflow** on `main`. + Preparation always starts at current `main`, including queued merges. A + failed local preparation can leave version edits for inspection; retry from + a fresh checkout instead of discarding unrelated work. +- **`main` advanced while preparing:** the normal push is rejected. Rerun the + prepare workflow, which recomputes the release against current `main`. +- **Version commit was pushed but some tags were not:** rerun the preparation + workflow while that commit remains `main`; it reuses the commit without a + second version bump. If a later merge has landed, run **Prepare pub.dev release + → Run workflow** on `main` with `recovery_sha` set to the full release commit + SHA. This uses the release App to recreate only missing tags under the tag + protection rules. For maintainers with permitted Git credentials, the equivalent + local command is: + + ```sh + git fetch origin main --tags + node tools/release/release.mjs push-tags FULL_40_CHARACTER_RELEASE_SHA + ``` + + This verifies that the release is on `origin/main`, preserves existing tags, + and creates only missing tags. Then rerun preparation for the later merge. +- **Publication or OIDC configuration failed:** fix pub.dev/App/environment + settings, then use **Re-run failed jobs** on the original tag-triggered run. + Do not delete/recreate tags, and do not use a branch workflow dispatch to + publish. Successful packages are skipped when their versions already exist. +- **A dependency is not yet on pub.dev:** each dependent waits up to ten minutes + for the exact version in the release commit. Fix/retry the dependency's tag + run first, then rerun the dependent's job. +- **The tagged package needs a source fix:** merge a conventional fix commit to + obtain a new Melos version and tag. Never mutate a previously published version. + +Adding a package requires first publication and the same admin/tag/environment +setup. Discovery is automatic for public `packages/*` and `plugins/*` packages; +an accidentally public nested example/tool fails discovery rather than being +uploaded. diff --git a/pubspec.yaml b/pubspec.yaml index 3ac8e49..d3aa391 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,7 +10,7 @@ dev_dependencies: test: ^1.31.1 coverage: ^1.15.0 flutter_lints: ^6.0.0 - melos: ^8.8.0 + melos: 8.8.0 path: ^1.9.1 pubspec_parse: ^1.3.0 mason_logger: ^0.3.1 @@ -44,7 +44,8 @@ melos: command: version: workspaceChangelog: false - fetchTags: true + # Fetch explicitly in the release CLI, without Melos pulling main mid-run. + fetchTags: false updateGitTagRefs: false linkToCommits: false hooks: diff --git a/test/tooling_test.dart b/test/tooling_test.dart index 0618158..ed7f69f 100644 --- a/test/tooling_test.dart +++ b/test/tooling_test.dart @@ -142,6 +142,51 @@ void main() { }, ); + for (final command in ['diff', 'add']) { + test('version command fails when git $command returns an error', () async { + await write('pubspec.yaml', 'name: sample\nversion: 1.0.0\n'); + await Directory(path.join(root.path, 'lib')).create(); + await version.main( + cliDirectory: root.path, + logger: logger, + runProcess: (_, arguments) async => ProcessResult( + 1, + arguments.first == command ? 2 : 1, + '', + 'git failed', + ), + ); + expect(exitCode, 1); + }); + } + + for (final command in ['pub', 'format', 'diff', 'add']) { + test('bundle command fails when $command returns an error', () async { + final input = await brick('sample'); + final calls = >[]; + await bundles.main( + bundles: [input], + logger: logger, + runProcess: (executable, arguments) async { + calls.add([executable, ...arguments]); + return ProcessResult( + 1, + arguments.first == command + ? 2 + : arguments.first == 'diff' + ? 1 + : 0, + '', + 'command failed', + ); + }, + ); + expect(exitCode, 1); + expect(calls.last[1], command); + expect(calls.where((call) => call[1] == 'commit'), isEmpty); + }); + } + test( 'staging cleans its temporary directory if the source is missing', () async { @@ -163,7 +208,7 @@ void main() { ); test( - 'bundle command formats and commits each changed generated file', + 'bundle command formats and stages each changed generated file', () async { final inputs = [await brick('first'), await brick('second')]; final calls = >[]; @@ -194,11 +239,18 @@ void main() { }, ); expect(exitCode, 0); - expect(calls.first, ['dart', 'pub', 'global', 'activate', 'mason_cli']); + expect(calls.first, [ + 'dart', + 'pub', + 'global', + 'activate', + 'mason_cli', + '0.1.4', + ]); expect(calls.where((call) => call[0] == 'mason'), hasLength(2)); expect( calls.where((call) => call[0] == 'git' && call[1] == 'commit'), - hasLength(2), + isEmpty, ); expect( calls, diff --git a/tools/release/release-failure.test.mjs b/tools/release/release-failure.test.mjs new file mode 100644 index 0000000..9f62175 --- /dev/null +++ b/tools/release/release-failure.test.mjs @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { reportFailure, diagnostic } from '../../.github/scripts/release-failure.mjs'; + +function fixture({ existing = false, diagnosticsFail = false, conclusion = 'failure' } = {}) { + const writes = []; + const rest = { + actions: { listJobsForWorkflowRunAttempt: 'jobs', downloadJobLogsForWorkflowRun: async () => ({ data: 'Error: package validation failed\nAuthorization: Bearer abc123\n' }) }, + checks: { listAnnotations: 'annotations' }, + issues: { + listForRepo: 'issues', + create: async (args) => { writes.push(args); return { data: { html_url: 'https://example.invalid/issue/1' } }; }, + update: async (args) => { writes.push(args); return { data: { html_url: 'https://example.invalid/issue/1' } }; }, + }, + }; + const github = { rest, paginate: async (method) => { + if (method === 'issues') return existing ? [{ number: 1, body: '' }] : []; + if (diagnosticsFail) throw new Error('logs unavailable'); + if (method === 'jobs') return [{ id: 2, name: 'publish', conclusion: 'failure', html_url: 'https://example.invalid/job/2', check_run_url: 'https://example.invalid/check/3', steps: [{ number: 4, name: 'Validate and publish', conclusion: 'failure' }] }]; + return [{ annotation_level: 'failure', message: 'dart pub publish exited with 65' }]; + } }; + const context = { repo: { owner: 'nonstopio', repo: 'flutter_forge' }, payload: { workflow_run: { id: 42, run_attempt: 2, name: 'Publish to pub.dev', conclusion, head_sha: 'abc', head_branch: 'a-v1.0.1', html_url: 'https://example.invalid/run/42' } } }; + return { github, context, core: { warning() {}, notice() {} }, writes }; +} + +test('failure issue includes the failed step, diagnostics, commit and recovery link', async () => { + const f = fixture(); + await reportFailure(f); + assert.equal(f.writes.length, 1); + assert.match(f.writes[0].body, /Validate and publish/); + assert.match(f.writes[0].body, /exited with 65/); + assert.match(f.writes[0].body, /package validation failed/); + assert.match(f.writes[0].body, /RELEASING.md#recover-a-failed-release/); + assert.doesNotMatch(f.writes[0].body, /abc123/); +}); + +test('retry updates the same issue and reopens it', async () => { + const f = fixture({ existing: true }); + await reportFailure(f); + assert.equal(f.writes[0].issue_number, 1); + assert.equal(f.writes[0].state, 'open'); +}); + +test('reporting still opens an issue if startup diagnostics are unavailable', async () => { + const f = fixture({ diagnosticsFail: true, conclusion: 'timed_out' }); + await reportFailure(f); + assert.equal(f.writes.length, 1); + assert.match(f.writes[0].body, /did not return job details/); +}); + +test('success does not create a failure issue', async () => { + const f = fixture({ conclusion: 'success' }); + await reportFailure(f); + assert.equal(f.writes.length, 0); +}); + +test('diagnostics redact common credentials and prevent code fence escapes and mentions', () => { + const text = diagnostic('ghp_secret github_pat_secret eyJabc.def.ghi https://user:password@host/ ``` @team'); + assert.doesNotMatch(text, /secret|password|eyJabc|```|@team/); +}); diff --git a/tools/release/release.mjs b/tools/release/release.mjs new file mode 100644 index 0000000..b6350bf --- /dev/null +++ b/tools/release/release.mjs @@ -0,0 +1,284 @@ +#!/usr/bin/env node +// Repository release CLI. Requires Node 22+ and the resolved Flutter workspace. +import { spawnSync } from 'node:child_process'; +import { existsSync, realpathSync, writeFileSync, unlinkSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const manifestPath = '.github/release-manifest.json'; +export const releaseSubject = 'chore(release): publish packages'; +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +export function run(command, args, cwd = root, capture = false) { + // Resolve the pinned workspace version, independent of global activation. + if (command === 'melos') { command = 'dart'; args = ['run', 'melos', ...args]; } + if (!capture) console.log(`> ${command} ${args.join(' ')}`); + const result = spawnSync(command, args, { + cwd, encoding: 'utf8', stdio: capture ? 'pipe' : 'inherit', + env: { ...process.env, CI: 'true', MELOS_NO_UPDATE_CHECK: 'true' }, + maxBuffer: 20 * 1024 * 1024, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} failed (exit ${result.status}).\n${result.stderr || ''}`); + } + return result.stdout?.trim() || ''; +} + +export function publicPackages(packages, directory) { + const canonical = (value) => existsSync(value) ? realpathSync(value) : value; + return packages.filter((pkg) => !pkg.private).map((pkg) => { + const relative = path.relative(canonical(directory), canonical(pkg.location)).split(path.sep).join('/'); + if (!/^(packages|plugins)\/[a-zA-Z0-9_]+$/.test(relative)) { + throw new Error(`Public package ${pkg.name} is outside packages/* or plugins/*: ${relative}`); + } + if (!/^[a-z][a-z0-9_]*$/.test(pkg.name) || !/^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(pkg.version)) { + throw new Error(`Invalid package name/version: ${pkg.name} ${pkg.version}`); + } + return { name: pkg.name, version: pkg.version, path: relative, tag: `${pkg.name}-v${pkg.version}` }; + }); +} + +export function dependencyOrder(packages, graph) { + const byName = new Map(packages.map((pkg) => [pkg.name, pkg])); + const result = [], done = new Set(), visiting = new Set(); + function visit(pkg) { + if (done.has(pkg.name)) return; + if (visiting.has(pkg.name)) throw new Error(`Dependency cycle at ${pkg.name}`); + visiting.add(pkg.name); + for (const name of graph[pkg.name] || []) if (byName.has(name)) visit(byName.get(name)); + visiting.delete(pkg.name); + done.add(pkg.name); + result.push(pkg); + } + packages.forEach(visit); + return result; +} + +// A 404 alone means unpublished. Network errors must never look like absence. +export async function isPublished(pkg, request = fetch, pause = sleep) { + const url = `https://pub.dev/api/packages/${pkg.name}/versions/${encodeURIComponent(pkg.version)}`; + for (let attempt = 0; attempt < 4; attempt++) { + try { + const response = await request(url, { signal: AbortSignal.timeout(30_000) }); + if (response.status === 404) return false; + if (response.ok) { + const data = await response.json(); + if (data.version !== pkg.version || data.pubspec?.name !== pkg.name) throw new Error(`Unexpected pub.dev response for ${pkg.tag}`); + return true; + } + if (response.status !== 429 && response.status < 500) throw new Error(`pub.dev returned HTTP ${response.status} for ${pkg.tag}`); + if (attempt === 3) throw new Error(`pub.dev returned HTTP ${response.status} for ${pkg.tag}`); + } catch (error) { + if (attempt === 3 || error.message.startsWith('pub.dev returned HTTP') || error.message.startsWith('Unexpected pub.dev')) throw error; + } + await pause(1000 * 2 ** attempt); + } + throw new Error(`Cannot determine publication status of ${pkg.tag}`); +} + +export class Release { + constructor({ directory = root, command = run, published = isPublished, pause = sleep } = {}) { + this.directory = directory; + this.command = command; + this.published = published; + this.pause = pause; + } + exec(command, args, capture = false, directory = this.directory) { + return this.command(command, args, directory, capture); + } + git(...args) { return this.exec('git', args, true); } + packages() { + return publicPackages(JSON.parse(this.exec('melos', ['list', '--json'], true)), this.directory); + } + graph() { return JSON.parse(this.exec('melos', ['list', '--graph'], true)); } + clean() { + if (this.git('status', '--porcelain')) throw new Error('Release requires a clean worktree, including untracked files.'); + } + manifest(commit = 'HEAD') { + const data = JSON.parse(this.git('show', `${commit}:${manifestPath}`)); + if (data.schema !== 1 || !Array.isArray(data.packages) || !/^[a-f0-9]{40}$/.test(data.base)) throw new Error('Invalid release manifest.'); + if (this.git('log', '-1', '--format=%s', commit) !== releaseSubject || this.git('rev-parse', `${commit}^`) !== data.base) { + throw new Error(`${commit} is not the release commit described by its manifest.`); + } + return data; + } + remoteTag(tag) { + const lines = this.git('ls-remote', '--tags', 'origin', `refs/tags/${tag}`, `refs/tags/${tag}^{}`).split('\n'); + return lines.find((line) => line.endsWith('^{}'))?.split('\t')[0] || lines.find((line) => line.endsWith(`refs/tags/${tag}`))?.split('\t')[0]; + } + async prepare() { + this.clean(); + if (this.git('branch', '--show-current') !== 'main') throw new Error('Prepare releases from a clean main checkout.'); + this.exec('git', ['fetch', 'origin', 'main', '--tags']); + const base = this.git('rev-parse', 'HEAD'); + if (base !== this.git('rev-parse', 'origin/main')) throw new Error('main moved. Start again from origin/main; do not force push a release.'); + if (this.git('log', '-1', '--format=%s') === releaseSubject) { + this.manifest(); + console.log('Reusing the existing release commit. Run push to retry missing tags.'); + return; + } + // Do not silently abandon tags if a previous push was interrupted. + if (existsSync(path.join(this.directory, manifestPath))) { + const previous = this.git('log', '-1', '--format=%H', '--', manifestPath); + for (const pkg of this.manifest(previous).packages) { + if (this.remoteTag(pkg.tag) !== previous) throw new Error(`Recover the previous release first: node tools/release/release.mjs push-tags ${previous}`); + } + } + const before = this.packages(); + this.exec('melos', ['version', '--yes', '--no-git-commit-version', ...before.map((pkg) => `--scope=${pkg.name}`)]); + const after = this.packages(); + const changed = after.filter((pkg) => before.find((old) => old.name === pkg.name)?.version !== pkg.version); + if (!changed.length) { + this.clean(); + console.log('No versionable changes. Nothing to release.'); + return; + } + const graph = this.graph(); + const packages = dependencyOrder(changed, graph); + // Hooks must not change another public package without versioning it. + for (const pkg of after.filter((pkg) => !changed.some((item) => item.name === pkg.name))) { + if (this.git('diff', 'HEAD', '--', pkg.path)) throw new Error(`Version hook changed unversioned package ${pkg.name}. Include that change in a versionable commit first.`); + } + for (const pkg of packages) { + if (await this.published(pkg)) throw new Error(`${pkg.tag} already exists on pub.dev. Reconcile main and release tags before versioning.`); + } + this.exec('melos', ['bootstrap', ...packages.map((pkg) => `--scope=${pkg.name}`), '--include-dependencies']); + for (const pkg of packages) this.validate(pkg); + if (this.git('rev-parse', 'HEAD') !== base) throw new Error('A version hook created a commit. Hooks must only stage files.'); + writeFileSync(path.join(this.directory, manifestPath), `${JSON.stringify({ schema: 1, base, packages }, null, 2)}\n`); + // Stage only tracked version/hook edits and the new manifest. Unexpected + // output from a test must not be silently included in a release. + this.exec('git', ['add', '--update']); + this.exec('git', ['add', manifestPath]); + if (this.git('ls-files', '--others', '--exclude-standard')) throw new Error('Release checks created untracked files; inspect them before preparing again.'); + this.exec('git', ['commit', '-m', `${releaseSubject}\n\n${packages.map((pkg) => ` - ${pkg.tag}`).join('\n')}`]); + console.log(`Prepared ${packages.length} package(s). Review the commit, then run push.`); + } + validate(pkg, flutter, standalone = false) { + const directory = path.join(this.directory, pkg.path); + flutter ??= JSON.parse(this.exec('melos', ['list', '--flutter', '--json'], true)).some((item) => item.name === pkg.name); + // Examples and brick hooks are separate workspace packages. They are + // checked during preparation/CI, before opting out of workspace resolution. + const targets = standalone + ? ['lib', 'bin', 'test'].filter((item) => existsSync(path.join(directory, item))) + : [undefined]; + for (const target of targets) this.exec('dart', ['analyze', '--fatal-infos', ...(target ? [target] : [])], false, directory); + if (existsSync(path.join(directory, 'test'))) { + this.exec(flutter ? 'flutter' : 'dart', ['test', ...(standalone && flutter ? ['--no-pub'] : [])], false, directory); + } + } + async push() { + this.clean(); + if (this.git('branch', '--show-current') !== 'main') throw new Error('Push releases from main.'); + if (this.git('log', '-1', '--format=%s') !== releaseSubject) { + console.log('No prepared release to push.'); + return; + } + this.manifest(); + // A normal fast-forward push fails safely if another merge won the race. + this.exec('git', ['-c', 'push.followTags=false', 'push', 'origin', 'HEAD:refs/heads/main']); + await this.pushTags(this.git('rev-parse', 'HEAD')); + } + async pushTags(commit) { + this.clean(); + if (!/^[a-f0-9]{40}$/.test(commit)) throw new Error('push-tags requires a full release commit SHA.'); + this.exec('git', ['fetch', 'origin', 'main']); + this.git('merge-base', '--is-ancestor', commit, 'origin/main'); + const manifest = this.manifest(commit); + for (const pkg of manifest.packages) { + if (!/^[a-z][a-z0-9_]*-v\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(pkg.tag)) throw new Error(`Invalid release tag ${pkg.tag}`); + const remote = this.remoteTag(pkg.tag); + if (remote && remote !== commit) throw new Error(`${pkg.tag} already points to a different commit. Never move a release tag.`); + if (remote) continue; + // Push one ref per request: GitHub suppresses push events for >3 tags. + // Direct refspecs also avoid accidentally pushing unrelated local tags. + this.exec('git', ['-c', 'push.followTags=false', 'push', 'origin', `${commit}:refs/tags/${pkg.tag}`]); + } + } + async publish(tag, execute = false) { + this.clean(); + if (process.env.PUB_HOSTED_URL && process.env.PUB_HOSTED_URL !== 'https://pub.dev') throw new Error('Release publishing requires PUB_HOSTED_URL=https://pub.dev.'); + const packages = this.packages(); + const pkg = packages.find((item) => item.tag === tag); + if (!pkg) throw new Error(`Tag ${tag} does not match a public package version in this checkout.`); + if (execute && (process.env.GITHUB_ACTIONS !== 'true' || process.env.GITHUB_EVENT_NAME !== 'push' || process.env.GITHUB_REF !== `refs/tags/${tag}`)) { + throw new Error('Uploads require a GitHub Actions tag-push run. For first publication use dart pub publish interactively.'); + } + const manifest = this.manifest(); + if (!manifest.packages.some((item) => item.tag === pkg.tag && item.path === pkg.path && item.name === pkg.name && item.version === pkg.version)) throw new Error(`${tag} is absent from this release manifest.`); + this.exec('git', ['fetch', 'origin', 'main']); + this.git('merge-base', '--is-ancestor', 'HEAD', 'origin/main'); + if (await this.published(pkg)) { + console.log(`${pkg.tag} is already published; retry is a no-op.`); + return; + } + // Separate tag jobs may start in any order. Await the exact dependency + // versions from this commit before resolving against the public registry. + const graph = this.graph(); + for (const name of graph[pkg.name] || []) { + const dependency = packages.find((item) => item.name === name); + if (!dependency) throw new Error(`${pkg.name} depends on private workspace package ${name}.`); + let available = false; + for (let attempt = 0; attempt < 60; attempt++) { + if (await this.published(dependency)) { available = true; break; } + console.log(`Waiting for ${dependency.tag} on pub.dev (${attempt + 1}/60)…`); + await this.pause(10_000); + } + if (!available) throw new Error(`Dependency ${dependency.tag} is unavailable after 10 minutes. Resolve its publish failure, then rerun this tag workflow.`); + } + const directory = path.join(this.directory, pkg.path); + const override = path.join(directory, 'pubspec_overrides.yaml'); + if (existsSync(override)) throw new Error('Publish from a fresh checkout without pubspec_overrides.yaml.'); + const flutter = JSON.parse(this.exec('melos', ['list', '--flutter', '--json'], true)).some((item) => item.name === pkg.name); + // Dart's documented opt-out from workspace resolution. No dependency + // overrides: get/analyze/test/publish must resolve this package on pub.dev. + // pub excludes this file from the uploaded archive; the source stays intact. + writeFileSync(override, 'resolution:\n', { flag: 'wx' }); + try { + this.exec(flutter ? 'flutter' : 'dart', ['pub', 'get', '--no-example'], false, directory); + this.validate(pkg, flutter, true); + this.exec('dart', ['pub', 'publish', '--dry-run'], false, directory); + this.clean(); + if (execute) this.exec('dart', ['pub', 'publish', '--force'], false, directory); + else console.log('Dry run passed. Upload is only enabled by --execute in the tag workflow.'); + } finally { + unlinkSync(override); + } + } +} + +export async function main(args) { + const release = new Release(); + const [command, value, flag, ...extra] = args; + if (extra.length) throw new Error('Too many arguments. Use --help.'); + switch (command) { + case 'list': + if (value) throw new Error('list takes no arguments.'); + console.log(JSON.stringify(release.packages(), null, 2)); break; + case 'auth': + if (value) throw new Error('auth takes no arguments.'); + console.log('Configure each package on pub.dev for repository nonstopio/flutter_forge, environment pub.dev, and these tag patterns:'); + for (const pkg of release.packages()) console.log(`${pkg.name}: ${pkg.name}-v{{version}} — https://pub.dev/packages/${pkg.name}/admin`); + console.log('GitHub Actions obtains short-lived OIDC credentials with dart-lang/setup-dart. No pub.dev secret is required. See docs/RELEASING.md.'); break; + case 'prepare': + case 'push': + if (value) throw new Error(`${command} takes no arguments.`); + await release[command](); break; + case 'push-tags': + if (!value || flag) throw new Error('Usage: push-tags '); + await release.pushTags(value); break; + case 'publish': + if (!value || (flag && flag !== '--execute')) throw new Error('Usage: publish [--execute]'); + await release.publish(value, flag === '--execute'); break; + case undefined: + case '--help': + console.log('Usage: node tools/release/release.mjs \n\nlist List public packages and current tags\nauth Print pub.dev setup links and tag patterns\nprepare Version changed packages with Melos, test, commit (main only)\npush Push prepared main commit and each package tag\npush-tags Resume interrupted tag pushes for a release on origin/main\npublish Validate tagged release and dry-run publishing\npublish --execute Upload from a GitHub Actions tag-push run\n\nSetup and recovery: docs/RELEASING.md'); break; + default: throw new Error(`Unknown command: ${command}. Use --help.`); + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(process.argv.slice(2)).catch((error) => { console.error(`Release failed: ${error.message}`); process.exitCode = 1; }); +} diff --git a/tools/release/release.test.mjs b/tools/release/release.test.mjs new file mode 100644 index 0000000..43ffb81 --- /dev/null +++ b/tools/release/release.test.mjs @@ -0,0 +1,232 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, symlinkSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; +import { Release, publicPackages, dependencyOrder, isPublished, manifestPath, releaseSubject, run } from './release.mjs'; + +const pkg = (name, version = '1.0.1') => ({ name, version, path: `packages/${name}`, tag: `${name}-v${version}` }); +const response = (status, packageValue = pkg('a')) => ({ status, ok: status === 200, json: async () => ({ version: packageValue.version, pubspec: { name: packageValue.name } }) }); + +test('discovery excludes private packages and refuses public examples or escaping paths', () => { + const packages = [{ ...pkg('a'), location: '/repo/packages/a', private: false }, { name: 'example', location: '/repo/packages/a/example', private: true }]; + assert.deepEqual(publicPackages(packages, '/repo'), [pkg('a')]); + assert.throws(() => publicPackages([{ ...packages[1], private: false }], '/repo'), /outside/); + assert.throws(() => publicPackages([{ ...packages[0], location: '/outside' }], '/repo'), /outside/); +}); + +test('topological order includes transitive dependencies and rejects cycles', () => { + assert.deepEqual(dependencyOrder([pkg('c'), pkg('a'), pkg('b')], { c: ['b'], b: ['a'] }).map((p) => p.name), ['a', 'b', 'c']); + assert.throws(() => dependencyOrder([pkg('a'), pkg('b')], { a: ['b'], b: ['a'] }), /cycle/); +}); + +test('discovery resolves symlinked checkout paths without allowing packages outside it', (t) => { + const directory = mkdtempSync(path.join(os.tmpdir(), 'forge-release-paths-')); + t.after(() => rmSync(directory, { recursive: true, force: true })); + mkdirSync(path.join(directory, 'repo/packages/a'), { recursive: true }); + symlinkSync(path.join(directory, 'repo'), path.join(directory, 'alias')); + assert.deepEqual(publicPackages([{ ...pkg('a'), private: false, location: path.join(directory, 'repo/packages/a') }], path.join(directory, 'alias')), [pkg('a')]); +}); + +test('pub.dev status distinguishes absence from authentication and service failures', async () => { + assert.equal(await isPublished(pkg('a'), async () => response(404)), false); + assert.equal(await isPublished(pkg('a'), async () => response(200)), true); + await assert.rejects(isPublished(pkg('a'), async () => response(403)), /403/); + let calls = 0; + await assert.rejects(isPublished(pkg('a'), async () => { calls++; return response(503); }, async () => {}), /503/); + assert.equal(calls, 4); + await assert.rejects(isPublished(pkg('a'), async () => response(200, pkg('wrong'))), /Unexpected/); +}); + +test('pub.dev transient errors retry without incorrectly declaring a version unpublished', async () => { + let calls = 0; + assert.equal(await isPublished(pkg('a'), async () => { if (++calls < 3) throw new Error('network'); return response(200); }, async () => {}), true); + assert.equal(calls, 3); +}); + +function fixture(t, packages = [pkg('a'), pkg('b')]) { + const directory = mkdtempSync(path.join(os.tmpdir(), 'forge-release-test-')); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const remote = path.join(directory, 'remote.git'), repo = path.join(directory, 'repo'); + run('git', ['init', '--bare', remote], directory, true); + mkdirSync(repo); + run('git', ['init', '-b', 'main'], repo, true); + const git = (...args) => run('git', args, repo, true); + git('config', 'user.name', 'Release Test'); + git('config', 'user.email', 'release-test@example.invalid'); + git('remote', 'add', 'origin', remote); + mkdirSync(path.join(repo, '.github')); + writeFileSync(path.join(repo, 'README.md'), 'fixture\n'); + writeFileSync(path.join(repo, '.gitignore'), 'pubspec_overrides.yaml\n'); + git('add', '.'); git('commit', '-m', 'feat: initial'); + git('push', '-u', 'origin', 'main'); + const base = git('rev-parse', 'HEAD'); + writeFileSync(path.join(repo, manifestPath), JSON.stringify({ schema: 1, base, packages })); + git('add', '.'); git('commit', '-m', releaseSubject); + const sha = git('rev-parse', 'HEAD'), commands = []; + const release = new Release({ directory: repo, command: (command, args, cwd, capture) => { + commands.push([command, ...args]); + return run(command, args, cwd, command === 'git' || capture); + } }); + return { repo, git, sha, commands, release }; +} + +test('push sends main first and one tag per request; repeated push is idempotent', async (t) => { + const f = fixture(t); + await f.release.push(); + const pushes = f.commands.filter((args) => args.includes('push')); + assert.equal(pushes.length, 3); + assert.equal(pushes[0].at(-1), 'HEAD:refs/heads/main'); + assert.equal(pushes[1].at(-1), `${f.sha}:refs/tags/a-v1.0.1`); + assert.equal(pushes[2].at(-1), `${f.sha}:refs/tags/b-v1.0.1`); + f.commands.length = 0; + await f.release.push(); + assert.equal(f.commands.filter((args) => args.includes('push')).length, 1); +}); + +test('partial tag failure can resume using an older release after another merge', async (t) => { + const f = fixture(t); + f.git('push', 'origin', 'main'); + f.git('push', 'origin', `${f.sha}:refs/tags/a-v1.0.1`); + writeFileSync(path.join(f.repo, 'README.md'), 'another merge\n'); + f.git('add', '.'); f.git('commit', '-m', 'fix: later merge'); f.git('push', 'origin', 'main'); + await f.release.pushTags(f.sha); + assert.equal(f.commands.filter((args) => args.includes('push')).length, 1); + assert.equal(f.release.remoteTag('b-v1.0.1'), f.sha); +}); + +test('conflicting remote tags are never overwritten', async (t) => { + const f = fixture(t); + f.git('push', 'origin', 'main'); + f.git('push', 'origin', `${f.git('rev-parse', 'HEAD^')}:refs/tags/a-v1.0.1`); + await assert.rejects(f.release.pushTags(f.sha), /different commit/); + assert.equal(f.commands.filter((args) => args.includes('push')).length, 0); +}); + +test('untracked files block releases and non-main prepare is refused', async (t) => { + const f = fixture(t); + writeFileSync(path.join(f.repo, 'secret.txt'), 'local data'); + await assert.rejects(f.release.push(), /clean worktree/); + rmSync(path.join(f.repo, 'secret.txt')); + f.git('switch', '-c', 'feature'); + await assert.rejects(f.release.prepare(), /main checkout/); +}); + +test('prepare reuses an already pushed release and does not bump again', async (t) => { + const f = fixture(t); + f.git('push', 'origin', 'main'); + await f.release.prepare(); + assert.equal(f.git('rev-parse', 'HEAD'), f.sha); + assert.equal(f.commands.some((args) => args[0] === 'melos'), false); +}); + +test('prepare refuses to abandon a previous incomplete tag push', async (t) => { + const f = fixture(t); + f.git('push', 'origin', 'main'); + writeFileSync(path.join(f.repo, 'README.md'), 'later merge\n'); + f.git('add', '.'); f.git('commit', '-m', 'fix: later merge'); f.git('push', 'origin', 'main'); + await assert.rejects(f.release.prepare(), /Recover the previous release/); +}); + +test('manifest must belong to its release commit', (t) => { + const f = fixture(t); + assert.equal(f.release.manifest().base, f.git('rev-parse', 'HEAD^')); + f.git('commit', '--allow-empty', '-m', 'fix: ordinary commit'); + assert.throws(() => f.release.manifest(), /not the release commit/); +}); + +function publishFixture(t, { published = async () => false, failTest = false, flutter = false } = {}) { + const f = fixture(t, [pkg('a'), pkg('b')]); + f.git('push', 'origin', 'main'); + mkdirSync(path.join(f.repo, 'packages/a'), { recursive: true }); + mkdirSync(path.join(f.repo, 'packages/b/test'), { recursive: true }); + mkdirSync(path.join(f.repo, 'packages/b/lib'), { recursive: true }); + mkdirSync(path.join(f.repo, 'packages/b/example'), { recursive: true }); + // Empty directories aren't tracked and therefore don't dirty the fixture. + const commands = []; + const release = new Release({ directory: f.repo, published, pause: async () => {}, command: (command, args, cwd, capture) => { + commands.push([command, ...args]); + if (command === 'git') return run(command, args, cwd, true); + if (command === 'melos' && args.includes('--graph')) return JSON.stringify({ b: ['a'] }); + if (command === 'melos' && args.includes('--flutter')) return JSON.stringify(flutter ? [{ name: 'b' }] : []); + if (command === 'melos') return JSON.stringify([pkg('a'), pkg('b')].map((p) => ({ ...p, private: false, location: path.join(f.repo, p.path) }))); + if (['dart', 'flutter'].includes(command) && args[0] === 'pub' && args[1] === 'get') assert.equal(readFileSync(path.join(cwd, 'pubspec_overrides.yaml'), 'utf8'), 'resolution:\n'); + if (failTest && args[0] === 'test') throw new Error('tests failed'); + return ''; + } }); + return { ...f, commands, release }; +} + +test('publish waits for dependencies, checks the package and dry-runs without upload', async (t) => { + let attempts = 0; + const f = publishFixture(t, { published: async (p) => p.name === 'a' && ++attempts >= 3 }); + await f.release.publish('b-v1.0.1'); + assert.equal(attempts, 3); + assert.equal(existsSync(path.join(f.repo, 'packages/b/pubspec_overrides.yaml')), false); + assert.ok(f.commands.some((args) => args.join(' ') === 'dart test')); + assert.ok(f.commands.some((args) => args.join(' ') === 'dart pub publish --dry-run')); + assert.equal(f.commands.some((args) => args.includes('--force') || args.includes('bootstrap')), false); +}); + +test('failed tests and dependency timeout prevent publication', async (t) => { + const f = publishFixture(t, { published: async (p) => p.name === 'a', failTest: true }); + await assert.rejects(f.release.publish('b-v1.0.1'), /tests failed/); + assert.equal(existsSync(path.join(f.repo, 'packages/b/pubspec_overrides.yaml')), false); + assert.equal(f.commands.some((args) => args.includes('publish')), false); + const timeout = publishFixture(t); + await assert.rejects(timeout.release.publish('b-v1.0.1'), /Dependency a-v1.0.1 is unavailable/); + assert.equal(timeout.commands.some((args) => args.includes('publish')), false); +}); + +test('standalone Flutter checks do not reenter example workspace resolution', async (t) => { + const f = publishFixture(t, { published: async (p) => p.name === 'a', flutter: true }); + await f.release.publish('b-v1.0.1'); + const commands = f.commands.filter((args) => ['flutter', 'dart'].includes(args[0])).map((args) => args.join(' ')); + assert.deepEqual(commands, [ + 'flutter pub get --no-example', + 'dart analyze --fatal-infos lib', + 'dart analyze --fatal-infos test', + 'flutter test --no-pub', + 'dart pub publish --dry-run', + ]); + assert.equal(existsSync(path.join(f.repo, 'packages/b/pubspec_overrides.yaml')), false); +}); + +test('already published package is skipped without resolving or publishing', async (t) => { + const f = publishFixture(t, { published: async () => true }); + await f.release.publish('b-v1.0.1'); + assert.equal(f.commands.some((args) => args[0] === 'dart' || args[0] === 'flutter'), false); +}); + +test('wrong tags and uploads outside a tag-push workflow are refused', async (t) => { + const f = publishFixture(t); + await assert.rejects(f.release.publish('b-v2.0.0'), /does not match/); + await assert.rejects(f.release.publish('b-v1.0.1', true), /tag-push run/); +}); + +test('real Melos prepares a changed package and its dependent in one commit', { skip: process.env.RELEASE_INTEGRATION !== '1' }, async (t) => { + const f = fixture(t); + rmSync(path.join(f.repo, manifestPath)); + writeFileSync(path.join(f.repo, '.gitignore'), '.dart_tool/\n.idea/\n*.iml\npubspec_overrides.yaml\n*.lock\n'); + writeFileSync(path.join(f.repo, 'pubspec.yaml'), 'name: release_fixture\npublish_to: none\nenvironment:\n sdk: ">=3.12.0 <4.0.0"\ndev_dependencies:\n melos: 8.8.0\nworkspace:\n - packages/forge_fixture_a\n - packages/forge_fixture_b\nmelos:\n command:\n version:\n workspaceChangelog: false\n fetchTags: false\n'); + for (const name of ['a', 'b']) { + const directory = path.join(f.repo, `packages/forge_fixture_${name}`); + mkdirSync(path.join(directory, 'lib'), { recursive: true }); + writeFileSync(path.join(directory, 'pubspec.yaml'), `name: forge_fixture_${name}\nversion: 0.0.1\nresolution: workspace\nenvironment:\n sdk: ">=3.12.0 <4.0.0"\n${name === 'b' ? 'dependencies:\n forge_fixture_a: ^0.0.1\n' : ''}`); + writeFileSync(path.join(directory, 'CHANGELOG.md'), '## 0.0.1\nInitial version.\n'); + writeFileSync(path.join(directory, `lib/${name}.dart`), 'const value = 1;\n'); + } + f.git('add', '.'); f.git('commit', '-m', 'feat: initial fixture packages'); + for (const name of ['a', 'b']) f.git('tag', `forge_fixture_${name}-v0.0.1`); + f.git('push', 'origin', 'main', '--tags'); + writeFileSync(path.join(f.repo, 'packages/forge_fixture_a/lib/a.dart'), 'const value = 2;\n'); + f.git('add', '.'); f.git('commit', '-m', 'fix(forge_fixture_a): update value'); + f.git('push', 'origin', 'main'); + run('dart', ['pub', 'get'], f.repo, true); + const base = f.git('rev-parse', 'HEAD'); + f.release.published = async () => false; + await f.release.prepare(); + assert.equal(f.git('rev-parse', 'HEAD^'), base); + assert.deepEqual(f.release.manifest().packages.map((p) => [p.name, p.version]), [['forge_fixture_a', '0.0.2'], ['forge_fixture_b', '0.0.2']]); + assert.equal(f.git('status', '--porcelain'), ''); +}); diff --git a/tools/update_nonstop_cli_bundles.dart b/tools/update_nonstop_cli_bundles.dart index 3ac474c..d679e8f 100644 --- a/tools/update_nonstop_cli_bundles.dart +++ b/tools/update_nonstop_cli_bundles.dart @@ -52,7 +52,16 @@ Future main({ exitCode = 0; try { logger.info('Activating mason_cli'); - await runProcess('dart', ['pub', 'global', 'activate', 'mason_cli']); + final activation = await runProcess('dart', [ + 'pub', + 'global', + 'activate', + 'mason_cli', + '0.1.4', + ]); + if (activation.exitCode != 0) { + throw Exception('Failed to activate mason_cli: ${activation.stderr}'); + } int updateCount = 0; for (final bundle in bundles ?? bundlePaths) { logger.info('Bundling ${bundle.fileName}'); @@ -79,22 +88,25 @@ Future main({ // Format the generated file final fullPath = '${bundle.output}/${bundle.fileName}'; - await runProcess('dart', ['format', fullPath]); + final format = await runProcess('dart', ['format', fullPath]); + if (format.exitCode != 0) { + throw Exception('Failed to format $fullPath: ${format.stderr}'); + } // Check if file is modified final isModified = await runProcess('git', ['diff', '--quiet', fullPath]); if (isModified.exitCode == 1) { - logger.info('Committing changes to ${bundle.fileName}'); - await runProcess('git', ['add', fullPath]); - await runProcess('git', [ - 'commit', - '-m', - 'chore(nonstop_cli): update ${bundle.fileName} bundle', - ]); + logger.info('Staging changes to ${bundle.fileName}'); + final stage = await runProcess('git', ['add', fullPath]); + if (stage.exitCode != 0) { + throw Exception('Failed to stage $fullPath: ${stage.stderr}'); + } updateCount++; logger.info('Successfully updated bundles for ${bundle.fileName}'); - } else { + } else if (isModified.exitCode == 0) { logger.info('No changes detected for ${bundle.fileName}'); + } else { + throw Exception('Failed to inspect $fullPath: ${isModified.stderr}'); } } if (updateCount > 0) { diff --git a/tools/update_nonstop_cli_version.dart b/tools/update_nonstop_cli_version.dart index 68acd66..37c9fba 100644 --- a/tools/update_nonstop_cli_version.dart +++ b/tools/update_nonstop_cli_version.dart @@ -46,7 +46,10 @@ const packageVersion = '$version'; ]); if (isModified.exitCode == 1) { logger.info('version.dart has been modified'); - await runProcess('git', ['add', versionFile.path]); + final stage = await runProcess('git', ['add', versionFile.path]); + if (stage.exitCode != 0) { + throw Exception('Failed to stage ${versionFile.path}: ${stage.stderr}'); + } logger.info('${versionFile.path} has been staged'); logger.success( 'Successfully updated version.dart with ' @@ -54,6 +57,9 @@ const packageVersion = '$version'; ); return; } + if (isModified.exitCode != 0) { + throw Exception('Failed to inspect version.dart: ${isModified.stderr}'); + } logger.success('No changes detected'); } catch (e) { logger.err('An error occurred while updating the version $e');