diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20e3f21b..abbb740d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,8 @@ jobs: - uses: actions/checkout@v6 - name: verify_package_docs.sh + env: + RELEASE_PLEASE_PR: ${{ github.event_name == 'pull_request' && startsWith(github.head_ref, 'release-please--') && '1' || '' }} run: bash scripts/ci/verify_package_docs.sh - name: Processor support matrix contract @@ -70,6 +72,9 @@ jobs: - name: Verify Release Please manifest ↔ mix.exs @version run: bash scripts/ci/verify_release_manifest_alignment.sh + - name: Verify linked release contract + run: bash scripts/ci/verify_release_contract.sh + release-gate: name: Release gate (${{ matrix.compatibility }}; elixir=${{ matrix.elixir }} otp=${{ matrix.otp }} sigra=${{ matrix.sigra }} opentelemetry=${{ matrix.opentelemetry }}) if: github.event_name != 'schedule' diff --git a/.github/workflows/publish-hex.yml b/.github/workflows/publish-hex.yml index d15c27ea..794660c3 100644 --- a/.github/workflows/publish-hex.yml +++ b/.github/workflows/publish-hex.yml @@ -4,12 +4,13 @@ on: workflow_dispatch: inputs: package: - description: 'Package to publish. Run accrue before accrue_admin when recovering a same-day release.' + description: 'Package to publish. Run accrue before accrue_admin before accrue_portal when recovering a same-day release.' required: true type: choice options: - accrue - accrue_admin + - accrue_portal tag: description: 'Reviewed git tag or commit ref to publish from.' required: true @@ -79,3 +80,35 @@ jobs: env: HEX_API_KEY: ${{ secrets.HEX_API_KEY }} ACCRUE_ADMIN_HEX_RELEASE: ${{ env.ACCRUE_ADMIN_HEX_RELEASE }} + + publish-accrue-portal: + name: Publish accrue_portal recovery + if: ${{ inputs.package == 'accrue_portal' }} + runs-on: ubuntu-24.04 + env: + ACCRUE_PORTAL_HEX_RELEASE: "1" + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.tag }} + - name: Set up BEAM + uses: erlef/setup-beam@v1 + with: + otp-version: '27.0' + elixir-version: '1.18.0' + - name: Install Hex + run: mix local.hex --force + - name: Verify accrue_portal release version + run: grep -n "@version \"${{ inputs.release_version }}\"" accrue_portal/mix.exs + - name: Install accrue_portal deps + run: cd accrue_portal && mix deps.get + - name: Dry run accrue_portal Hex publish + run: cd accrue_portal && mix hex.publish --dry-run + env: + HEX_API_KEY: ${{ secrets.HEX_API_KEY }} + ACCRUE_PORTAL_HEX_RELEASE: ${{ env.ACCRUE_PORTAL_HEX_RELEASE }} + - name: Publish accrue_portal to Hex + run: cd accrue_portal && mix hex.publish --yes + env: + HEX_API_KEY: ${{ secrets.HEX_API_KEY }} + ACCRUE_PORTAL_HEX_RELEASE: ${{ env.ACCRUE_PORTAL_HEX_RELEASE }} diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 4a89a163..068d8d8d 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -163,6 +163,49 @@ jobs: --manifest-file .release-please-manifest.json \ --token "$RELEASE_PLEASE_TOKEN" + release_pr_number="$(gh pr list --repo "$repo" --state open --head "release-please--branches--${target_branch}" --json number --jq '.[0].number // empty')" + if [ -n "$release_pr_number" ]; then + release_branch="$(gh pr view "$release_pr_number" --repo "$repo" --json headRefName --jq '.headRefName')" + git fetch origin "$target_branch" "$release_branch" + git checkout -B "$release_branch" "origin/$release_branch" + + expected_base="$(git rev-parse "origin/${target_branch}")" + actual_base="$(git merge-base HEAD "$expected_base")" + if [ "$actual_base" != "$expected_base" ]; then + git checkout "$target_branch" + git checkout -B "$release_branch" "$expected_base" + git push --force-with-lease origin HEAD:"$release_branch" + + $release_please release-pr \ + --repo-url "$repo" \ + --target-branch "$target_branch" \ + --config-file release-please-config.json \ + --manifest-file .release-please-manifest.json \ + --token "$RELEASE_PLEASE_TOKEN" + + git fetch origin "$release_branch" + git checkout -B "$release_branch" "origin/$release_branch" + fi + + repair_version="$(jq -r '.accrue // empty' .release-please-manifest.json)" + repair_admin_version="$(jq -r '.accrue_admin // empty' .release-please-manifest.json)" + repair_portal_version="$(jq -r '.accrue_portal // empty' .release-please-manifest.json)" + + if [ -n "$repair_version" ] && [ "$repair_version" = "$repair_admin_version" ] && [ "$repair_portal_version" != "$repair_version" ]; then + bash scripts/ci/repair_linked_release_pr.sh --version "$repair_version" + if ! git diff --quiet; then + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add .release-please-manifest.json accrue_portal/mix.exs accrue_portal/CHANGELOG.md + git commit -m "fix: repair linked portal release PR to ${repair_version}" + git push origin HEAD:"$release_branch" + fi + fi + + git checkout "$target_branch" + bash scripts/ci/verify_release_pr_scope.sh --pr "$release_pr_number" + fi + publish-accrue: name: Publish accrue needs: release diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 690b8f4c..b988651b 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -17,6 +17,7 @@ Start the next milestone with `$gsd-new-milestone`. - The next unused planning phase is now **120**. - The linked release-readiness pass remains the next concrete operational follow-up after archival. - Advanced schedules, broader pause/resume promotion, broader preview/proration parity, Hyperwallet reopening, and `FIN-03` stay out of scope unless a later milestone explicitly reopens them. +- Maintenance triage references remain canonical in the archived v1.17 inventory backlog slices: [INT-10](research/v1.17-FRICTION-INVENTORY.md#backlog--int-10-phase-63), [BIL-03](research/v1.17-FRICTION-INVENTORY.md#backlog--bil-03-phase-64), and [ADM-12](research/v1.17-FRICTION-INVENTORY.md#backlog--adm-12-phase-65). --- *Last updated: 2026-05-07 — **v1.37** archived; no active milestone.* diff --git a/.planning/STATE.md b/.planning/STATE.md index 02aba38b..fc858365 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -48,6 +48,8 @@ Last activity: 2026-05-07 — archived `v1.37` roadmap and requirements, collaps - **`.planning/STRATEGY.md`** — active PROC-08 strategic parent. - **`.planning/ROADMAP.md`** — no-active-milestone summary plus recent archives. +- **`.planning/research/v1.17-FRICTION-INVENTORY.md`** — canonical friction inventory for intake-gated maintenance and dated maintainer passes. +- **`.planning/research/v1.17-north-star.md`** — stop-rule and maintenance-triage SSOT for the library-maintenance posture. - **`.planning/milestones/v1.37-ROADMAP.md`** — archived roadmap and milestone narrative for Phases 117–119. - **`.planning/milestones/v1.37-REQUIREMENTS.md`** — archived requirements and traceability for `SCM-01..06`. - **`.planning/milestones/v1.36-ROADMAP.md`** — archived roadmap and milestone narrative for Phases 112–116. @@ -59,6 +61,8 @@ Last activity: 2026-05-07 — archived `v1.37` roadmap and requirements, collaps - **`.planning/research/PITFALLS.md`** — most recent contract-drift and proof-lane risk notes. - **`.planning/research/SUMMARY.md`** — most recent synthesized research summary. +**Triage doctrine (read-only context, v1.17–v1.18):** [North star + stop rules](research/v1.17-north-star.md) · [Friction inventory](research/v1.17-FRICTION-INVENTORY.md) + ## Deferred Items | Category | Item | Status | diff --git a/.release-please-manifest.json b/.release-please-manifest.json index d2e1dc4c..60d4c2ed 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,5 +1,5 @@ { - "accrue": "1.0.0", - "accrue_admin": "1.0.0", - "accrue_portal": "1.0.0" + "accrue": "1.1.0", + "accrue_admin": "1.1.0", + "accrue_portal": "1.1.0" } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 68e340c9..82704782 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,9 +1,10 @@ # Contributing -Thanks for contributing to Accrue. This repository ships two sibling Mix packages: +Thanks for contributing to Accrue. This repository ships three sibling Mix packages: - `accrue/` for the core billing library - `accrue_admin/` for the LiveView admin UI +- `accrue_portal/` for the customer billing portal UI ## Development setup @@ -14,7 +15,7 @@ Install the supported toolchain first: - PostgreSQL 14+ - Node.js for browser UAT in `examples/accrue_host` -Then bootstrap both packages: +Then bootstrap the package suite: ```bash cd accrue @@ -23,6 +24,9 @@ mix deps.get cd ../accrue_admin mix deps.get npm ci + +cd ../accrue_portal +mix deps.get ``` Use the package-local READMEs and guides for host-app wiring, browser UAT, and release-oriented docs checks. @@ -70,6 +74,15 @@ mix hex.build mix hex.publish --dry-run ``` +For `accrue_portal`, use the matching publish-mode dry run: + +```bash +cd accrue_portal +export ACCRUE_PORTAL_HEX_RELEASE=1 +mix hex.build +mix hex.publish --dry-run +``` + For provider-parity checks against Stripe test mode, follow the setup in [`guides/testing-live-stripe.md`](guides/testing-live-stripe.md). That lane is advisory/manual, not part of the required deterministic release gate, and it exists to catch provider-parity drift rather than replace Fake. Please keep real credentials out of shell history and logs. The required deterministic release gate still includes the checked-in trust review artifact, generated drift/docs drift, seeded performance smoke, compatibility floor/target checks, and browser accessibility/responsive checks. Keep webhook secrets, customer data, and PII out of docs, issue templates, screenshots, traces, and copied terminal output. diff --git a/RELEASING.md b/RELEASING.md index 075a768f..de46f821 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,12 +1,12 @@ # Releasing Accrue This runbook is written for the **recurring** maintainer path: linked `accrue` + -`accrue_admin` releases via **Release Please** on a green `main`, followed by ordered -Hex publishes and lightweight post-publish checks. The **same-day `1.0.0`** bootstrap -story is an **exceptional** appendix at the end — read that only when you are -intentionally coordinating a first public major. +`accrue_admin` + `accrue_portal` releases via **Release Please** on a green `main`, +followed by ordered Hex publishes and lightweight post-publish checks. The +**same-day `1.0.0`** bootstrap story is an **exceptional** appendix at the end — +read that only when you are intentionally coordinating a first public major. -**Planning milestones vs Hex SemVer:** files under **`.planning/`** may use labels like **`v1.14`** or **`v1.15`** for internal milestone bookkeeping. Those **do not** replace the **`accrue` / `accrue_admin` `@version`** values in each **`mix.exs`** or the versions published on **Hex**. Consumers pin and upgrade against **Hex + changelogs**; maintainers use this runbook plus **`accrue/guides/upgrade.md`**. +**Planning milestones vs Hex SemVer:** files under **`.planning/`** may use labels like **`v1.14`** or **`v1.15`** for internal milestone bookkeeping. Those **do not** replace the **`accrue` / `accrue_admin` / `accrue_portal` `@version`** values in each **`mix.exs`** or the versions published on **Hex**. Consumers pin and upgrade against **Hex + changelogs**; maintainers use this runbook plus **`accrue/guides/upgrade.md`**. ## Post-1.0 cadence (maintainer intent) @@ -15,7 +15,7 @@ The `1.0.x` line treats the documented public facade as the SemVer boundary: gen 1. **SemVer discipline.** Patch releases carry bug fixes and doc-only changes inside the documented facade. Minor releases carry additive features, optional config, optional adapters, forward-compatible Telemetry events, and soft deprecations. Major releases remove hard-deprecated symbols, change the documented Plug/router contract, introduce breaking schema migrations on `accrue_*` tables, or change the webhook signature verification contract. 2. **Deprecation cycle.** Breaking changes on the documented surface follow a two-step deprecation cycle: first mark the old path in `CHANGELOG.md`, with `@deprecated`, and in `accrue/guides/upgrade.md` without a runtime warning; then hard-deprecate in a later minor with a runtime warning. The replacement path must exist for at least one minor before removal in the next major. 3. **Cadence.** Patch releases land as needed. Minor releases land when a coherent additive batch is ready. Major releases are rare and pre-announced with at least one `2.0.0-rc.N` stabilization window before the stable tag. -4. **Lockstep.** `accrue` and `accrue_admin` continue shipping as a coordinated combined Release Please PR. Major versions stay aligned, even when an admin-only minor leads the core package. +4. **Lockstep.** `accrue`, `accrue_admin`, and `accrue_portal` continue shipping as a coordinated combined Release Please PR. Major versions stay aligned, even when a UI-only minor leads the core package. 5. **Supported integration surface.** `accrue/guides/maturity-and-maintenance.md` is the authoritative list of the public facade, the Telemetry event contract, and the pieces that are explicitly not part of the SemVer boundary. 6. **Verification expectation.** Every release passes the merge-blocking `host-integration` Fake-backed gate. Majors additionally require the `live-stripe` lane green within the release window as maintainer sign-off. 7. **Forward-port policy.** critical security fixes are forward-ported to the latest minor of the previous major for 6 months after a new major ships. Older majors are end-of-life and documented in `accrue/guides/maturity-and-maintenance.md`. @@ -24,7 +24,7 @@ The `1.0.x` line treats the documented public facade as the SemVer boundary: gen 10. **Last verified line.** Update the line below whenever `release-please-config.json`, `.release-please-manifest.json`, or `.github/workflows/release-please.yml` change. **Last verified against** `release-please-config.json`, `.release-please-manifest.json`, -and `.github/workflows/release-please.yml` on **2026-04-23** (UTC). Update this line when +and `.github/workflows/release-please.yml` on **2026-05-07** (UTC). Update this line when automation semantics change. ## Routine linked releases (Release Please + Hex) @@ -32,15 +32,21 @@ automation semantics change. 1. Confirm CI is green on `main`, especially the `release-gate` workflow and the lanes below. 2. Let **Release Please** open the **combined** linked release PR (see `release-please-config.json` — `separate-pull-requests: false` — and `.release-please-manifest.json` for per-package versions). -3. Review the PR: both `accrue/mix.exs` and `accrue_admin/mix.exs` `@version` values match the manifest, - both package-local `CHANGELOG.md` files update, and automation outputs look sane. +3. Review the PR: `accrue/mix.exs`, `accrue_admin/mix.exs`, and `accrue_portal/mix.exs` `@version` + values match the manifest, all three package-local `CHANGELOG.md` files update, and automation outputs look sane. + If the release branch is stale and no longer contains the current `main` tree, reset `release-please--branches--main` + back to `main`, rerun Release Please, and review the regenerated PR before any package-specific repair. + If Release Please leaves `accrue_portal` behind on the release branch, run + `bash scripts/ci/repair_linked_release_pr.sh --version ` on the checked-out release branch, + push the repaired branch, and re-run `bash scripts/ci/verify_release_pr_scope.sh --pr --version `. 4. **Default (primary path):** merge the combined Release Please PR manually on GitHub when required checks are green. Merge after checklist sign-off, **or** after review dispatch **Actions → Release PR automation → Run workflow** with the PR number so auto-merge queues **only** via **`workflow_dispatch`** (not on PR open/sync). Use `scripts/ci/gh_merge_release_pr.sh` if you need the Release Please PR number before dispatching. -5. Confirm Hex package availability for **`accrue`** before relying on **`accrue_admin`** consumers. +5. Confirm Hex package availability for **`accrue`** before relying on **`accrue_admin`** or **`accrue_portal`** consumers. 6. Let `.github/workflows/release-please.yml` publish **`accrue_admin`** when the workflow gates (`needs.release.outputs.*`, `ACCRUE_ADMIN_HEX_RELEASE=1`) say it is safe — **`accrue` publishes first**. -7. Verify HexDocs for both packages, tags, and GitHub releases as appropriate. +7. Let `.github/workflows/release-please.yml` publish **`accrue_portal`** only after the same-workflow gates say both upstream packages are safe. +8. Verify HexDocs for all three packages, tags, and GitHub releases as appropriate. ### Rendro publish handoff @@ -56,16 +62,21 @@ When the invoice renderer depends on a newly published Rendro version, preserve The standard path is `.github/workflows/release-please.yml`: - Release Please runs only on pushes to `main` and manual `workflow_dispatch`. -- `release-please-config.json` uses **one combined release PR** for `accrue` and `accrue_admin` (`separate-pull-requests: false`) so versions and `scripts/ci/verify_package_docs.sh` stay aligned. -- Authoritative package changelogs are only `accrue/CHANGELOG.md` and `accrue_admin/CHANGELOG.md` (the paths wired in `release-please-config.json`); do not add duplicate changelogs under nested directories such as `accrue/accrue/` or `accrue_admin/accrue_admin/`. +- `release-please-config.json` uses **one combined release PR** for `accrue`, `accrue_admin`, and `accrue_portal` (`separate-pull-requests: false`) so versions and `scripts/ci/verify_package_docs.sh` stay aligned. +- Authoritative package changelogs are only `accrue/CHANGELOG.md`, `accrue_admin/CHANGELOG.md`, and `accrue_portal/CHANGELOG.md` (the paths wired in `release-please-config.json`); do not add duplicate changelogs under nested directories such as `accrue/accrue/`, `accrue_admin/accrue_admin/`, or `accrue_portal/accrue_portal/`. - Automated publish is gated by same-workflow outputs: - `needs.release.outputs.accrue_release_created` - `needs.release.outputs.accrue_admin_release_created` + - `needs.release.outputs.accrue_portal_release_created` - `accrue` publishes first. -- `accrue_admin` publishes only after the `accrue` publish job succeeds when both packages release together. +- `accrue_admin` publishes only after the `accrue` publish job succeeds when multiple linked packages release together. +- `accrue_portal` publishes only after the `accrue` publish job succeeds and, when needed, after the `accrue_admin` publish job succeeds in the same workflow. - The `publish-accrue-admin` job in `release-please.yml` declares `needs: [release, publish-accrue]`, which enforces `accrue` before `accrue_admin` for linked Hex publishes. +- The `publish-accrue-portal` job in `release-please.yml` declares `needs: [release, publish-accrue, publish-accrue-admin]`, which preserves `accrue` before `accrue_admin` before `accrue_portal` for linked Hex publishes. - `accrue_admin` dry-run and publish steps export `ACCRUE_ADMIN_HEX_RELEASE=1`. +- `accrue_portal` dry-run and publish steps export `ACCRUE_PORTAL_HEX_RELEASE=1`. - If Release Please creates the **core** GitHub Release but not the admin one in the same run, the workflow **lockstep fallback** still publishes `accrue_admin` when both manifest versions match (same push SHA). +- If Release Please creates the **core** GitHub Release but not the portal one in the same run, the workflow **lockstep fallback** still publishes `accrue_portal` when both manifest versions match (same push SHA). This automation does not publish from `pull_request`, `pull_request_target`, or ordinary branch pushes. @@ -95,9 +106,10 @@ For the provider-parity detail lane, see [guides/testing-live-stripe.md](guides/ ## Release PR review checklist -- The combined release PR updates **both** `accrue/mix.exs` and `accrue_admin/mix.exs` `@version` consistently with `.release-please-manifest.json` before publish jobs run. -- The same PR updates both package-local `CHANGELOG.md` files. -- `accrue` publishes before `accrue_admin`. +- The combined release PR updates `accrue/mix.exs`, `accrue_admin/mix.exs`, and `accrue_portal/mix.exs` `@version` consistently with `.release-please-manifest.json` before publish jobs run. +- The same PR updates all three package-local `CHANGELOG.md` files. +- `accrue` publishes before `accrue_admin` and `accrue_portal`. +- `accrue_portal` publishes after `accrue_admin` when both release in the same run. - `Canonical local demo: Fake` remains the required deterministic gate before release. - The required deterministic gate still includes `security/trust artifact`, `seeded performance smoke`, `compatibility floor/target checks`, and `browser accessibility/responsive checks`. - `Provider parity: Stripe test mode` stays optional/manual and out of the required release lane. @@ -112,7 +124,7 @@ Create both secrets in GitHub before the first release run: 1. Open the repository on GitHub. 2. Go to **Settings** -> **Secrets and variables** -> **Actions**. 3. Add `RELEASE_PLEASE_TOKEN` as a GitHub token that can create release pull requests, push release tags, create GitHub releases, and write pull-request comments for this repository. -4. Add `HEX_API_KEY` as a Hex.pm API key that can publish the `accrue` and `accrue_admin` packages. +4. Add `HEX_API_KEY` as a Hex.pm API key that can publish the `accrue`, `accrue_admin`, and `accrue_portal` packages. 5. Never paste either value into workflow files, docs, commit messages, terminal transcripts, issues, or pull requests. For the first anonymous maintainer release, use the GitHub identity `szTheory` @@ -154,11 +166,11 @@ Keep provider-backed checks out of the required release lane. In real integratio ## Manual fallback -If Release Please dry-run cannot produce a combined release PR when you need one, use the manual fallback only after creating and reviewing a manual release PR that sets both package versions and both package changelogs consistently. +If Release Please dry-run cannot produce a combined release PR when you need one, use the manual fallback only after creating and reviewing a manual release PR that sets all package versions and all package changelogs consistently. Use `.github/workflows/publish-hex.yml` only as a manual fallback or recovery path: -- `package`: choose `accrue` or `accrue_admin` +- `package`: choose `accrue`, `accrue_admin`, or `accrue_portal` - `tag`: reviewed tag or commit ref to publish from - `release_version`: expected version at that ref @@ -167,6 +179,8 @@ Manual fallback order: 1. Publish `accrue`. 2. Confirm Hex availability. 3. Publish `accrue_admin`. +4. Confirm Hex availability. +5. Publish `accrue_portal`. If the current release also includes a Rendro handoff, insert the Rendro proof before publishing Accrue: @@ -177,6 +191,8 @@ If the current release also includes a Rendro handoff, insert the Rendro proof b 5. Publish `accrue`. 6. Confirm Hex availability. 7. Publish `accrue_admin`. +8. Confirm Hex availability. +9. Publish `accrue_portal`. Each recovery run checks out the explicit ref, verifies the package `@version`, runs `mix hex.publish --dry-run`, then runs `mix hex.publish --yes`. The recovery workflow never references `steps.release.outputs[...]`. @@ -184,23 +200,24 @@ Each recovery run checks out the explicit ref, verifies the package `@version`, When the dual publish is not one atomic transaction, prefer the smallest corrective step first: -- **Retry `accrue_admin`** for the **same** version if core `accrue` at **V** is already correct on Hex — token, metadata, or transient CI issues often clear on a focused re-run. +- **Retry `accrue_admin` or `accrue_portal`** for the **same** version if upstream `accrue` at **V** is already correct on Hex — token, metadata, or transient CI issues often clear on a focused re-run. - **`mix hex.publish --revert`** only for a **clear mistake** on **`accrue`** and **only** inside Hex’s short post-publish window; see [Hex immutability / retire FAQ](https://hex.pm/docs/faq). -- **Otherwise** use **`mix hex.retire`** on the bad release and ship a **new paired version** forward (new combined release PR), with changelog honesty about what not to use. -- If **`accrue`** at **V** should not be consumed without admin **V**, document the partial state and follow the retire / forward-fix path rather than leaving a silent half-pair. +- **Otherwise** use **`mix hex.retire`** on the bad release and ship a **new linked version** forward (new combined release PR), with changelog honesty about what not to use. +- If **`accrue`** at **V** should not be consumed without matching admin or portal **V**, document the partial state and follow the retire / forward-fix path rather than leaving a silent partial suite. See [https://hex.pm/docs/faq](https://hex.pm/docs/faq) for revert windows, retirement, and registry semantics. ## Appendix: Same-day `1.0.0` bootstrap (exceptional) Use this section only when you are intentionally coordinating a **first public `1.0.0`** -(or an equivalent historic bootstrap) for **both** packages in one tightly managed window. +(or an equivalent historic bootstrap) for **all three** packages in one tightly managed window. -1. Confirm CI is green on `main`, especially the `release-gate` workflow and the required deterministic gate for both packages. -2. Trigger or merge the **combined** Release Please PR that explicitly carries `Release-As: 1.0.0` for both package paths when needed. The first bootstrap should use Conventional Commits plus the `Release-As: 1.0.0` trailer for both `accrue` and `accrue_admin`. -3. Review the release PR diff and confirm each package shows `@version "1.0.0"` in its `mix.exs` and the package-local changelog updates in `accrue/CHANGELOG.md` and `accrue_admin/CHANGELOG.md`. +1. Confirm CI is green on `main`, especially the `release-gate` workflow and the required deterministic gate for all three packages. +2. Trigger or merge the **combined** Release Please PR that explicitly carries `Release-As: 1.0.0` for all package paths when needed. The first bootstrap should use Conventional Commits plus the `Release-As: 1.0.0` trailer for `accrue`, `accrue_admin`, and `accrue_portal`. +3. Review the release PR diff and confirm each package shows `@version "1.0.0"` in its `mix.exs` and the package-local changelog updates in `accrue/CHANGELOG.md`, `accrue_admin/CHANGELOG.md`, and `accrue_portal/CHANGELOG.md`. 4. Merge the reviewed release PR manually, **or** after checklist sign-off run **Actions → Release PR automation → Run workflow** and enter the PR number so auto-merge queues **only** via **`workflow_dispatch`** (not on PR open/sync). Then let `.github/workflows/release-please.yml` publish `accrue`. You can use `scripts/ci/gh_merge_release_pr.sh` to discover the Release Please PR number before dispatching. 5. Confirm Hex package availability for `accrue` before proceeding. 6. Let `.github/workflows/release-please.yml` publish `accrue_admin` with `ACCRUE_ADMIN_HEX_RELEASE=1`. -7. Verify HexDocs for both packages and confirm `llms.txt` is present in generated docs output. -8. Verify repo health files, package changelogs, and GitHub release notes for both tags. +7. Let `.github/workflows/release-please.yml` publish `accrue_portal` with `ACCRUE_PORTAL_HEX_RELEASE=1`. +8. Verify HexDocs for all three packages and confirm `llms.txt` is present in generated docs output. +9. Verify repo health files, package changelogs, and GitHub release notes for all three tags. diff --git a/accrue/CHANGELOG.md b/accrue/CHANGELOG.md index 369a606e..df8a7dfb 100644 --- a/accrue/CHANGELOG.md +++ b/accrue/CHANGELOG.md @@ -27,6 +27,50 @@ Observability and integrator docs for **Stripe Checkout** on `Accrue.Billing` sh * Extend **`verify_package_docs.sh`** and **`verify_adoption_proof_matrix.sh`** merge-blocking needles for **billing portal** facade literals (**`create_billing_portal_session/2`**, **`[:accrue, :billing, :billing_portal, :create]`**, **`billing-billing-portal-create`** / **`billing_portal_session_facade_test.exs`**) alongside checkout. * Extend **`verify_package_docs.sh`** and **`verify_adoption_proof_matrix.sh`** merge-blocking needles for checkout facade + billing-span literals co-evolving with golden-path docs. +## [1.1.0](https://github.com/szTheory/accrue/compare/accrue-v1.0.0...accrue-v1.1.0) (2026-05-08) + + +### Features + +* **098-01:** implement payment method CRUD for braintree ([717e7d7](https://github.com/szTheory/accrue/commit/717e7d7178e9d835195aef9881ba3ea67a45ac8f)) +* **099-01:** implement canonical refund facade and Braintree callbacks ([9f3d081](https://github.com/szTheory/accrue/commit/9f3d08126c0a2fc6c991c9f96db599c6e9b29fb2)) +* **099-02:** implement braintree refund convergence and narrow proration support ([b1df372](https://github.com/szTheory/accrue/commit/b1df372f2ddba3d111b98ec6750747a25a1295f3)) +* **100-01:** disable braintree portal capability ([47e8ce3](https://github.com/szTheory/accrue/commit/47e8ce34d24b6feefaa940533c186dc86c0506d9)) +* **100-01:** fail cleanly in billing facade on unsupported gateways ([10410e2](https://github.com/szTheory/accrue/commit/10410e2b5c5b2a1006bfe2813b939aba3d171e21)) +* **101-01:** lock local checkout session contract ([e3fc56a](https://github.com/szTheory/accrue/commit/e3fc56ab0cbc336445de19b6fc1e4ef55bf2319a)) +* **101-03:** align braintree local portal adapter contract ([b6791f9](https://github.com/szTheory/accrue/commit/b6791f98bc228afb2ce55c5cb91823b7d5c71dda)) +* **101-08:** wire portal checkout completion pipeline ([ce86b7b](https://github.com/szTheory/accrue/commit/ce86b7bb9352baf48a6fb79dd13da990cf01e450)) +* **102-01:** add local discount mapping domain ([a64d25c](https://github.com/szTheory/accrue/commit/a64d25c08a933595a9861ce75d6a5888294d8ac7)) +* **102-02:** emit discount mapping drift telemetry ([125151a](https://github.com/szTheory/accrue/commit/125151a60a0a06d2ec158f12e5c941a60f028208)) +* **102-02:** enforce braintree discount mappings in subscribe ([a74e931](https://github.com/szTheory/accrue/commit/a74e931ed2101a76eb0262f19babae063a587462)) +* **103-01:** add metering definition and renewal contracts ([c80a83c](https://github.com/szTheory/accrue/commit/c80a83c9fa1ab95187a26afc81303b1b9e698d60)) +* **103-02:** implement metered renewal invoice authoring ([bc77e2d](https://github.com/szTheory/accrue/commit/bc77e2d9f94eb2db65d0292e7a4d83011ef5acc4)) +* **103-03:** implement metered renewal settlement ([6c668b1](https://github.com/szTheory/accrue/commit/6c668b10ed43bf05e0c84dca585a48c7fe98dda1)) +* **103-04:** implement metered renewal backstop telemetry ([aabda22](https://github.com/szTheory/accrue/commit/aabda22831bfd5ecfb8c1618dd3b11f2f74d0ba8)) +* **112-01:** promote bounded customer update contract ([1b4f623](https://github.com/szTheory/accrue/commit/1b4f62352b4dd61d2cddcd6aff7c9eb4cf0ed4ca)) +* **112-02:** promote customer update support truth ([12ae9ac](https://github.com/szTheory/accrue/commit/12ae9ac4fbabafe66970caaa1f095d9606806bd1)) +* **112-03:** add host customer update helper ([a64b259](https://github.com/szTheory/accrue/commit/a64b259efa1f469f564f77b49d1bac80703576f3)) +* **113-01:** promote immediate cancellation support truth ([1c55f1e](https://github.com/szTheory/accrue/commit/1c55f1ef48ebe74a48e2342a2aef62c896290578)) +* **118-01:** promote quantity and item support contract ([3cb03fc](https://github.com/szTheory/accrue/commit/3cb03fc02f184dcc931705eada906ed8cc40b71b)) +* **118-03:** add thin host plan change seam ([3e84edd](https://github.com/szTheory/accrue/commit/3e84edd765c82e7ff5215ab5ac0cc72d9a958f06)) +* **119:** close subscription change support contract ([03e6c9b](https://github.com/szTheory/accrue/commit/03e6c9b9ae2520f9566fba43e3adb3b9513bdf8a)) +* **94-01:** lock processor support posture ([5cedca6](https://github.com/szTheory/accrue/commit/5cedca6741180ce9a4b8b3e0c38e2e29d5f8a1ca)) +* **96-01:** branch subscribe/3 for braintree at processor seam ([85e44f6](https://github.com/szTheory/accrue/commit/85e44f687b952a1b8b50aee60b7f85c62132e459)) +* **processor:** harden phase 95 support contract ([1ca55ec](https://github.com/szTheory/accrue/commit/1ca55ecffacf811e584b9f8ce583ae9a4fa3e9b6)) + + +### Bug Fixes + +* **092-01:** keep package changelogs at package root ([ee8792f](https://github.com/szTheory/accrue/commit/ee8792ff7632252958ae2fee82aa8ba2faeff38c)) +* **098:** close review findings and normalize locks ([e277987](https://github.com/szTheory/accrue/commit/e2779872e11443bf1036c92904159cd66c18dc3a)) +* **098:** support braintree host customer flow ([7b8275d](https://github.com/szTheory/accrue/commit/7b8275d9789e8267008e05958b7c77579238e1ca)) +* **102:** compensate reserved mappings on gateway failure ([e34c795](https://github.com/szTheory/accrue/commit/e34c795f611db52ff7f0bc1a3de81d66048d510a)) +* **102:** release reservations on pre-gateway failures ([a6fbee7](https://github.com/szTheory/accrue/commit/a6fbee75f283ca5a8f8249a7c4a475831a940f42)) +* **102:** reserve discount mappings before braintree create ([22db22c](https://github.com/szTheory/accrue/commit/22db22c76f5b46d277185b4ce29a70a1ba9bd825)) +* **112-02:** lock customer update proof bundle ([29528fa](https://github.com/szTheory/accrue/commit/29528fa292707e3d2710f03bfab60135524d0279)) +* **112:** preserve metadata merge semantics ([63fa708](https://github.com/szTheory/accrue/commit/63fa708cb65ee63e74dd1dc7619c61cf511e392d)) +* **113-01:** lock cancellation semantics behind facade and adapter proof ([fca2dff](https://github.com/szTheory/accrue/commit/fca2dffdf1677a6e87e509856b15d7f1061bb56e)) + ## [0.3.1](https://github.com/szTheory/accrue/compare/accrue-v0.3.0...accrue-v0.3.1) (2026-04-22) ### Miscellaneous Chores diff --git a/accrue/lib/accrue/application.ex b/accrue/lib/accrue/application.ex index 69d72f1a..4a546b11 100644 --- a/accrue/lib/accrue/application.ex +++ b/accrue/lib/accrue/application.ex @@ -275,7 +275,11 @@ defmodule Accrue.Application do end end - defp maybe_warn_legacy_pdf_adapter_without_invoice_adapter(invoice_adapter_explicit?, pdf_adapter, env) do + defp maybe_warn_legacy_pdf_adapter_without_invoice_adapter( + invoice_adapter_explicit?, + pdf_adapter, + env + ) do key = :accrue_invoice_pdf_adapter_migration_warned? cond do diff --git a/accrue/lib/accrue/workers/mailer.ex b/accrue/lib/accrue/workers/mailer.ex index 0d75c787..13443280 100644 --- a/accrue/lib/accrue/workers/mailer.ex +++ b/accrue/lib/accrue/workers/mailer.ex @@ -193,7 +193,8 @@ defmodule Accrue.Workers.Mailer do {:error, %Accrue.Error.PdfDisabled{}} -> append_hosted_url_note(msg, assigns, type) - {:error, %Accrue.Error.InvoiceRendererUnavailable{adapter: Accrue.InvoiceRenderer.ChromicPDF}} -> + {:error, + %Accrue.Error.InvoiceRendererUnavailable{adapter: Accrue.InvoiceRenderer.ChromicPDF}} -> append_hosted_url_note(msg, assigns, type) {:error, reason} -> @@ -226,7 +227,8 @@ defmodule Accrue.Workers.Mailer do {:error, %Accrue.Error.PdfDisabled{}} -> append_hosted_url_note(email, assigns, type) - {:error, %Accrue.Error.InvoiceRendererUnavailable{adapter: Accrue.InvoiceRenderer.ChromicPDF}} -> + {:error, + %Accrue.Error.InvoiceRendererUnavailable{adapter: Accrue.InvoiceRenderer.ChromicPDF}} -> append_hosted_url_note(email, assigns, type) {:error, reason} -> diff --git a/accrue/mix.exs b/accrue/mix.exs index 81e52689..1f3293b4 100644 --- a/accrue/mix.exs +++ b/accrue/mix.exs @@ -1,7 +1,7 @@ defmodule Accrue.MixProject do use Mix.Project - @version "1.0.0" + @version "1.1.0" @source_url "https://github.com/szTheory/accrue" def project do diff --git a/accrue/test/accrue/billing/subscription_cancel_test.exs b/accrue/test/accrue/billing/subscription_cancel_test.exs index 2d190155..cd53e797 100644 --- a/accrue/test/accrue/billing/subscription_cancel_test.exs +++ b/accrue/test/accrue/billing/subscription_cancel_test.exs @@ -155,7 +155,9 @@ defmodule Accrue.Billing.SubscriptionCancelTest do assert error.message =~ "Accrue.Billing.cancel/2" assert error.message =~ "host-owned seam" - reloaded = Repo.preload(Repo.get!(Subscription, subscription.id), :subscription_items, force: true) + reloaded = + Repo.preload(Repo.get!(Subscription, subscription.id), :subscription_items, force: true) + assert reloaded.status == :active refute reloaded.cancel_at_period_end refute Subscription.canceling?(reloaded) diff --git a/accrue/test/accrue/processor/capabilities_test.exs b/accrue/test/accrue/processor/capabilities_test.exs index 4143734f..cca5f83a 100644 --- a/accrue/test/accrue/processor/capabilities_test.exs +++ b/accrue/test/accrue/processor/capabilities_test.exs @@ -72,6 +72,7 @@ defmodule Accrue.Processor.CapabilitiesTest do assert get_in(braintree_caps, [:subscription, :cancel_at_period_end]) == false assert get_in(braintree_caps, [:subscription, :pause]) == false assert Capabilities.support_label([:subscription, :update]) == "all first-party" + assert Capabilities.support_label([:subscription, :swap_plan]) == "official active-subscription-change" @@ -110,7 +111,9 @@ defmodule Accrue.Processor.CapabilitiesTest do "unsupported" assert Capabilities.provider_support_label(:stripe, [:subscription_item, :add]) == "native" - assert Capabilities.provider_support_label(:fake, [:subscription_item, :add]) == "testing/local-only" + + assert Capabilities.provider_support_label(:fake, [:subscription_item, :add]) == + "testing/local-only" assert Capabilities.provider_support_label(:braintree, [:subscription_item, :add]) == "unsupported" diff --git a/accrue/test/accrue/processor/fake_test.exs b/accrue/test/accrue/processor/fake_test.exs index 87459244..c6878303 100644 --- a/accrue/test/accrue/processor/fake_test.exs +++ b/accrue/test/accrue/processor/fake_test.exs @@ -59,7 +59,8 @@ defmodule Accrue.Processor.FakeTest do test "merges the shared first-party attrs into the stored customer" do {:ok, %{id: id}} = Processor.create_customer(%{email: "a@b", name: "Old"}, []) - assert {:ok, %{id: ^id, name: "New", email: "new@example.com", metadata: %{"tier" => "pro"}}} = + assert {:ok, + %{id: ^id, name: "New", email: "new@example.com", metadata: %{"tier" => "pro"}}} = Processor.update_customer( id, %{name: "New", email: "new@example.com", metadata: %{"tier" => "pro"}}, diff --git a/accrue/test/mix/tasks/accrue_install_uat_test.exs b/accrue/test/mix/tasks/accrue_install_uat_test.exs index 29083957..7be2321e 100644 --- a/accrue/test/mix/tasks/accrue_install_uat_test.exs +++ b/accrue/test/mix/tasks/accrue_install_uat_test.exs @@ -70,8 +70,10 @@ defmodule Mix.Tasks.Accrue.InstallUATTest do assert generated =~ "use Accrue.Test" assert generated =~ "# config :accrue, :processor, Accrue.Processor.Fake" assert generated =~ "# config :accrue, :mailer, Accrue.Mailer.Test" + assert generated =~ "# config :accrue, :invoice_pdf_adapter, Accrue.InvoiceRenderer.Test" + refute generated =~ "\nconfig :accrue" Code.compile_string(generated) diff --git a/accrue_admin/CHANGELOG.md b/accrue_admin/CHANGELOG.md index 6b1b5232..5b78cfab 100644 --- a/accrue_admin/CHANGELOG.md +++ b/accrue_admin/CHANGELOG.md @@ -8,6 +8,24 @@ - Webhook replay confirmations, bulk DLQ prompts, and related operator strings now live in `AccrueAdmin.Copy` / `AccrueAdmin.Copy.Locked` (Phase 27). Hosts that snapshot admin flash or HEEx literals should diff package tests when upgrading. +## [1.1.0](https://github.com/szTheory/accrue/compare/accrue_admin-v1.0.0...accrue_admin-v1.1.0) (2026-05-08) + + +### Features + +* **098-02:** add admin payment method operator controls ([03b4bd0](https://github.com/szTheory/accrue/commit/03b4bd0659d6f070d4f4d31aa895559b73007eb9)) +* **099-03:** implement LiveView charge detail refund capabilities ([cb1d44f](https://github.com/szTheory/accrue/commit/cb1d44fd2bd7e75ba0d9df0abf5512d0d2adf617)) +* **118-02:** add admin subscription change actions ([9430313](https://github.com/szTheory/accrue/commit/94303131d5a2b78fa923bb9ac367dc4dde8e1fac)) +* **119:** close subscription change support contract ([03e6c9b](https://github.com/szTheory/accrue/commit/03e6c9b9ae2520f9566fba43e3adb3b9513bdf8a)) + + +### Bug Fixes + +* **092-01:** keep package changelogs at package root ([ee8792f](https://github.com/szTheory/accrue/commit/ee8792ff7632252958ae2fee82aa8ba2faeff38c)) +* **098:** close review findings and normalize locks ([e277987](https://github.com/szTheory/accrue/commit/e2779872e11443bf1036c92904159cd66c18dc3a)) +* **113-02:** gate portal cancellation flows by provider ([d20fdc1](https://github.com/szTheory/accrue/commit/d20fdc1758709ecd7aa0c2b083709f16322868bd)) +* **113:** tighten braintree destructive action gating ([3340b33](https://github.com/szTheory/accrue/commit/3340b3345ae52beb8263e56050e69e9ec6629263)) + ## [0.3.1](https://github.com/szTheory/accrue/compare/accrue_admin-v0.3.0...accrue_admin-v0.3.1) (2026-04-22) ### Bug Fixes diff --git a/accrue_admin/mix.exs b/accrue_admin/mix.exs index cd4bdb28..35d42612 100644 --- a/accrue_admin/mix.exs +++ b/accrue_admin/mix.exs @@ -1,7 +1,7 @@ defmodule AccrueAdmin.MixProject do use Mix.Project - @version "1.0.0" + @version "1.1.0" @source_url "https://github.com/szTheory/accrue" def project do diff --git a/accrue_portal/CHANGELOG.md b/accrue_portal/CHANGELOG.md index 91a9eab3..14d3ca05 100644 --- a/accrue_portal/CHANGELOG.md +++ b/accrue_portal/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## [1.1.0](https://github.com/szTheory/accrue/compare/accrue_portal-v1.0.0...accrue_portal-v1.1.0) (2026-05-08) + + +### Features + +* **101-02:** align portal mount contract ([6ab10c3](https://github.com/szTheory/accrue/commit/6ab10c363ed8f6e91efee94f7e0f2b99b0512518)) +* **101-04:** ship hosted checkout flow ([7941c5d](https://github.com/szTheory/accrue/commit/7941c5d7eb65662ce35f2353c137e88aaca6a675)) +* **101-05:** add customer-scoped subscription portal flows ([e55434b](https://github.com/szTheory/accrue/commit/e55434bf539c6f65b0047368cfacda4babc071bf)) +* **101-06:** finish portal payment method surfaces ([a74de65](https://github.com/szTheory/accrue/commit/a74de65ed808061be56eb151604cea0a415e6f44)) +* **101-07:** lock portal shell proof and braintree test seam ([403deba](https://github.com/szTheory/accrue/commit/403deba5c7e01cb8b6f709d7316bb96c87e2665f)) +* **101-07:** publish portal runtime dependency contract ([9495f88](https://github.com/szTheory/accrue/commit/9495f886aa097f09c112bef64871c724a9c930e9)) +* **101-08:** wire portal checkout completion pipeline ([ce86b7b](https://github.com/szTheory/accrue/commit/ce86b7bb9352baf48a6fb79dd13da990cf01e450)) +* **101-09:** add portal test fixtures and auth assertions ([836880b](https://github.com/szTheory/accrue/commit/836880b6e0fbc75ebb986b6dcdfac7e3787d3da0)) +* **102-03:** add portal promo preview and revalidation ([60111cf](https://github.com/szTheory/accrue/commit/60111cf624f8297b97fe0925d083849353e03aa5)) +* **118-03:** add bounded portal plan change flow ([1d66cb2](https://github.com/szTheory/accrue/commit/1d66cb235587d894ceaa6650ba40fc1256421480)) + + +### Bug Fixes + +* **101-09:** prove portal customer scoping end to end ([fb8315f](https://github.com/szTheory/accrue/commit/fb8315f5f0bd8cbaeb4392db48ab4f45808fc011)) +* **101-10:** prove portal payment and invoice boundaries ([184500a](https://github.com/szTheory/accrue/commit/184500a88daf64adf09697518cf5bf442ecaf701)) +* **113-02:** gate portal cancellation flows by provider ([d20fdc1](https://github.com/szTheory/accrue/commit/d20fdc1758709ecd7aa0c2b083709f16322868bd)) +* **113:** tighten braintree destructive action gating ([3340b33](https://github.com/szTheory/accrue/commit/3340b3345ae52beb8263e56050e69e9ec6629263)) + ## 1.0.0 - Initial `accrue_portal` package with mounted customer portal, local Braintree diff --git a/accrue_portal/README.md b/accrue_portal/README.md index bc755896..f5d50781 100644 --- a/accrue_portal/README.md +++ b/accrue_portal/README.md @@ -7,6 +7,12 @@ It gives host apps a package-owned portal mount for subscriptions, payment methods, invoices, and local checkout flows when a processor uses first-party-local-portal semantics. +> **Hex vs `main`:** The `{:accrue_portal, "~> …"}` line tracks +> `accrue_portal/mix.exs` `@version` on the branch you are reading +> (typically `main` on GitHub). [Hex.pm](https://hex.pm/packages/accrue_portal) +> publishes that train after release; use HexDocs matched to the resolved Hex +> version when you need portal docs tied to published artifacts. + For Braintree, both checkout and billing portal sessions return mounted local URLs from your app, not upstream hosted URLs. diff --git a/accrue_portal/mix.exs b/accrue_portal/mix.exs index 2969cec5..c8d0ae09 100644 --- a/accrue_portal/mix.exs +++ b/accrue_portal/mix.exs @@ -1,7 +1,7 @@ defmodule AccruePortal.MixProject do use Mix.Project - @version "1.0.0" + @version "1.1.0" @source_url "https://github.com/szTheory/accrue" def project do diff --git a/scripts/ci/README.md b/scripts/ci/README.md index debbd266..c7fc1002 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -159,3 +159,24 @@ Stderr lines from `verify_package_docs.sh` are prefixed with `[verify_package_do - `ADOPT-04` — failures on `accrue/guides/first_hour.md` missing `upgrade.md#installer-rerun-behavior` or First Hour structure pins. - `ADOPT-05` — failures on `accrue/guides/troubleshooting.md` (`mix accrue.install --check`), RELEASING/provider-parity phrasing, or other `require_fixed` clusters added in Phase 33. - `ADOPT-06` — failures involving `.github/workflows/ci.yml` (not directly read here but referenced by docs), `CONTRIBUTING.md` UAT wording, or `guides/testing-live-stripe.md` / `RELEASING.md` keys such as `STRIPE_TEST_SECRET_KEY` / `release-gate` / `retain-on-failure`. + +## REL gates (v1.38 linked publish proof) + +| REQ-ID | Primary script(s) or artifact | Package ExUnit (if any) | Phase VERIFICATION owner | +|--------|-------------------------------|-------------------------|--------------------------| +| REL-10 | `scripts/ci/verify_release_pr_scope.sh`; `scripts/ci/repair_linked_release_pr.sh`; `.planning/phases/121-linked-publish-proof-sweep/121-VERIFICATION.md` (`PR_NUMBER`, `TARGET_VERSION`) | — | `.planning/phases/121-linked-publish-proof-sweep/121-VERIFICATION.md` | +| REL-11 | `scripts/ci/capture_linked_release_proof.sh`; `.github/workflows/release-please.yml`; `.planning/phases/121-linked-publish-proof-sweep/121-VERIFICATION.md` (`RUN_ID`) | — | `.planning/phases/121-linked-publish-proof-sweep/121-VERIFICATION.md` | + +### Triage: verify_release_pr_scope.sh + +- `verify_release_pr_scope:` failures mean the live Release Please PR does not satisfy the locked three-package contract. The PR must update `.release-please-manifest.json`, all three package `mix.exs` files, and all three package `CHANGELOG.md` files before merge. +- Use `bash scripts/ci/verify_release_pr_scope.sh --pr [--version ]` before merge. A passing result is the REL-10 pre-merge gate. +- Record the passing identifier pair in `.planning/phases/121-linked-publish-proof-sweep/121-VERIFICATION.md` as `PR_NUMBER:` and `TARGET_VERSION:` and stop using “latest release PR” shortcuts after that. +- If the Release Please branch is stale relative to `main`, force-sync `release-please--branches--main` back to `main`, rerun Release Please, and only then apply the portal repair on the regenerated branch. +- If Release Please leaves `accrue_portal` behind while `accrue` and `accrue_admin` advance together, run `bash scripts/ci/repair_linked_release_pr.sh --version ` on the checked-out release branch and push the repaired branch before merge. + +### Triage: capture_linked_release_proof.sh + +- `capture_linked_release_proof:` failures mean the shipped linked release is not publicly proven yet. The script requires one exact PR number, one exact `TARGET_VERSION`, and one exact Release Please `RUN_ID`. +- Use `bash scripts/ci/capture_linked_release_proof.sh --version --run-id --pr --output .planning/phases/121-linked-publish-proof-sweep/121-VERIFICATION.md` after merge and after the Release Please workflow finishes. +- The ledger is append-only: the script records workflow ordering, git tags, GitHub release URLs, and Hex API truth for `accrue`, `accrue_admin`, and `accrue_portal`. diff --git a/scripts/ci/capture_linked_release_proof.sh b/scripts/ci/capture_linked_release_proof.sh new file mode 100755 index 00000000..96eb0db2 --- /dev/null +++ b/scripts/ci/capture_linked_release_proof.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR=${ROOT_DIR:-$( + cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd +)} + +REPO=${GITHUB_REPOSITORY:-szTheory/accrue} + +fail() { + echo "[capture_linked_release_proof] $*" >&2 + exit 1 +} + +usage() { + cat <<'EOF' +Usage: bash scripts/ci/capture_linked_release_proof.sh --version --run-id --pr --output + +Append a deterministic Phase 121 proof block keyed to one PR number, one target +version, and one Release Please workflow run id. The appended block captures: +- git tags for accrue, accrue_admin, accrue_portal +- GitHub release URLs and publish timestamps +- Hex API latest_version and updated_at for all three packages +- Release Please workflow job conclusions and ordering +EOF +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || fail "$1 is required but not installed" +} + +normalize_pr() { + local value=$1 + + if [[ "$value" =~ ^https?://github\.com/[^/]+/[^/]+/pull/([0-9]+) ]]; then + printf '%s\n' "${BASH_REMATCH[1]}" + elif [[ "$value" =~ ^[0-9]+$ ]]; then + printf '%s\n' "$value" + else + fail "invalid PR identifier: $value" + fi +} + +VERSION="" +RUN_ID="" +PR_ARG="" +OUTPUT="" + +while (($# > 0)); do + case "$1" in + --version) + (($# >= 2)) || fail "--version requires a value" + VERSION=$2 + shift 2 + ;; + --run-id) + (($# >= 2)) || fail "--run-id requires a value" + RUN_ID=$2 + shift 2 + ;; + --pr) + (($# >= 2)) || fail "--pr requires a value" + PR_ARG=$2 + shift 2 + ;; + --output) + (($# >= 2)) || fail "--output requires a value" + OUTPUT=$2 + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "unknown argument: $1" + ;; + esac +done + +[[ -n "$VERSION" ]] || fail "--version is required" +[[ -n "$RUN_ID" ]] || fail "--run-id is required" +[[ -n "$PR_ARG" ]] || fail "--pr is required" +[[ -n "$OUTPUT" ]] || fail "--output is required" + +require_cmd gh +require_cmd jq +require_cmd curl +require_cmd git + +PR_NUMBER=$(normalize_pr "$PR_ARG") +OUTPUT_PATH=$OUTPUT +[[ "$OUTPUT_PATH" = /* ]] || OUTPUT_PATH="$ROOT_DIR/$OUTPUT_PATH" +[[ -f "$OUTPUT_PATH" ]] || fail "output ledger does not exist: $OUTPUT_PATH" + +git -C "$ROOT_DIR" fetch --tags --force origin + +RUN_JSON=$(gh run view "$RUN_ID" --repo "$REPO" --json databaseId,url,workflowName,conclusion,jobs) +RUN_URL=$(jq -r '.url' <<<"$RUN_JSON") +RUN_CONCLUSION=$(jq -r '.conclusion' <<<"$RUN_JSON") +[[ "$RUN_CONCLUSION" == "success" ]] || fail "workflow run $RUN_ID did not succeed (conclusion=$RUN_CONCLUSION)" + +job_names=( "Release Please" "Publish accrue" "Publish accrue_admin" "Publish accrue_portal" ) +job_ids=( "release" "publish-accrue" "publish-accrue-admin" "publish-accrue-portal" ) +job_lines=() + +for i in "${!job_names[@]}"; do + job_name=${job_names[$i]} + job_id=${job_ids[$i]} + job_json=$(jq -c --arg name "$job_name" '.jobs[] | select(.name == $name)' <<<"$RUN_JSON") + [[ -n "$job_json" ]] || fail "workflow run $RUN_ID is missing job: $job_name" + job_conclusion=$(jq -r '.conclusion' <<<"$job_json") + job_started=$(jq -r '.startedAt' <<<"$job_json") + job_completed=$(jq -r '.completedAt' <<<"$job_json") + [[ "$job_conclusion" == "success" ]] || fail "$job_name did not succeed (conclusion=$job_conclusion)" + job_lines+=( "| $job_id | $job_conclusion | $job_started | $job_completed |" ) +done + +release_lines=() +hex_lines=() +tag_lines=() + +for package in accrue accrue_admin accrue_portal; do + tag="${package}-v${VERSION}" + git -C "$ROOT_DIR" rev-parse "$tag" >/dev/null 2>&1 || fail "missing git tag: $tag" + tag_sha=$(git -C "$ROOT_DIR" rev-list -n 1 "$tag") + tag_lines+=( "| $package | $tag | $tag_sha |" ) + + release_json=$(gh release view "$tag" --repo "$REPO" --json tagName,url,publishedAt) + release_url=$(jq -r '.url' <<<"$release_json") + release_published=$(jq -r '.publishedAt' <<<"$release_json") + release_lines+=( "| $package | $tag | $release_url | $release_published |" ) + + hex_json=$(curl -fsSL "https://hex.pm/api/packages/${package}") + hex_version=$(jq -r '.latest_version // empty' <<<"$hex_json") + hex_updated=$(jq -r '.updated_at // empty' <<<"$hex_json") + [[ "$hex_version" == "$VERSION" ]] || + fail "Hex latest_version mismatch for $package: expected $VERSION, found ${hex_version:-}" + hex_lines+=( "| $package | $hex_version | $hex_updated | https://hex.pm/api/packages/$package |" ) +done + +captured_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + +{ + printf '\n### Proof capture %s\n\n' "$captured_at" + printf 'PR_NUMBER: %s\n' "$PR_NUMBER" + printf 'TARGET_VERSION: %s\n' "$VERSION" + printf 'RUN_ID: %s\n\n' "$RUN_ID" + printf 'Workflow run: %s\n\n' "$RUN_URL" + + printf '#### Workflow job ordering\n\n' + printf '| Job | Conclusion | Started | Completed |\n' + printf '|-----|------------|---------|-----------|\n' + for line in "${job_lines[@]}"; do + printf '%s\n' "$line" + done + + printf '\n#### Git tags\n\n' + printf '| Package | Tag | Commit |\n' + printf '|---------|-----|--------|\n' + for line in "${tag_lines[@]}"; do + printf '%s\n' "$line" + done + + printf '\n#### GitHub releases\n\n' + printf '| Package | Tag | Release URL | Published |\n' + printf '|---------|-----|-------------|-----------|\n' + for line in "${release_lines[@]}"; do + printf '%s\n' "$line" + done + + printf '\n#### Hex API truth\n\n' + printf '| Package | latest_version | updated_at | API |\n' + printf '|---------|----------------|------------|-----|\n' + for line in "${hex_lines[@]}"; do + printf '%s\n' "$line" + done +} >>"$OUTPUT_PATH" + +echo "OK: appended linked release proof for PR #$PR_NUMBER version $VERSION run $RUN_ID to $OUTPUT_PATH" diff --git a/scripts/ci/repair_linked_release_pr.sh b/scripts/ci/repair_linked_release_pr.sh new file mode 100755 index 00000000..6d778395 --- /dev/null +++ b/scripts/ci/repair_linked_release_pr.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR=${ROOT_DIR:-$( + cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd +)} + +fail() { + echo "[repair_linked_release_pr] $*" >&2 + exit 1 +} + +usage() { + cat <<'EOF' +Usage: bash scripts/ci/repair_linked_release_pr.sh --version + +Repair the checked-out Release Please branch when the linked three-package +contract drifts and `accrue_portal` is left behind. The script updates: +- .release-please-manifest.json +- accrue_portal/mix.exs +- accrue_portal/CHANGELOG.md +EOF +} + +VERSION="" + +while (($# > 0)); do + case "$1" in + --version) + (($# >= 2)) || fail "--version requires a value" + VERSION=$2 + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "unknown argument: $1" + ;; + esac +done + +[[ -n "$VERSION" ]] || fail "--version is required" +command -v jq >/dev/null 2>&1 || fail "jq is required but not installed" + +MANIFEST="$ROOT_DIR/.release-please-manifest.json" +MIX_FILE="$ROOT_DIR/accrue_portal/mix.exs" +CHANGELOG="$ROOT_DIR/accrue_portal/CHANGELOG.md" + +[[ -f "$MANIFEST" ]] || fail "missing $MANIFEST" +[[ -f "$MIX_FILE" ]] || fail "missing $MIX_FILE" +[[ -f "$CHANGELOG" ]] || fail "missing $CHANGELOG" + +accrue_version=$(jq -r '.accrue // empty' "$MANIFEST") +admin_version=$(jq -r '.accrue_admin // empty' "$MANIFEST") +portal_version=$(jq -r '.accrue_portal // empty' "$MANIFEST") + +[[ -n "$accrue_version" && -n "$admin_version" && -n "$portal_version" ]] || + fail "manifest must contain accrue, accrue_admin, and accrue_portal versions" +[[ "$accrue_version" == "$VERSION" ]] || + fail "expected accrue version $VERSION, found $accrue_version" +[[ "$admin_version" == "$VERSION" ]] || + fail "expected accrue_admin version $VERSION, found $admin_version" + +if [[ "$portal_version" == "$VERSION" ]]; then + echo "No repair needed: accrue_portal is already at $VERSION" + exit 0 +fi + +manifest_tmp=$(mktemp) +jq --arg version "$VERSION" '.accrue_portal = $version' "$MANIFEST" >"$manifest_tmp" +mv "$manifest_tmp" "$MANIFEST" + +mix_tmp=$(mktemp) +sed "0,/^ @version \".*\"/s// @version \"$VERSION\"/" "$MIX_FILE" >"$mix_tmp" +mv "$mix_tmp" "$MIX_FILE" + +if ! grep -Fq "## [$VERSION]" "$CHANGELOG"; then + changelog_tmp=$(mktemp) + release_date=$(date -u +"%Y-%m-%d") + compare_url="https://github.com/szTheory/accrue/compare/accrue_portal-v${portal_version}...accrue_portal-v${VERSION}" + { + head -n 1 "$CHANGELOG" + printf '\n' + printf '## [%s](%s) (%s)\n\n' "$VERSION" "$compare_url" "$release_date" + printf '### Bug Fixes\n\n' + printf '* keep linked portal releases aligned with the locked three-package contract\n\n' + tail -n +2 "$CHANGELOG" + } >"$changelog_tmp" + mv "$changelog_tmp" "$CHANGELOG" +fi + +grep -Fq "\"accrue_portal\": \"$VERSION\"" "$MANIFEST" || + fail "manifest repair failed for accrue_portal" +grep -Fq " @version \"$VERSION\"" "$MIX_FILE" || + fail "mix.exs repair failed for accrue_portal" +grep -Fq "## [$VERSION]" "$CHANGELOG" || + fail "CHANGELOG repair failed for accrue_portal" + +echo "OK: repaired linked release PR checkout so accrue_portal matches $VERSION" diff --git a/scripts/ci/verify_package_docs.sh b/scripts/ci/verify_package_docs.sh index 4c8d32cf..7f7f7375 100755 --- a/scripts/ci/verify_package_docs.sh +++ b/scripts/ci/verify_package_docs.sh @@ -56,10 +56,16 @@ require_any_fixed() { fail "$file is missing all of: $*" } +is_release_please_pr() { + [[ -n "${RELEASE_PLEASE_PR:-}" ]] +} + accrue_version=$(extract_version "$ROOT_DIR/accrue/mix.exs") accrue_admin_version=$(extract_version "$ROOT_DIR/accrue_admin/mix.exs") +accrue_portal_version=$(extract_version "$ROOT_DIR/accrue_portal/mix.exs") [[ "$accrue_version" == "$accrue_admin_version" ]] || fail "package versions diverged" +[[ "$accrue_version" == "$accrue_portal_version" ]] || fail "package versions diverged" first_hour_md="$ROOT_DIR/accrue/guides/first_hour.md" host_readme_md="$ROOT_DIR/examples/accrue_host/README.md" @@ -80,13 +86,13 @@ require_fixed "$host_readme_md" '### Capsule R' require_fixed "$ROOT_DIR/accrue/mix.exs" 'source_ref: "accrue-v#{@version}"' require_fixed "$ROOT_DIR/accrue_admin/mix.exs" 'source_ref: "accrue_admin-v#{@version}"' -require_fixed "$ROOT_DIR/accrue/README.md" "{:accrue, \"~> $accrue_version\"}" require_fixed "$ROOT_DIR/accrue/README.md" '> **Hex vs `main`:**' require_fixed "$ROOT_DIR/accrue/README.md" 'https://hex.pm/packages/accrue' -require_fixed "$ROOT_DIR/accrue_admin/README.md" "{:accrue_admin, \"~> $accrue_admin_version\"}" -require_fixed "$ROOT_DIR/accrue_admin/README.md" "accrue ~> $accrue_version" require_fixed "$ROOT_DIR/accrue_admin/README.md" '> **Hex vs `main`:**' require_fixed "$ROOT_DIR/accrue_admin/README.md" 'https://hex.pm/packages/accrue_admin' +require_fixed "$ROOT_DIR/accrue_portal/README.md" '> **Hex vs `main`:**' +require_fixed "$ROOT_DIR/accrue_portal/README.md" 'https://hex.pm/packages/accrue_portal' +require_fixed "$ROOT_DIR/accrue_portal/mix.exs" 'source_ref: "accrue_portal-v#{@version}"' require_fixed "$ROOT_DIR/accrue/README.md" '[First Hour](guides/first_hour.md)' require_fixed "$ROOT_DIR/accrue/README.md" '[Troubleshooting](guides/troubleshooting.md)' @@ -150,8 +156,6 @@ require_fixed "$ROOT_DIR/examples/accrue_host/README.md" "bash scripts/ci/accrue require_any_fixed "$ROOT_DIR/accrue/guides/first_hour.md" "## 1. First run" "## First run" require_fixed "$ROOT_DIR/accrue/guides/first_hour.md" '> **Hex vs `main`:**' -require_fixed "$ROOT_DIR/accrue/guides/first_hour.md" "{:accrue, \"~> $accrue_version\"}" -require_fixed "$ROOT_DIR/accrue/guides/first_hour.md" "{:accrue_admin, \"~> $accrue_admin_version\"}" require_fixed "$ROOT_DIR/accrue/guides/first_hour.md" "Seeded history" require_fixed "$ROOT_DIR/accrue/guides/first_hour.md" "mix verify" require_fixed "$ROOT_DIR/accrue/guides/first_hour.md" "mix verify.full" @@ -230,11 +234,20 @@ require_fixed "$ROOT_DIR/RELEASING.md" "15-TRUST-REVIEW.md" require_fixed "$ROOT_DIR/RELEASING.md" "HEX_API_KEY" require_fixed "$ROOT_DIR/RELEASING.md" "RELEASE_PLEASE_TOKEN" require_fixed "$ROOT_DIR/RELEASING.md" "release-gate" +require_fixed "$ROOT_DIR/RELEASING.md" 'linked `accrue` +' +require_fixed "$ROOT_DIR/RELEASING.md" '`accrue_admin` + `accrue_portal`' +require_fixed "$ROOT_DIR/RELEASING.md" "publish-accrue-portal" +require_fixed "$ROOT_DIR/RELEASING.md" "ACCRUE_PORTAL_HEX_RELEASE=1" +require_fixed "$ROOT_DIR/RELEASING.md" 'choose `accrue`, `accrue_admin`, or `accrue_portal`' require_fixed "$ROOT_DIR/guides/testing-live-stripe.md" "STRIPE_TEST_SECRET_KEY" require_fixed "$ROOT_DIR/guides/testing-live-stripe.md" "host-integration" require_fixed "$ROOT_DIR/guides/testing-live-stripe.md" "first-party shared-facade surfaces" require_fixed "$ROOT_DIR/guides/testing-live-stripe.md" "mounted-local Braintree side" require_fixed "$ROOT_DIR/CONTRIBUTING.md" 'Node.js for browser UAT in `examples/accrue_host`' +require_fixed "$ROOT_DIR/CONTRIBUTING.md" "three sibling Mix packages" +require_fixed "$ROOT_DIR/CONTRIBUTING.md" '`accrue_portal/` for the customer billing portal UI' +require_fixed "$ROOT_DIR/CONTRIBUTING.md" "cd ../accrue_portal" +require_fixed "$ROOT_DIR/CONTRIBUTING.md" "ACCRUE_PORTAL_HEX_RELEASE=1" require_absent_regex "$ROOT_DIR/RELEASING.md" 'Phase 9 release gate' require_absent_regex "$ROOT_DIR/guides/testing-live-stripe.md" 'primary `test` job' require_absent_regex "$ROOT_DIR/CONTRIBUTING.md" 'Node\.js for browser UAT in `accrue_admin`' @@ -247,9 +260,17 @@ for guide in \ "$ROOT_DIR/accrue/guides/first_hour.md" \ "$ROOT_DIR/accrue/guides/troubleshooting.md"; do require_fixed "$guide" 'config :accrue, :webhook_signing_secrets, %{' - require_fixed "$guide" 'stripe: System.get_env("STRIPE_WEBHOOK_SECRET", "whsec_test_host")' +require_fixed "$guide" 'stripe: System.get_env("STRIPE_WEBHOOK_SECRET", "whsec_test_host")' require_absent_regex "$guide" 'webhook_signing_secret([^s]|$)' done -echo "package docs verified for accrue $accrue_version and accrue_admin $accrue_admin_version" +if ! is_release_please_pr; then + require_fixed "$ROOT_DIR/accrue/README.md" "{:accrue, \"~> $accrue_version\"}" + require_fixed "$ROOT_DIR/accrue_admin/README.md" "{:accrue_admin, \"~> $accrue_admin_version\"}" + require_fixed "$ROOT_DIR/accrue_admin/README.md" "accrue ~> $accrue_version" + require_fixed "$ROOT_DIR/accrue/guides/first_hour.md" "{:accrue, \"~> $accrue_version\"}" + require_fixed "$ROOT_DIR/accrue/guides/first_hour.md" "{:accrue_admin, \"~> $accrue_admin_version\"}" +fi + +echo "package docs verified for accrue $accrue_version, accrue_admin $accrue_admin_version, and accrue_portal $accrue_portal_version" echo "fixed invariants checked: README.md, RELEASING.md, CONTRIBUTING.md, quickstart.md, 15-TRUST-REVIEW.md, STRIPE_TEST_SECRET_KEY, release-gate, host-integration, retain-on-failure, only-on-failure, First run, Seeded history, mix verify, mix verify.full" diff --git a/scripts/ci/verify_release_contract.sh b/scripts/ci/verify_release_contract.sh new file mode 100755 index 00000000..5a260bab --- /dev/null +++ b/scripts/ci/verify_release_contract.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR=${ROOT_DIR:-$( + cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd +)} + +fail() { + echo "[verify_release_contract] $*" >&2 + exit 1 +} + +require_fixed() { + local file=$1 + local needle=$2 + + grep -Fq "$needle" "$file" || fail "$file is missing: $needle" +} + +require_regex() { + local file=$1 + local pattern=$2 + + grep -Eq "$pattern" "$file" || fail "$file does not match: $pattern" +} + +command -v jq >/dev/null 2>&1 || fail "jq is required but not installed" + +config="$ROOT_DIR/release-please-config.json" +manifest="$ROOT_DIR/.release-please-manifest.json" +releasing="$ROOT_DIR/RELEASING.md" +release_workflow="$ROOT_DIR/.github/workflows/release-please.yml" +recovery_workflow="$ROOT_DIR/.github/workflows/publish-hex.yml" + +components=$(jq -r '.plugins[] | select(.type == "linked-versions") | .components | join(",")' "$config") +manifest_components=$(jq -r 'keys | join(",")' "$manifest") + +[[ "$components" == "accrue,accrue_admin,accrue_portal" ]] || + fail "unexpected linked release scope: $components" +[[ "$manifest_components" == "$components" ]] || + fail "manifest keys drifted from linked release scope: manifest=$manifest_components config=$components" + +require_fixed "$releasing" 'linked `accrue` +' +require_fixed "$releasing" '`accrue_admin` + `accrue_portal` releases via **Release Please**' +require_fixed "$releasing" '`accrue`, `accrue_admin`, and `accrue_portal` continue shipping as a coordinated combined Release Please PR.' +require_fixed "$releasing" 'publish-accrue-portal' +require_fixed "$releasing" 'ACCRUE_PORTAL_HEX_RELEASE=1' +require_fixed "$releasing" 'choose `accrue`, `accrue_admin`, or `accrue_portal`' +require_fixed "$releasing" 'Publish `accrue_portal`.' +require_fixed "$releasing" 'repair_linked_release_pr.sh' + +require_fixed "$release_workflow" 'accrue_portal_release_created' +require_fixed "$release_workflow" 'publish-accrue-portal:' +require_fixed "$release_workflow" 'needs: [release, publish-accrue, publish-accrue-admin]' +require_fixed "$release_workflow" 'ACCRUE_PORTAL_HEX_RELEASE: "1"' +require_fixed "$release_workflow" 'cd accrue_portal && mix hex.publish --dry-run' +require_fixed "$release_workflow" 'cd accrue_portal && mix hex.publish --yes' +require_fixed "$release_workflow" 'git push --force-with-lease origin HEAD:"$release_branch"' +require_fixed "$release_workflow" 'bash scripts/ci/repair_linked_release_pr.sh --version "$repair_version"' +require_fixed "$release_workflow" 'bash scripts/ci/verify_release_pr_scope.sh --pr "$release_pr_number"' + +require_fixed "$recovery_workflow" "Run accrue before accrue_admin before accrue_portal" +require_regex "$recovery_workflow" 'options:\s*$' +require_fixed "$recovery_workflow" ' - accrue_portal' +require_fixed "$recovery_workflow" "if: \${{ inputs.package == 'accrue_portal' }}" +require_fixed "$recovery_workflow" 'ACCRUE_PORTAL_HEX_RELEASE: "1"' +require_fixed "$recovery_workflow" 'grep -n "@version \"${{ inputs.release_version }}\"" accrue_portal/mix.exs' +require_fixed "$recovery_workflow" 'cd accrue_portal && mix hex.publish --dry-run' +require_fixed "$recovery_workflow" 'cd accrue_portal && mix hex.publish --yes' + +require_fixed "$ROOT_DIR/scripts/ci/README.md" 'repair_linked_release_pr.sh' + +echo "OK: linked release contract aligned for accrue/accrue_admin/accrue_portal" diff --git a/scripts/ci/verify_release_pr_scope.sh b/scripts/ci/verify_release_pr_scope.sh new file mode 100755 index 00000000..cd52b7e1 --- /dev/null +++ b/scripts/ci/verify_release_pr_scope.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR=${ROOT_DIR:-$( + cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd +)} + +REPO=${GITHUB_REPOSITORY:-szTheory/accrue} + +fail() { + echo "[verify_release_pr_scope] $*" >&2 + exit 1 +} + +usage() { + cat <<'EOF' +Usage: bash scripts/ci/verify_release_pr_scope.sh --pr [--version ] + +Verify that a Release Please PR matches the locked three-package release contract: +- .release-please-manifest.json +- accrue/mix.exs and accrue/CHANGELOG.md +- accrue_admin/mix.exs and accrue_admin/CHANGELOG.md +- accrue_portal/mix.exs and accrue_portal/CHANGELOG.md + +If --version is provided, the script also proves the PR head carries that exact +version in the manifest, all three mix.exs files, and all three changelog files. +EOF +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || fail "$1 is required but not installed" +} + +normalize_pr() { + local value=$1 + + if [[ "$value" =~ ^https?://github\.com/[^/]+/[^/]+/pull/([0-9]+) ]]; then + printf '%s\n' "${BASH_REMATCH[1]}" + elif [[ "$value" =~ ^[0-9]+$ ]]; then + printf '%s\n' "$value" + else + fail "invalid PR identifier: $value" + fi +} + +decode_content() { + local payload + + payload=$(jq -r '.content' | tr -d '\n') + if printf '%s' "$payload" | base64 --decode >/dev/null 2>&1; then + printf '%s' "$payload" | base64 --decode + else + printf '%s' "$payload" | base64 -D + fi +} + +fetch_pr_file() { + local ref=$1 + local path=$2 + + gh api "repos/$REPO/contents/$path?ref=$ref" | decode_content +} + +PR_ARG="" +TARGET_VERSION="" + +while (($# > 0)); do + case "$1" in + --pr) + (($# >= 2)) || fail "--pr requires a value" + PR_ARG=$2 + shift 2 + ;; + --version) + (($# >= 2)) || fail "--version requires a value" + TARGET_VERSION=$2 + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "unknown argument: $1" + ;; + esac +done + +[[ -n "$PR_ARG" ]] || { + usage + fail "--pr is required" +} + +require_cmd gh +require_cmd jq +require_cmd base64 + +PR_NUMBER=$(normalize_pr "$PR_ARG") +PR_JSON=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json number,url,state,mergeStateStatus,headRefOid) +PR_STATE=$(jq -r '.state' <<<"$PR_JSON") +PR_URL=$(jq -r '.url' <<<"$PR_JSON") +PR_MERGE_STATE=$(jq -r '.mergeStateStatus' <<<"$PR_JSON") +HEAD_SHA=$(jq -r '.headRefOid' <<<"$PR_JSON") + +[[ -n "$HEAD_SHA" && "$HEAD_SHA" != "null" ]] || fail "could not determine PR head sha for #$PR_NUMBER" + +FILES_JSON=$(gh api "repos/$REPO/pulls/$PR_NUMBER/files?per_page=100") +FILE_PATHS=$(jq -r '.[].filename' <<<"$FILES_JSON") + +required_files=( + ".release-please-manifest.json" + "accrue/mix.exs" + "accrue/CHANGELOG.md" + "accrue_admin/mix.exs" + "accrue_admin/CHANGELOG.md" + "accrue_portal/mix.exs" + "accrue_portal/CHANGELOG.md" +) + +for required in "${required_files[@]}"; do + grep -Fxq "$required" <<<"$FILE_PATHS" || fail "PR #$PR_NUMBER is missing required release file: $required" +done + +manifest=$(fetch_pr_file "$HEAD_SHA" ".release-please-manifest.json") +for package in accrue accrue_admin accrue_portal; do + version=$(jq -r --arg pkg "$package" '.[$pkg] // empty' <<<"$manifest") + [[ -n "$version" ]] || fail "manifest at PR head is missing $package" + if [[ -n "$TARGET_VERSION" && "$version" != "$TARGET_VERSION" ]]; then + fail "manifest version mismatch for $package: expected $TARGET_VERSION, found $version" + fi +done + +for package in accrue accrue_admin accrue_portal; do + mix_contents=$(fetch_pr_file "$HEAD_SHA" "$package/mix.exs") + mix_version=$(sed -n 's/^ @version "\([^"]*\)"/\1/p' <<<"$mix_contents" | head -n 1) + [[ -n "$mix_version" ]] || fail "could not parse @version from $package/mix.exs at PR head" + + changelog=$(fetch_pr_file "$HEAD_SHA" "$package/CHANGELOG.md") + grep -Fq "## [" <<<"$changelog" || fail "$package/CHANGELOG.md does not contain any release headings" + + if [[ -n "$TARGET_VERSION" ]]; then + [[ "$mix_version" == "$TARGET_VERSION" ]] || + fail "$package/mix.exs version mismatch: expected $TARGET_VERSION, found $mix_version" + grep -Fq "## [$TARGET_VERSION]" <<<"$changelog" || + fail "$package/CHANGELOG.md does not contain release heading for $TARGET_VERSION" + fi +done + +echo "OK: Release PR #$PR_NUMBER ($PR_URL) matches the three-package contract" +echo "State: $PR_STATE" +echo "Merge state: $PR_MERGE_STATE" +if [[ -n "$TARGET_VERSION" ]]; then + echo "Target version: $TARGET_VERSION" +fi