From 35d30fd12208ef193a71b488420cdb9521082e8a Mon Sep 17 00:00:00 2001 From: Ajay Kumar Date: Tue, 22 Sep 2026 01:13:07 +0530 Subject: [PATCH 1/2] feat(release): automate pub.dev publishing --- .github/ISSUE_TEMPLATE/bug_report.yml | 47 ++++ .github/ISSUE_TEMPLATE/config.yml | 8 + .github/ISSUE_TEMPLATE/feature_request.yml | 29 +++ .github/PULL_REQUEST_TEMPLATE.md | 20 ++ .github/dependabot.yml | 12 + .github/scripts/release-failure.mjs | 62 +++++ .github/workflows/publish.yml | 46 ++++ .github/workflows/release-failure.yml | 37 +++ .github/workflows/release-tooling.yml | 25 ++ .github/workflows/release.yml | 59 +++++ CODE_OF_CONDUCT.md | 17 ++ CONTRIBUTING.md | 52 ++++ README.md | 7 + SECURITY.md | 19 ++ docs/RELEASING.md | 233 ++++++++++++++++++ melos.yaml | 4 +- pubspec.yaml | 2 +- tools/release/release-failure.test.mjs | 60 +++++ tools/release/release.mjs | 266 +++++++++++++++++++++ tools/release/release.test.mjs | 213 +++++++++++++++++ tools/update_nonstop_cli_bundles.dart | 32 ++- tools/update_nonstop_cli_version.dart | 8 +- 22 files changed, 1242 insertions(+), 16 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/dependabot.yml create mode 100644 .github/scripts/release-failure.mjs create mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/release-failure.yml create mode 100644 .github/workflows/release-tooling.yml create mode 100644 .github/workflows/release.yml create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 docs/RELEASING.md create mode 100644 tools/release/release-failure.test.mjs create mode 100644 tools/release/release.mjs create mode 100644 tools/release/release.test.mjs 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..2fda2d8 --- /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.12.0' + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.44.0' + cache: true + - name: Install Melos + run: dart pub global activate melos 6.3.3 + - 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..f1cf812 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,59 @@ +name: Prepare pub.dev release + +on: + push: + branches: [main] + workflow_dispatch: + +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.44.0' + cache: true + - name: Install release tools + run: | + dart pub get + dart pub global activate melos 6.3.3 + - 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 + run: node tools/release/release.mjs prepare + - name: Push release commit and individual tags + run: node tools/release/release.mjs push 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..af18820 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,52 @@ +# 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 +dart pub get +dart pub global activate melos 6.3.3 +melos bootstrap --scope=PACKAGE_NAME --include-dependencies +``` + +Replace `PACKAGE_NAME` with the package you are changing. Current release and +NonStop template workflows use Flutter 3.44.0; the older general Dart workflow +uses its own version. Check the relevant workflow when reproducing a CI failure. +Some older packages may need compatibility updates before working on newer SDKs. + +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 256e930..5dd5625 100644 --- a/README.md +++ b/README.md @@ -101,3 +101,10 @@ optional-module configurations. + +## 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..ec99f7d --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,233 @@ +# Automated pub.dev releases + +Merging into `main` starts **Prepare pub.dev release**. Melos 6.3.3 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. + +| 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 +dart pub get +dart pub global activate melos 6.3.3 +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. + +The new release checks use Flutter **3.44.0**, matching the existing NonStop +template workflow. Packages on `origin/main` may have pre-existing SDK/dependency +or analyzer failures (for example, `contact_permission` still declares Dart +`<3.0.0`). Such packages need their compatibility fixes merged before they can +pass this pipeline. The separate dependency/coverage PR is not included here. + +## CLI usage + +Run from a checkout with Node 22+, Flutter 3.44.0 and Melos 6.3.3 on `PATH`: + +```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: local dependency overrides would hide +missing published dependencies. + +## 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, fetch the release SHA and + recover missing tags from a clean checkout with authenticated Git access: + + ```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/melos.yaml b/melos.yaml index 0e84cb6..33edaeb 100644 --- a/melos.yaml +++ b/melos.yaml @@ -14,7 +14,9 @@ packages: command: version: workspaceChangelog: false - fetchTags: true + # The release CLI fetches explicitly and checks the base SHA. Melos 6's + # fetchTags performs a git pull, which could change that base mid-release. + fetchTags: false updateGitTagRefs: false linkToCommits: false hooks: diff --git a/pubspec.yaml b/pubspec.yaml index 6879a77..8fa1d5f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,6 +6,6 @@ environment: dev_dependencies: flutter_lints: ^5.0.0 - melos: ^6.2.0 + melos: 6.3.3 pubspec_parse: ^1.3.0 mason_logger: ^0.3.1 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..fef2cf0 --- /dev/null +++ b/tools/release/release.mjs @@ -0,0 +1,266 @@ +#!/usr/bin/env node +// Repository release CLI. Requires Node 22+, Flutter and Melos 6.3.3. +import { spawnSync } from 'node:child_process'; +import { existsSync, realpathSync, writeFileSync } 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) { + 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) { + const directory = path.join(this.directory, pkg.path); + const flutter = JSON.parse(this.exec('melos', ['list', '--flutter', '--json'], true)).some((item) => item.name === pkg.name); + this.exec('dart', ['analyze', '--fatal-infos'], false, directory); + if (existsSync(path.join(directory, 'test'))) this.exec(flutter ? 'flutter' : 'dart', ['test'], 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); + if (existsSync(path.join(directory, 'pubspec_overrides.yaml'))) throw new Error('Publish from a fresh checkout without pubspec_overrides.yaml or Melos bootstrap.'); + // No bootstrap here: dependency checks must use packages from pub.dev. + this.exec('flutter', ['pub', 'get'], false, directory); + this.validate(pkg); + 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.'); + } +} + +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..05ca684 --- /dev/null +++ b/tools/release/release.test.mjs @@ -0,0 +1,213 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, 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'); + 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 } = {}) { + 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 }); + // 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 '[]'; + if (command === 'melos') return JSON.stringify([pkg('a'), pkg('b')].map((p) => ({ ...p, private: false, location: path.join(f.repo, p.path) }))); + 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.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(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('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\nenvironment:\n sdk: ">=3.0.0 <4.0.0"\ndev_dependencies:\n melos: 6.3.3\n'); + writeFileSync(path.join(f.repo, 'melos.yaml'), 'name: release_fixture\npackages:\n - packages/*\ncommand:\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\nenvironment:\n sdk: ">=3.0.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.1+1'], ['forge_fixture_b', '0.0.1+1']]); + assert.equal(f.git('status', '--porcelain'), ''); +}); diff --git a/tools/update_nonstop_cli_bundles.dart b/tools/update_nonstop_cli_bundles.dart index f089930..e832400 100644 --- a/tools/update_nonstop_cli_bundles.dart +++ b/tools/update_nonstop_cli_bundles.dart @@ -43,7 +43,13 @@ void main() async { final logger = Logger(); try { logger.info('Activating mason_cli'); - await Process.run('dart', ['pub', 'global', 'activate', 'mason_cli']); + final activation = await Process.run( + 'dart', + ['pub', 'global', 'activate', 'mason_cli', '0.1.2'], + ); + if (activation.exitCode != 0) { + throw Exception('Failed to activate mason_cli: ${activation.stderr}'); + } int updateCount = 0; for (final bundle in bundlePaths) { logger.info('Bundling ${bundle.fileName}'); @@ -59,26 +65,26 @@ void main() async { // Format the generated file final fullPath = '${bundle.output}/${bundle.fileName}'; - await Process.run('dart', ['format', fullPath]); + final format = await Process.run('dart', ['format', fullPath]); + if (format.exitCode != 0) { + throw Exception('Failed to format $fullPath: ${format.stderr}'); + } // Check if file is modified final isModified = await Process.run('git', ['diff', '--quiet', fullPath]); if (isModified.exitCode == 1) { - logger.info('Committing changes to ${bundle.fileName}'); - await Process.run('git', ['add', fullPath]); - await Process.run( - 'git', - [ - 'commit', - '-m', - 'chore(nonstop_cli): update ${bundle.fileName} bundle' - ], - ); + logger.info('Staging changes to ${bundle.fileName}'); + final stage = await Process.run('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 bb37f85..ec2d4d7 100644 --- a/tools/update_nonstop_cli_version.dart +++ b/tools/update_nonstop_cli_version.dart @@ -35,7 +35,10 @@ const packageVersion = '$version'; await Process.run('git', ['diff', '--quiet', versionFile.path]); if (isModified.exitCode == 1) { logger.info('version.dart has been modified'); - await Process.run('git', ['add', versionFile.path]); + final stage = await Process.run('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 ' @@ -43,6 +46,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'); From 3e59d880e1053482033bd8a7d74e24149c7bbafd Mon Sep 17 00:00:00 2001 From: Ajay Kumar Date: Tue, 22 Sep 2026 01:41:49 +0530 Subject: [PATCH 2/2] fix(release): isolate Flutter package validation from examples --- docs/RELEASING.md | 7 +++++-- tools/release/release.mjs | 17 ++++++++++++----- tools/release/release.test.mjs | 22 +++++++++++++++++++--- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/docs/RELEASING.md b/docs/RELEASING.md index c8691a8..8055fa1 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -191,8 +191,11 @@ 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. Run `flutter pub get` at the root after a local dry run to restore -the shared package resolution. +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 diff --git a/tools/release/release.mjs b/tools/release/release.mjs index 2884a13..b6350bf 100644 --- a/tools/release/release.mjs +++ b/tools/release/release.mjs @@ -156,11 +156,18 @@ export class Release { 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) { + 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); - this.exec('dart', ['analyze', '--fatal-infos'], false, directory); - if (existsSync(path.join(directory, 'test'))) this.exec(flutter ? 'flutter' : 'dart', ['test'], false, directory); + // 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(); @@ -230,8 +237,8 @@ export class Release { // 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'], false, directory); - this.validate(pkg, flutter); + 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); diff --git a/tools/release/release.test.mjs b/tools/release/release.test.mjs index fc4e21e..43ffb81 100644 --- a/tools/release/release.test.mjs +++ b/tools/release/release.test.mjs @@ -135,20 +135,22 @@ test('manifest must belong to its release commit', (t) => { assert.throws(() => f.release.manifest(), /not the release commit/); }); -function publishFixture(t, { published = async () => false, failTest = false } = {}) { +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 '[]'; + 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 (command === 'dart' && args[0] === 'pub' && args[1] === 'get') assert.equal(readFileSync(path.join(cwd, 'pubspec_overrides.yaml'), 'utf8'), 'resolution:\n'); + 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 ''; } }); @@ -176,6 +178,20 @@ test('failed tests and dependency timeout prevent publication', async (t) => { 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');