diff --git a/.github/workflows/chrome-web-store-status.yml b/.github/workflows/chrome-web-store-status.yml index 6169e7e9..698d5339 100644 --- a/.github/workflows/chrome-web-store-status.yml +++ b/.github/workflows/chrome-web-store-status.yml @@ -38,10 +38,34 @@ jobs: node-version: 22.13.0 - name: Check Chrome Web Store status - run: node scripts/check-chrome-web-store-status.mjs + shell: bash + run: | + set +e + status_output="$(node scripts/check-chrome-web-store-status.mjs 2>&1)" + status_code=$? + set -e + + if [[ $status_code -eq 0 ]]; then + printf '%s\n' "$status_output" + exit 0 + fi + + if [[ "$GITHUB_EVENT_NAME" == "schedule" && "$CWS_REQUIRE_PUBLISHED" == "false" ]] && + [[ "$status_output" == "Missing Chrome Web Store credentials."* ]]; then + echo "::notice::Chrome Web Store status check skipped because credentials are not configured. See #59." + echo "### Chrome Web Store status check skipped" >> "$GITHUB_STEP_SUMMARY" + echo "Credentials are not configured, so the non-strict scheduled status check did not run. See #59." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + printf '%s\n' "$status_output" >&2 + exit "$status_code" env: CWS_EXTENSION_ID: nfnbhekccajjfgkppolomflaeledoccb CWS_EXPECTED_VERSION: ${{ inputs.expected_version }} CWS_REQUIRE_PUBLISHED: ${{ inputs.require_published || 'false' }} CWS_PUBLISHER_ID: ${{ vars.CWS_PUBLISHER_ID }} CWS_SERVICE_ACCOUNT_JSON: ${{ secrets.CWS_SERVICE_ACCOUNT_JSON }} + CWS_CLIENT_ID: ${{ secrets.CWS_CLIENT_ID }} + CWS_CLIENT_SECRET: ${{ secrets.CWS_CLIENT_SECRET }} + CWS_REFRESH_TOKEN: ${{ secrets.CWS_REFRESH_TOKEN }} diff --git a/docs/PUBLICATION_READINESS.md b/docs/PUBLICATION_READINESS.md index f5182fb5..e2e72689 100644 --- a/docs/PUBLICATION_READINESS.md +++ b/docs/PUBLICATION_READINESS.md @@ -26,12 +26,16 @@ durable full-year, or stable-release claims. 2026-07-06 records item ID `nfnbhekccajjfgkppolomflaeledoccb`, item name `ComplyEaze Pack: GSTR-1/GSTR-3B Downloader`, version `0.3.2`, and visibility `Public`. -- Repository source is the pre-1.0 `v0.5.0` beta release. The current - Store-published package evidence is for `v0.3.2` only. Workflow run - `29507382500` built and verified the exact `v0.4.0` package and uploaded it - with Store state `SUCCEEDED`, but publish returned HTTP 400 because dashboard - requirements were incomplete. The pending ZIP SHA-256 is +- The past `v0.4.0` release workflow run `29507382500` built and verified the + exact package and uploaded it with Store state `SUCCEEDED`, but publish + returned HTTP 400 because dashboard requirements were incomplete. It was not + published. The historical pending ZIP SHA-256 is `6ee4be24cafbe15db69275cac4da6b212f3de49b0f747eb9909eed7d293347c6`. +- Repository source and the GitHub release are the pre-1.0 `v0.5.0` beta, + published as a pre-release. The Chrome Web Store package for `v0.5.0` is + submitted and in review as a draft; it is not approved, published, or live. + `v0.3.2` remains the last confirmed Store publication and the only basis for + current Store-published public claims. - The `v0.5.0` beta does not expand Store-facing, durable full-year, or universal live-period/format claims. Synthetic regression coverage of target binding, artifact selection, and recovery is not a substitute for the @@ -104,22 +108,35 @@ durable full-year, or stable-release claims. ## Chrome Web Store Checklist +Every checked item below records at least one verifiable source path, workflow +run identifier, or dated observation. Unevidenced claims stay unchecked. + ### Done In Source -- [x] Manifest V3. -- [x] Exact GST host permission allow-list only. +- [x] Manifest V3 is asserted by `scripts/verify-extension-browser.mjs`. +- [x] Exact GST host permission allow-list only, defined in + `src/extension/manifest-policy.ts`. - [x] No ``, cookies, history, webRequest, debugger/CDP, - nativeMessaging, tabs, identity, or externally_connectable in any Pack build. -- [x] Restrictive extension CSP. -- [x] No remote executable code allowed by package verifier. -- [x] No extension analytics SDK. -- [x] Synthetic reviewer demo exists. + nativeMessaging, tabs, identity, or externally_connectable in any Pack + build; `scripts/verify-extension-package.mjs` enforces the package boundary. +- [x] Restrictive extension CSP, defined in + `src/extension/manifest-policy.ts`. +- [x] No remote executable code allowed by + `scripts/verify-extension-package.mjs`. +- [x] No extension analytics SDK; packaged markers are rejected by + `scripts/verify-extension-package.mjs`. +- [x] Synthetic reviewer demo exists in `src/background/synthetic-demo.ts`. - [x] Reviewer instructions exist in `docs/CHROME_REVIEWER_TEST.md`. -- [x] Manifest icons are present in source and verified in the built package. -- [x] Manifest homepage URL points to `https://pack.complyeaze.com/gst`. -- [x] Protected Chrome Web Store workflow exists for future release updates. +- [x] Manifest icons are defined in `src/extension/manifest-policy.ts` and + verified in the built package by `scripts/verify-extension-package.mjs`. +- [x] Manifest homepage URL is defined in + `src/extension/manifest-policy.ts` and points to + `https://pack.complyeaze.com/gst`. +- [x] Protected Chrome Web Store workflow exists for future release updates at + `.github/workflows/chrome-web-store.yml`. - [x] Protected Chrome Web Store status monitor exists for post-submit - review/publication checks without upload or publish side effects. + review/publication checks without upload or publish side effects at + `.github/workflows/chrome-web-store-status.yml`. ### Must Complete Before Future Store Updates Or Broader Store Claims @@ -135,17 +152,22 @@ durable full-year, or stable-release claims. - [ ] Legal review of live GST Portal terms against exact extension behaviour. - [ ] Privacy Policy, Terms, support URL, source URL, and limited-use statement are live and accessible without login. -- [ ] Chrome privacy declarations match the exact final build. For `v0.4.0`, - disclose personally identifiable information, financial and payment - information, and website content because Chrome counts local processing. - Keep authentication information, web history, and user activity - unchecked unless runtime behavior changes. +- [ ] Chrome privacy declarations match the exact final build. Disclose + personally identifiable information, financial and payment information, + and website content because Chrome counts local processing. Keep + authentication information, web history, and user activity unchecked + unless runtime behavior changes. - [x] Source-controlled Store screenshots and promotional images use only - synthetic/redacted data. The seven `v0.4.0` PNG exports were generated - from refreshed source SVGs, visually checked, and recorded in + synthetic/redacted data. The seven PNG exports prepared for the past + `v0.4.0` dashboard update were generated from refreshed source SVGs, + visually checked, and recorded in `docs/chrome-web-store/assets/exports/asset-hashes.json`. -- [ ] Upload the generated `v0.4.0` Store screenshot/promotional PNGs to the - Chrome Web Store dashboard, then record dashboard image review state. +- [ ] The regenerated Store exports are visually reviewed by a maintainer and + bound to the exact `v0.5.0` submission. Source/export hash agreement alone + does not satisfy this review. +- [ ] Upload the generated Store screenshot/promotional PNGs to the Chrome Web + Store dashboard, then record dashboard image review state for the exact + submitted release. - [ ] Exact ZIP tested in a clean Chrome profile. - [ ] Exact ZIP tested against the live GSTR-3B and GSTR-1 flows by an authorised user. @@ -156,19 +178,65 @@ durable full-year, or stable-release claims. cookies, credentials, OTP, or CAPTCHA data. - [ ] Authorised live full fiscal year run reconciles every eligible target as downloaded, positively not filed, blocked, or failed in the local ledger. -- [ ] Authorised live full fiscal year recovery matrix records every currently - offered return/artifact selection as exercised or as explicitly expected - fail-closed/not applicable when it cannot reach an acquisition checkpoint. - For each acquisition-capable selection, cover service-worker restart, - browser restart, interrupted download, cancellation/discard and its - cleanup outcome, and a retained checkpoint whose browser record is no - longer available. A resumed path must not repeat a - completed target; an unproven path must remain non-complete until retry - or cancellation. Manual observation may be recorded only as an explicit - non-completing action that still requires retry before ZIP staging. Record - category/state summaries only—never raw portal URLs, - filenames, download IDs, page or DOM text, local paths, or taxpayer/session - data. +- [ ] The authorised live full fiscal year recovery matrix below is complete: + every observation matches a completion-eligible row in the cell legend, + and every recorded date is valid and no later than the current UTC date. + +The selection rows are derived from the supported Cartesian product of +`FILED_RETURNS_RETURN_TYPES` and `FILED_RETURNS_ARTIFACT_TYPES`; a test keeps +this instrument aligned with those canonical constants. The same test derives +each row's acquisition capability from +`supportsFullFiscalYearFiledReturnsRun` and +`supportsFiledReturnsArtifactType`; the document cannot declare that fact. For +each row, the final expectation cell's capability claim must agree with the +derived value. For each acquisition-capable selection, record service-worker +restart, browser restart, interrupted download, cancellation/discard and its +cleanup outcome, and a retained checkpoint whose browser record is no longer available. A +resumed path must not repeat a completed target. An unproven path remains +non-complete until retry or cancellation. Manual observation is only an +explicit non-completing action and still requires retry before ZIP staging. + +Every cell must match one complete row in this legend. The test renders the +legend from the same rule table used for validation, so state, reason, date, +column, and completion semantics cannot drift into an independent vocabulary. + + + +| State | Date constraint | Reason | Allowed column | Derived row capability | Recorded capability claim | Completion-eligible | +| ------------------------- | ------------------------------------------- | ----------------------------------- | ------------------------ | ------------------------- | ------------------------- | ------------------- | +| `pass` | valid `YYYY-MM-DD`, today or earlier in UTC | none | scenario columns | any derived capability | none | yes | +| `fail` | valid `YYYY-MM-DD`, today or earlier in UTC | none | scenario columns | any derived capability | none | no | +| `fail-closed-as-expected` | valid `YYYY-MM-DD`, today or earlier in UTC | `expected-fail-closed-boundary` | scenario columns | any derived capability | none | yes | +| `fail-closed-as-expected` | valid `YYYY-MM-DD`, today or earlier in UTC | `expected-fail-closed-boundary` | final expectation column | `acquisition-capable` | `acquisition-capable` | yes | +| `not-applicable` | valid `YYYY-MM-DD`, today or earlier in UTC | `recovery-scenario-not-applicable` | scenario columns | `acquisition-capable` | none | no | +| `not-applicable` | valid `YYYY-MM-DD`, today or earlier in UTC | `recovery-scenario-not-applicable` | scenario columns | `not-acquisition-capable` | none | yes | +| `not-applicable` | valid `YYYY-MM-DD`, today or earlier in UTC | `selection-not-acquisition-capable` | final expectation column | `not-acquisition-capable` | `not-acquisition-capable` | yes | +| `not-yet-run` | `not-recorded` | none | scenario columns | any derived capability | none | no | +| `not-yet-run` | `not-recorded` | `not-recorded` | final expectation column | any derived capability | none | no | + + + +If these combinations are insufficient, add a rule through review before +recording the observation. No other cell text is permitted, so raw portal URLs, +filenames, download IDs, page or DOM text, local paths, and taxpayer/session +data are unrepresentable in the matrix. + + + +| Return type | Artifact type | Service-worker restart | Browser restart | Interrupted download | Cancellation/discard and cleanup | Retained checkpoint; browser record unavailable | Expected fail-closed / not applicable | +| ----------- | ------------- | ------------------------------- | ------------------------------- | ------------------------------- | -------------------------------- | ----------------------------------------------- | ----------------------------------------------------- | +| GSTR-3B | PDF | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded; reason: not-recorded | +| GSTR-3B | JSON | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded; reason: not-recorded | +| GSTR-1 | PDF | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded; reason: not-recorded | +| GSTR-1 | EXCEL | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded; reason: not-recorded | +| GSTR-1 | PDF_AND_EXCEL | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded; reason: not-recorded | +| GSTR-2B | PDF | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded; reason: not-recorded | +| GSTR-2B | JSON | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded; reason: not-recorded | +| GSTR-2B | EXCEL | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded; reason: not-recorded | +| GSTR-2B | PDF_AND_EXCEL | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded | not-yet-run; date: not-recorded; reason: not-recorded | + + + - [ ] Action-bound capture is tested in clean Chrome and Brave profiles plus the real profile where the native Save dialog appeared, with "Ask where to save each file" on and off, existing filename collisions, and @@ -242,7 +310,8 @@ durable full-year, or stable-release claims. - [ ] Publisher account MFA, recovery, and team access are verified. - [ ] Product, engineering, security, privacy/legal, open-source, and release manager sign-offs are recorded. -- [x] Initial Chrome Web Store V0 listing published. +- [x] Initial Chrome Web Store V0 listing published. The dated 2026-07-06 + `v0.3.2` publication evidence recorded below is the basis for this item. - [x] Protected Chrome Web Store release update submitted through workflow dispatch. The `v0.2.1` package was uploaded through GitHub Actions run `28542410006` with Chrome Web Store upload state `SUCCEEDED`, publish @@ -255,16 +324,25 @@ durable full-year, or stable-release claims. `nfnbhekccajjfgkppolomflaeledoccb`, item name `ComplyEaze Pack: GSTR-1/GSTR-3B Downloader`, item type `extension`, version `0.3.2`, and visibility `Public`. -- [x] Upload the exact `v0.4.0` package. Workflow run `29507382500` reported - upload state `SUCCEEDED`; publication remains blocked on dashboard - closeout and is not claimed complete. +- [x] Record the historical `v0.4.0` package upload. Workflow run `29507382500` + reported upload state `SUCCEEDED`; that release was not published. +- [ ] Record the `v0.5.0` Chrome Web Store submission. The release sequence + currently describes the package as submitted and in review as a draft; + approval, publication, and live availability are not claimed. Required + evidence remains unfilled: `observation-date: not-recorded`; + `observed-state: not-recorded`; `observation-location: not-recorded`; + `submission-method: not-recorded`; `workflow-run-id: not-recorded` (or + `manual; workflow-run-id: none`); and + `observer-or-approver: not-recorded`. - [x] Add a read-only Chrome Web Store status monitor for submitted packages. Scheduled runs use the dedicated `chrome-web-store-status` environment so publication/rejection monitoring is not blocked by the protected publishing - approval gate. + approval gate; the source is + `.github/workflows/chrome-web-store-status.yml`. - [ ] Record a read-only Chrome Web Store Status run with - `expected_version=0.4.0` and `require_published=false` after dashboard - submission, then `require_published=true` after publication. Use + `expected_version=0.5.0` and `require_published=false` while the submitted + draft is in review, then `require_published=true` only after confirmed + publication. Use [`docs/chrome-web-store/dashboard-closeout.md`](chrome-web-store/dashboard-closeout.md) for the dashboard and read-only status-monitor closeout checklist. @@ -278,10 +356,11 @@ version here. ## Not Yet Stable Or Broad-Claim Ready Until -The V0 listing exists for Store-published `v0.3.2`, `v0.4.0` is uploaded but not -yet published, and the source-build alpha -full-year workflow has maintainer evidence for automatic local downloads after -user initiation. Pack must not claim stable Chrome Web Store maturity, +The last confirmed Store publication is `v0.3.2`; the past `v0.4.0` package was +uploaded but not published, and the `v0.5.0` package is submitted and in review +as a draft. The source-build alpha full-year workflow has maintainer evidence +for automatic local downloads after user initiation. Pack must not claim stable +Chrome Web Store maturity, store-facing full-year availability, durable restart-safe full-year support, legal approval, live manifest/index/exception output, or broad GST coverage until legal review, live public policy URL confirmation, exact-ZIP manual QA, diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 1e4a381a..52cbd68d 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -154,10 +154,13 @@ dispatch it with `require_published=true` when final publication, not just submission, is the release gate. Configure the status workflow with a dedicated `chrome-web-store-status` -environment that has no required reviewer protection. Give it a read-only -service-account `CWS_SERVICE_ACCOUNT_JSON` secret plus `CWS_PUBLISHER_ID`; do -not copy the publish workflow's OAuth client secret or refresh token into this -environment. Keep the publishing workflow on the protected `chrome-web-store` +environment that has no required reviewer protection. Give it +`CWS_PUBLISHER_ID` plus either `CWS_SERVICE_ACCOUNT_JSON` or the complete OAuth +set `CWS_CLIENT_ID`, `CWS_CLIENT_SECRET`, and `CWS_REFRESH_TOKEN`. Use a +dedicated OAuth set provisioned with the read-only grant; do not copy broader +publishing credentials into this environment. The status checker performs only +the `fetchStatus` call, and its service-account flow requests the read-only API +scope. Keep the publishing workflow on the protected `chrome-web-store` environment. For local dry-runs against a generated release package: diff --git a/docs/chrome-web-store/assets/README.md b/docs/chrome-web-store/assets/README.md index 40cc68a4..f32312d6 100644 --- a/docs/chrome-web-store/assets/README.md +++ b/docs/chrome-web-store/assets/README.md @@ -1,10 +1,10 @@ # Chrome Web Store Asset Sources And Exports These source assets and generated exports are the safe synthetic asset set for -the `v0.4.0` Chrome Web Store dashboard update. They use the current Pack popup -visual language as a reference while intentionally excluding real GST Portal -screenshots and taxpayer data. Store publication is recorded in -[`../listing.md`](../listing.md); these files remain source-controlled inputs, +the current Chrome Web Store submission recorded in +[`../listing.md`](../listing.md). They use the current Pack popup visual +language as a reference while intentionally excluding real GST Portal +screenshots and taxpayer data. These files remain source-controlled inputs, not dashboard-held proof. The asset dimensions follow Chrome's official "Supplying Images" guidance: @@ -50,9 +50,10 @@ dashboard review state in [`../listing.md`](../listing.md). ## Generated Exports `exports/asset-hashes.json` is the authoritative generated inventory. It records -each PNG's source, exact dimensions, and SHA-256. Re-export and review that -manifest whenever any source SVG changes; do not duplicate hashes manually in -this README. +each PNG's source, the source SVG's SHA-256, the exact dimensions, and the PNG's +SHA-256. The asset test fails when a source digest no longer matches, so an SVG +edit must be followed by `pnpm store:assets`. Do not edit the generated manifest +or duplicate its hashes manually in this README. For each Store release, keep the exported assets, package upload, privacy declarations, reviewer instructions, review result, and publication state tied diff --git a/docs/chrome-web-store/assets/exports/asset-hashes.json b/docs/chrome-web-store/assets/exports/asset-hashes.json index 82d388c6..627bc770 100644 --- a/docs/chrome-web-store/assets/exports/asset-hashes.json +++ b/docs/chrome-web-store/assets/exports/asset-hashes.json @@ -6,6 +6,7 @@ "height": 280, "sha256": "8ab52389de440d2c3957922e9dcc0c766a06b767a5933c25ff11d09ed952e54d", "source": "small-promo-440x280.svg", + "sourceSha256": "5f3995d98f73a0e6258e2768917de4a6e65f9b8f6f7f0ef660ed085066bc190d", "width": 440 }, { @@ -13,13 +14,15 @@ "height": 560, "sha256": "6097bf88a0e482234b34b16dff17bdd53e1762a27ea9b6d3f51dc1852aba5c0a", "source": "marquee-promo-1400x560.svg", + "sourceSha256": "30b4603db304b54aac6e23fff212ef0b86043afbd7e944d962fcf79c0e263863", "width": 1400 }, { "file": "screenshot-local-downloads-1280x800.png", "height": 800, - "sha256": "4f3c4dbdb163022ba1d78ceb0ad9ff841a8885babf8d68a9ee82cb60be2d8acb", + "sha256": "aa9de58498f39d44044881daf4e189893411f833b910f342b36493b4af2f8ba0", "source": "screenshot-local-downloads-1280x800.svg", + "sourceSha256": "c9428696fcec974161b36d80515d098fb063b9cb963640951e64c01a041e6ec4", "width": 1280 }, { @@ -27,6 +30,7 @@ "height": 800, "sha256": "f764fa8cbe44e006ff65dcbdefaa3398b3a6538348fd491c26a670ebebd09bc5", "source": "screenshot-gstr3b-summary-pdf-1280x800.svg", + "sourceSha256": "b86a46f379c84392c506c5f1124b3d88f4ea006f0aa4eecf6df5b99e8addd18e", "width": 1280 }, { @@ -34,6 +38,7 @@ "height": 800, "sha256": "8ae1ff6df21a59fa4d88b85f82d8a785f2d04b0aaaa57ed091368c675856ae8c", "source": "screenshot-local-review-state-1280x800.svg", + "sourceSha256": "bad727ff699781a3fa39e4e09ccc1f369b5acf7de5af0da2378021a0eaee8f5a", "width": 1280 }, { @@ -41,6 +46,7 @@ "height": 800, "sha256": "cb6ed48ac4c38913115b8c24b4869c508926251ca9fbc9ea31f9b20f0c8d7134", "source": "screenshot-options-clear-data-1280x800.svg", + "sourceSha256": "9c13b9fa072e8fd1cc0e695f44225491711c5f5e97da452891433f83d2df09b0", "width": 1280 }, { @@ -48,6 +54,7 @@ "height": 800, "sha256": "a6d66974398fd9053b9199c6e5521740bd3e2b12e2c831b778e19097bef1ef43", "source": "screenshot-reviewer-demo-1280x800.svg", + "sourceSha256": "a300d88dc294c0684b7cc5d6a89572316a9535096a724a2064359e8e0387f840", "width": 1280 } ] diff --git a/docs/chrome-web-store/assets/exports/screenshot-local-downloads-1280x800.png b/docs/chrome-web-store/assets/exports/screenshot-local-downloads-1280x800.png index 8b041135..654814ff 100644 Binary files a/docs/chrome-web-store/assets/exports/screenshot-local-downloads-1280x800.png and b/docs/chrome-web-store/assets/exports/screenshot-local-downloads-1280x800.png differ diff --git a/docs/chrome-web-store/assets/marquee-promo-1400x560.svg b/docs/chrome-web-store/assets/marquee-promo-1400x560.svg index 99a5081b..38d18872 100644 --- a/docs/chrome-web-store/assets/marquee-promo-1400x560.svg +++ b/docs/chrome-web-store/assets/marquee-promo-1400x560.svg @@ -1,6 +1,6 @@ ComplyEaze Pack marquee promotional image source - Synthetic Chrome Web Store marquee source for Pack v0.4.0. It shows no real GST Portal or taxpayer data. + Synthetic Chrome Web Store marquee source for Pack. It shows no real GST Portal or taxpayer data. diff --git a/docs/chrome-web-store/assets/small-promo-440x280.svg b/docs/chrome-web-store/assets/small-promo-440x280.svg index 736751a7..9be1a194 100644 --- a/docs/chrome-web-store/assets/small-promo-440x280.svg +++ b/docs/chrome-web-store/assets/small-promo-440x280.svg @@ -1,6 +1,6 @@ ComplyEaze Pack small promotional image source - Synthetic Chrome Web Store promotional image for Pack v0.4.0. No real GST Portal data is shown. + Synthetic Chrome Web Store promotional image for Pack. No real GST Portal data is shown. diff --git a/docs/chrome-web-store/dashboard-closeout.md b/docs/chrome-web-store/dashboard-closeout.md index 0c680ede..2c08e293 100644 --- a/docs/chrome-web-store/dashboard-closeout.md +++ b/docs/chrome-web-store/dashboard-closeout.md @@ -5,12 +5,14 @@ not replace the protected package submit workflow. It records the manual Chrome Web Store Developer Dashboard steps that cannot be proved from source control alone. -The public item remains on `v0.3.2`. The exact `v0.4.0` package upload -succeeded in workflow run `29507382500`, but publication is blocked by -incomplete dashboard requirements. The 2026-07-16 dashboard snapshots show a -blank required `offscreen` justification, stale GSTR-3B-only text, stale -promotional assets, and data-usage selections that do not disclose local -handling. +The public item remains on `v0.3.2`. The `v0.5.0` package is submitted and in +review as a draft; it is not approved, published, or live. The exact historical +`v0.4.0` package upload succeeded in workflow run `29507382500`, but that +release was not published. The 2026-07-16 dashboard snapshots are historical +`v0.4.0` evidence: they showed a blank required `offscreen` justification, +stale GSTR-3B-only text, stale promotional assets, and data-usage selections +that did not disclose local handling. They do not prove the current draft's +dashboard fields. ## Source Inputs @@ -35,9 +37,9 @@ Official Chrome references to re-check before editing the dashboard: 1. Open the Chrome Web Store Developer Dashboard for the existing Pack item: `nfnbhekccajjfgkppolomflaeledoccb`. -2. Confirm the dashboard package under review is version `0.4.0`, source commit - `eb21404d274917876fcba20abce09216ce6348f4`, and release asset SHA-256 - `6ee4be24cafbe15db69275cac4da6b212f3de49b0f747eb9909eed7d293347c6`. +2. Confirm the dashboard package under review is version `0.5.0`, source commit + `985e9aa91d30e2955c996cd483496ebe92cfeef6`, and release ZIP SHA-256 + `1ecea75998ce69ae79caf8e6d27134516320a527d298ef164543cf87f6c07e62`. 3. Copy every Store listing and Privacy practices field from [`listing.md`](listing.md). Do not keep the previous GSTR-3B-only description or permission justifications. @@ -70,18 +72,24 @@ Official Chrome references to re-check before editing the dashboard: ## Status Monitor Closeout The read-only status workflow is separate from the protected upload/publish -workflow. Configure the GitHub environment named `chrome-web-store-status` with: +workflow. Configure the GitHub environment named `chrome-web-store-status` +with `CWS_PUBLISHER_ID` and either accepted credential form: -- variable `CWS_PUBLISHER_ID`; -- read-only service-account secret `CWS_SERVICE_ACCOUNT_JSON`. +- service-account secret `CWS_SERVICE_ACCOUNT_JSON`; or +- dedicated read-only OAuth secrets `CWS_CLIENT_ID`, `CWS_CLIENT_SECRET`, and + `CWS_REFRESH_TOKEN`. -Do not copy the publish workflow's OAuth client secret or refresh token into the -status environment. +The checker owns credential-form validation and performs only the read-only +`fetchStatus` call. Its service-account flow requests the read-only API scope; +an OAuth refresh token must have been provisioned with the read-only grant. Do +not copy broader publishing credentials into this environment. A non-strict +scheduled run without either form records an explicit skip. A manual dispatch, +including `require_published=true`, fails when it cannot authenticate. After the environment is configured, dispatch `Chrome Web Store Status` with: ```text -expected_version=0.4.0 +expected_version=0.5.0 require_published=false ``` @@ -90,7 +98,7 @@ requiring final publication. After Chrome publishes the item, dispatch the same workflow with: ```text -expected_version=0.4.0 +expected_version=0.5.0 require_published=true ``` @@ -100,7 +108,7 @@ successful package upload or a saved dashboard draft as publication evidence. ## Evidence To Record -Before considering the `v0.4.0` Store closeout done, record: +Before considering the `v0.5.0` Store closeout done, record: - dashboard listing text review state; - dashboard screenshot and promotional image review state; diff --git a/docs/chrome-web-store/listing.md b/docs/chrome-web-store/listing.md index 43ed6b22..2a20640c 100644 --- a/docs/chrome-web-store/listing.md +++ b/docs/chrome-web-store/listing.md @@ -10,18 +10,26 @@ Use [`dashboard-closeout.md`](dashboard-closeout.md) for the action sequence. ## Current Store State - Published package: `v0.3.2`. -- Pending package: `v0.4.0`, source commit - `eb21404d274917876fcba20abce09216ce6348f4`. -- Release asset: `complyeazepack-0.4.0-chrome.zip`. -- Release asset SHA-256: - `6ee4be24cafbe15db69275cac4da6b212f3de49b0f747eb9909eed7d293347c6`. -- GitHub release: . -- Workflow run `29507382500` built, tested, verified, zipped, published the - prerelease assets, and uploaded the exact ZIP to Chrome Web Store. The Store - upload state was `SUCCEEDED`; publish returned HTTP 400 because dashboard - requirements were incomplete. -- The dashboard screenshots supplied on 2026-07-16 show stale GSTR-3B-only - copy/assets and a blank required `offscreen` permission justification. +- Submitted package: `v0.5.0`, source commit + `985e9aa91d30e2955c996cd483496ebe92cfeef6`. +- GitHub pre-release: . +- Release ZIP SHA-256: + `1ecea75998ce69ae79caf8e6d27134516320a527d298ef164543cf87f6c07e62`. +- Chrome Web Store state: submitted and in review as a draft. It is not + approved, published, or live; `v0.3.2` remains the last confirmed Store + publication and the basis for Store-published public claims. +- Historical `v0.4.0` workflow run `29507382500` built, tested, verified, and + uploaded that release's exact ZIP with Store upload state `SUCCEEDED`, but + publish returned HTTP 400 because dashboard requirements were incomplete. + It was not published. +- The dashboard screenshots supplied on 2026-07-16 are historical `v0.4.0` + evidence. They showed stale GSTR-3B-only copy/assets and a blank required + `offscreen` permission justification; they do not prove the current draft's + dashboard fields. + +This section is the canonical source-controlled record for the current Store +version and review state. Release-readiness and dashboard-closeout documents +must not claim a newer state than this record. The Store-supported alpha claims remain GSTR-1 and GSTR-3B single-period downloads. Private GSTR-2B and full-fiscal-year workflows are source-build @@ -153,9 +161,12 @@ requires disclosure: Use the seven generated PNGs under [`assets/exports/`](assets/exports/). They are generated from the source SVGs, contain synthetic UI only, and exclude GST Portal screenshots, taxpayer data, -real GST filenames, local paths, and downloaded content. The committed hashes -pin the reviewed dashboard bytes; regeneration on a host with different installed -fonts can produce different pixels, so upload the committed hash-matched exports. +real GST filenames, local paths, and downloaded content. The committed manifest +binds every generated PNG to the exact source SVG bytes. Regeneration on a host +with different installed fonts can produce different pixels, so upload only the +committed hash-matched exports after a maintainer has visually reviewed them for +the exact submission. The regenerated `v0.5.0` exports have not yet received +that visual review. | Dashboard slot | Export | | ------------------ | ----------------------------------------------------------- | @@ -168,8 +179,9 @@ fonts can produce different pixels, so upload the committed hash-matched exports | Screenshot 4 | `assets/exports/screenshot-options-clear-data-1280x800.png` | | Screenshot 5 | `assets/exports/screenshot-reviewer-demo-1280x800.png` | -The asset filenames, dimensions, source files, and current SHA-256 values are -recorded in `assets/exports/asset-hashes.json`. +The asset filenames, dimensions, source files, source SVG SHA-256 values, and +generated PNG SHA-256 values are recorded in +`assets/exports/asset-hashes.json`. ## Dashboard Evidence Boundary diff --git a/scripts/export-chrome-web-store-assets.d.mts b/scripts/export-chrome-web-store-assets.d.mts index ab17f83b..20c897c9 100644 --- a/scripts/export-chrome-web-store-assets.d.mts +++ b/scripts/export-chrome-web-store-assets.d.mts @@ -4,4 +4,10 @@ export interface ChromeWebStoreAssetDimensions { width: number; } +export interface ChromeWebStoreAsset extends ChromeWebStoreAssetDimensions { + source: string; +} + +export const CHROME_WEB_STORE_ASSETS: readonly ChromeWebStoreAsset[]; + export function assertOpaqueRgbPng(buffer: Buffer, asset: ChromeWebStoreAssetDimensions): void; diff --git a/scripts/export-chrome-web-store-assets.mjs b/scripts/export-chrome-web-store-assets.mjs index a0b237ef..9d8d6950 100644 --- a/scripts/export-chrome-web-store-assets.mjs +++ b/scripts/export-chrome-web-store-assets.mjs @@ -7,7 +7,7 @@ import { chromium } from "@playwright/test"; const SOURCE_DIR = path.join("docs", "chrome-web-store", "assets"); const EXPORT_DIR = path.join(SOURCE_DIR, "exports"); -const ASSETS = [ +export const CHROME_WEB_STORE_ASSETS = [ { file: "small-promo-440x280.png", height: 280, @@ -68,10 +68,10 @@ export async function exportChromeWebStoreAssets({ const exportedAssets = []; try { - for (const asset of ASSETS) { + for (const asset of CHROME_WEB_STORE_ASSETS) { const sourcePath = path.join(sourceDir, asset.source); const outputPath = path.join(exportDir, asset.file); - const svg = await readSvgSource(sourcePath, asset); + const { sourceSha256, svg } = await readSvgSource(sourcePath, asset); const page = await browser.newPage({ deviceScaleFactor: 1, @@ -91,6 +91,7 @@ export async function exportChromeWebStoreAssets({ height: asset.height, sha256: sha256(buffer), source: asset.source, + sourceSha256, width: asset.width, }); } finally { @@ -119,7 +120,8 @@ export async function exportChromeWebStoreAssets({ } async function readSvgSource(sourcePath, asset) { - const svg = await readFile(sourcePath, "utf8"); + const source = await readFile(sourcePath); + const svg = source.toString("utf8"); const width = readSvgNumericAttribute(svg, "width", sourcePath); const height = readSvgNumericAttribute(svg, "height", sourcePath); if (width !== asset.width || height !== asset.height) { @@ -127,7 +129,7 @@ async function readSvgSource(sourcePath, asset) { `${path.basename(sourcePath)} must be ${asset.width}x${asset.height}; got ${width}x${height}.`, ); } - return svg; + return { sourceSha256: sha256(source), svg }; } function readSvgNumericAttribute(svg, attribute, sourcePath) { diff --git a/tests/docs/publication-readiness.test.ts b/tests/docs/publication-readiness.test.ts new file mode 100644 index 00000000..183a5c53 --- /dev/null +++ b/tests/docs/publication-readiness.test.ts @@ -0,0 +1,593 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + FILED_RETURNS_ARTIFACT_TYPES, + isFiledReturnsArtifactType, + supportsFiledReturnsArtifactType, +} from "../../src/connectors/gst/filed-returns-artifacts"; +import { + FILED_RETURNS_RETURN_TYPES, + isFiledReturnsReturnType, + supportsFullFiscalYearFiledReturnsRun, +} from "../../src/connectors/gst/filed-returns-return-types"; + +const rootDir = process.cwd(); +const matrixStart = ""; +const matrixEnd = ""; +const legendStart = ""; +const legendEnd = ""; +const storeChecklistStart = "## Chrome Web Store Checklist"; +const storeChecklistEnd = "## Suggested Store Copy"; +const matrixColumns = [ + "Return type", + "Artifact type", + "Service-worker restart", + "Browser restart", + "Interrupted download", + "Cancellation/discard and cleanup", + "Retained checkpoint; browser record unavailable", + "Expected fail-closed / not applicable", +]; +const observationPattern = + /^([a-z]+(?:-[a-z]+)*); date: ([^;\s]+)(?:; reason: ([a-z]+(?:-[a-z]+)*))?$/; +type DateConstraint = "not-recorded" | "recorded-not-future"; +type ColumnConstraint = "any" | "expectation-only" | "scenario-only"; +type RowCapability = "acquisition-capable" | "not-acquisition-capable"; +type RowCapabilityConstraint = "any" | RowCapability; +interface ObservationCellRule { + columnConstraint: ColumnConstraint; + completionEligible: boolean; + dateConstraint: DateConstraint; + recordedRowCapability?: RowCapability; + reasons: readonly (string | undefined)[]; + rowCapabilityConstraint: RowCapabilityConstraint; + state: string; +} +const observationCellRules: readonly ObservationCellRule[] = [ + { + columnConstraint: "scenario-only", + completionEligible: true, + dateConstraint: "recorded-not-future", + reasons: [undefined], + rowCapabilityConstraint: "any", + state: "pass", + }, + { + columnConstraint: "scenario-only", + completionEligible: false, + dateConstraint: "recorded-not-future", + reasons: [undefined], + rowCapabilityConstraint: "any", + state: "fail", + }, + { + columnConstraint: "scenario-only", + completionEligible: true, + dateConstraint: "recorded-not-future", + reasons: ["expected-fail-closed-boundary"], + rowCapabilityConstraint: "any", + state: "fail-closed-as-expected", + }, + { + columnConstraint: "expectation-only", + completionEligible: true, + dateConstraint: "recorded-not-future", + recordedRowCapability: "acquisition-capable", + reasons: ["expected-fail-closed-boundary"], + rowCapabilityConstraint: "acquisition-capable", + state: "fail-closed-as-expected", + }, + { + columnConstraint: "scenario-only", + completionEligible: false, + dateConstraint: "recorded-not-future", + reasons: ["recovery-scenario-not-applicable"], + rowCapabilityConstraint: "acquisition-capable", + state: "not-applicable", + }, + { + columnConstraint: "scenario-only", + completionEligible: true, + dateConstraint: "recorded-not-future", + reasons: ["recovery-scenario-not-applicable"], + rowCapabilityConstraint: "not-acquisition-capable", + state: "not-applicable", + }, + { + columnConstraint: "expectation-only", + completionEligible: true, + dateConstraint: "recorded-not-future", + recordedRowCapability: "not-acquisition-capable", + reasons: ["selection-not-acquisition-capable"], + rowCapabilityConstraint: "not-acquisition-capable", + state: "not-applicable", + }, + { + columnConstraint: "scenario-only", + completionEligible: false, + dateConstraint: "not-recorded", + reasons: [undefined], + rowCapabilityConstraint: "any", + state: "not-yet-run", + }, + { + columnConstraint: "expectation-only", + completionEligible: false, + dateConstraint: "not-recorded", + reasons: ["not-recorded"], + rowCapabilityConstraint: "any", + state: "not-yet-run", + }, +]; +const recoveryMatrixCheckboxPattern = + /^- \[( |x)\] The authorised live full fiscal year recovery matrix below is complete:/m; +const storeChecklistEvidenceTokenPattern = + /`(?:\.github\/|docs\/|scripts\/|src\/|tests\/|wxt\.config\.ts)[^`]*`|\b20\d{2}-\d{2}-\d{2}\b|\b(?:GitHub Actions run|[Ww]orkflow run|Run) `\d{8,}`/; + +describe("publication readiness recovery matrix", () => { + it("keeps status-closeout consumers aligned to the canonical listing version", async () => { + const [readiness, listing, dashboardCloseout] = await Promise.all([ + readPublicationReadiness(), + readFile(path.join(rootDir, "docs", "chrome-web-store", "listing.md"), "utf8"), + readFile(path.join(rootDir, "docs", "chrome-web-store", "dashboard-closeout.md"), "utf8"), + ]); + const submittedVersion = listing.match(/^- Submitted package: `(v\d+\.\d+\.\d+)`/m)?.[1]; + + expect(submittedVersion).toBeTruthy(); + const expectedVersion = submittedVersion?.slice(1); + expect( + readiness.includes(`expected_version=${expectedVersion}`), + "publication readiness must use the canonical submitted version", + ).toBe(true); + expect( + [...dashboardCloseout.matchAll(/^expected_version=(\d+\.\d+\.\d+)$/gm)].map( + (match) => match[1], + ), + ).toEqual([expectedVersion, expectedVersion]); + }); + + it("tracks every canonical offered return and artifact selection once", async () => { + assertCanonicalSelections(matrixRows(await readRecoveryMatrix())); + }); + + it("requires every checked Store item to carry a recorded evidence token", async () => { + const checkedItems = checklistItems( + markedSection(await readPublicationReadiness(), storeChecklistStart, storeChecklistEnd), + ).filter((item) => item.checked); + + expect( + checkedItems.length, + "Store checklist must contain checked evidence items", + ).toBeGreaterThan(0); + for (const [index, item] of checkedItems.entries()) { + expect( + storeChecklistEvidenceTokenPattern.test(item.text), + `checked Store item ${index + 1} must carry a source, run, or dated evidence token`, + ).toBe(true); + } + }); + + it("renders the matrix legend from the canonical whole-cell rules", async () => { + const readiness = await readPublicationReadiness(); + expect(markedSection(readiness, legendStart, legendEnd).trim()).toBe( + renderObservationCellLegend(), + ); + }); + + it("rejects every unexpected data row instead of filtering it out", async () => { + const matrix = await readRecoveryMatrix(); + const unfilled = "not-yet-run; date: not-recorded"; + const unexpectedRow = [ + "Notes", + "unexpected", + unfilled, + unfilled, + unfilled, + unfilled, + unfilled, + `${unfilled}; reason: not-recorded`, + ]; + const matrixWithUnexpectedRow = `${matrix.trimEnd()}\n| ${unexpectedRow.join(" | ")} |\n`; + + expect(() => assertCanonicalSelections(matrixRows(matrixWithUnexpectedRow))).toThrow(); + }); + + it("keeps every observation fillable, dated, and reasoned when required", async () => { + const matrix = await readRecoveryMatrix(); + + for (const [returnType = "", artifactType = "", ...observations] of matrixRows(matrix)) { + const rowCapability = deriveRowCapability(returnType, artifactType); + expect(observations.length, "matrix row must have six observation cells").toBe(6); + + for (const [index, observation] of observations.entries()) { + validateObservation(observation, index === observations.length - 1, rowCapability); + } + } + }); + + it.each([ + "not-applicable; date: 2026-08-17; reason: expected-fail-closed-boundary", + "fail-closed-as-expected; date: 2026-08-17; reason: recovery-scenario-not-applicable", + "fail-closed-as-expected; date: 2026-08-17; reason: selection-not-acquisition-capable", + ])("rejects a reason assigned to the wrong state: %s", (observation) => { + expect(() => validateObservation(observation, false, "acquisition-capable")).toThrow(); + }); + + it("accepts today and past dates but rejects future evidence", () => { + expect(() => + validateObservation(`pass; date: ${utcDateOffset(-1)}`, false, "acquisition-capable"), + ).not.toThrow(); + expect(() => + validateObservation(`pass; date: ${utcDateOffset(0)}`, false, "acquisition-capable"), + ).not.toThrow(); + expect(() => + validateObservation(`pass; date: ${utcDateOffset(1)}`, false, "acquisition-capable"), + ).toThrow(); + }); + + it("allows date not-recorded only for the not-yet-run placeholder", () => { + expect(() => + validateObservation("not-yet-run; date: not-recorded", false, "acquisition-capable"), + ).not.toThrow(); + expect(() => + validateObservation("pass; date: not-recorded", false, "acquisition-capable"), + ).toThrow(); + expect(() => + validateObservation(`not-yet-run; date: ${utcDateOffset(0)}`, false, "acquisition-capable"), + ).toThrow(); + }); + + it("rejects a combination absent from the whole-cell table", () => { + expect(() => + validateObservation(`manual-review; date: ${utcDateOffset(0)}`, false, "acquisition-capable"), + ).toThrow(); + }); + + it.each([ + [ + "not-applicable; date: 2026-08-17; reason: recovery-scenario-not-applicable", + true, + "acquisition-capable" as const, + ], + [ + "not-applicable; date: 2026-08-17; reason: selection-not-acquisition-capable", + false, + "not-acquisition-capable" as const, + ], + ])( + "rejects a reason in the wrong column: %s", + (observation, expectationColumn, rowCapability) => { + expect(() => validateObservation(observation, expectationColumn, rowCapability)).toThrow(); + }, + ); + + it.each(["2026-99-99", "2026-02-29", "2026-04-31"])( + "rejects the non-calendar date %s", + (date) => { + expect(() => + validateObservation(`pass; date: ${date}`, false, "acquisition-capable"), + ).toThrow(); + }, + ); + + it("cannot mark the recovery gate complete while any observation is unfilled", async () => { + const readiness = await readPublicationReadiness(); + assertRecoveryGate(readiness); + }); + + it("cannot mark the recovery gate complete when any filled observation failed", async () => { + const today = utcDateOffset(0); + const completed = fillRecoveryMatrix(await readPublicationReadiness()).replace( + `pass; date: ${today}`, + `fail; date: ${today}`, + ); + + expect(() => assertRecoveryGate(completed)).toThrow(); + }); + + it("cannot complete an acquisition-capable row with every scenario not applicable", async () => { + const today = utcDateOffset(0); + let completed = fillRecoveryMatrix(await readPublicationReadiness()); + + for (let scenario = 0; scenario < 5; scenario += 1) { + completed = completed.replace( + `pass; date: ${today}`, + `not-applicable; date: ${today}; reason: recovery-scenario-not-applicable`, + ); + } + + expect(() => assertRecoveryGate(completed)).toThrow(); + }); + + it("allows a canonically non-capable selection to complete through its expected path", () => { + const today = utcDateOffset(0); + const scenario = `fail-closed-as-expected; date: ${today}; reason: expected-fail-closed-boundary`; + const expectation = `not-applicable; date: ${today}; reason: selection-not-acquisition-capable`; + + expect(() => + assertRecoveryRowComplete([ + "GSTR-1", + "JSON", + ...Array(5).fill(scenario), + expectation, + ]), + ).not.toThrow(); + }); + + it("rejects a recorded capability claim that contradicts the derived value", () => { + const today = utcDateOffset(0); + const expectation = `not-applicable; date: ${today}; reason: selection-not-acquisition-capable`; + + expect(() => + assertRecoveryRowComplete([ + "GSTR-3B", + "PDF", + ...Array(5).fill(`pass; date: ${today}`), + expectation, + ]), + ).toThrow( + "matrix row capability mismatch: derived acquisition-capable; recorded not-acquisition-capable", + ); + }); + + it("accepts a checked matrix only when every cell is completion-eligible", async () => { + const completed = fillRecoveryMatrix(await readPublicationReadiness()); + expect(() => assertRecoveryGate(completed)).not.toThrow(); + }); +}); + +function assertRecoveryGate(readiness: string): void { + const checkbox = readiness.match(recoveryMatrixCheckboxPattern); + + expect(checkbox).not.toBeNull(); + if (checkbox?.[1] !== "x") return; + + for (const row of matrixRows(recoveryMatrix(readiness))) assertRecoveryRowComplete(row); +} + +function assertRecoveryRowComplete(row: string[]): void { + const [returnType = "", artifactType = "", ...observations] = row; + const rowCapability = deriveRowCapability(returnType, artifactType); + expect(observations.length, "matrix row must have six observation cells").toBe(6); + + for (const [index, observation] of observations.entries()) { + const rule = validateObservation(observation, index === observations.length - 1, rowCapability); + expect(rule.completionEligible, "matrix completion requires an eligible cell state").toBe(true); + } +} + +function fillRecoveryMatrix(readiness: string): string { + const today = utcDateOffset(0); + return readiness + .replace( + recoveryMatrixCheckboxPattern, + "- [x] The authorised live full fiscal year recovery matrix below is complete:", + ) + .replaceAll( + "not-yet-run; date: not-recorded; reason: not-recorded", + `fail-closed-as-expected; date: ${today}; reason: expected-fail-closed-boundary`, + ) + .replaceAll("not-yet-run; date: not-recorded", `pass; date: ${today}`); +} + +async function readRecoveryMatrix(): Promise { + return recoveryMatrix(await readPublicationReadiness()); +} + +async function readPublicationReadiness(): Promise { + return readFile(path.join(rootDir, "docs", "PUBLICATION_READINESS.md"), "utf8"); +} + +function recoveryMatrix(readiness: string): string { + return markedSection(readiness, matrixStart, matrixEnd); +} + +function markedSection(document: string, startMarker: string, endMarker: string): string { + const start = document.indexOf(startMarker); + const end = document.indexOf(endMarker); + + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + return document.slice(start + startMarker.length, end); +} + +function matrixRows(matrix: string): string[][] { + const lines = matrix + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + expect(lines.every((line) => line.startsWith("|") && line.endsWith("|"))).toBe(true); + expect(lines.length).toBeGreaterThanOrEqual(3); + + const [header, separator, ...dataRows] = lines.map(parseMatrixRow); + expect( + header?.every((cell, index) => cell === matrixColumns[index]) && + header.length === matrixColumns.length, + "matrix header must match the canonical columns", + ).toBe(true); + expect(separator?.length, "matrix separator must match the canonical column count").toBe( + matrixColumns.length, + ); + expect(separator?.every((cell) => /^:?-{3,}:?$/.test(cell))).toBe(true); + + for (const row of dataRows) { + expect(row.length, "matrix data row must match the canonical column count").toBe( + matrixColumns.length, + ); + } + return dataRows; +} + +function parseMatrixRow(line: string): string[] { + return line + .split("|") + .slice(1, -1) + .map((cell) => cell.trim()); +} + +function checklistItems(section: string): Array<{ checked: boolean; text: string }> { + const items: Array<{ checked: boolean; text: string }> = []; + + for (const line of section.split("\n")) { + const item = line.match(/^- \[([ x])\] (.+)$/); + if (item) { + items.push({ checked: item[1] === "x", text: item[2] ?? "" }); + } else if (items.length > 0 && /^ {6}\S/.test(line)) { + const current = items[items.length - 1]; + if (current) current.text += ` ${line.trim()}`; + } + } + + return items; +} + +function assertCanonicalSelections(rows: string[][]): void { + const documentedSelections = rows.map(([returnType, artifactType]) => + [returnType, artifactType].join(" | "), + ); + const offeredSelections = FILED_RETURNS_RETURN_TYPES.flatMap((returnType) => + FILED_RETURNS_ARTIFACT_TYPES.filter((artifactType) => + supportsFiledReturnsArtifactType(returnType, artifactType), + ).map((artifactType) => [returnType, artifactType].join(" | ")), + ); + + expect( + documentedSelections.length === offeredSelections.length && + documentedSelections.every((selection, index) => selection === offeredSelections[index]), + "matrix selections must match canonical offered selections in order", + ).toBe(true); + expect(new Set(documentedSelections).size).toBe(documentedSelections.length); +} + +function deriveRowCapability(returnType: string, artifactType: string): RowCapability { + if (!isFiledReturnsReturnType(returnType) || !isFiledReturnsArtifactType(artifactType)) { + throw new Error("matrix row does not use canonical return and artifact types"); + } + + return supportsFullFiscalYearFiledReturnsRun(returnType) && + supportsFiledReturnsArtifactType(returnType, artifactType) + ? "acquisition-capable" + : "not-acquisition-capable"; +} + +function validateObservation( + observation: string, + expectationColumn: boolean, + rowCapability: RowCapability, +): ObservationCellRule { + const parsed = observation.match(observationPattern); + expect(parsed, "matrix cell has an invalid observation format").not.toBeNull(); + if (!parsed) throw new Error("matrix cell has an invalid observation format"); + + const [, state, date, reason] = parsed; + const matchingCellRules = observationCellRules.filter( + (candidate) => + candidate.state === state && + candidate.reasons.includes(reason) && + dateMatchesConstraint(date ?? "", candidate.dateConstraint) && + columnMatchesConstraint(expectationColumn, candidate.columnConstraint), + ); + const rule = matchingCellRules.find((candidate) => + rowCapabilityMatchesConstraint(rowCapability, candidate.rowCapabilityConstraint), + ); + if (!rule) { + const contradictoryClaim = matchingCellRules.find( + (candidate) => + candidate.recordedRowCapability !== undefined && + candidate.recordedRowCapability !== rowCapability, + )?.recordedRowCapability; + if (contradictoryClaim) { + throw new Error( + `matrix row capability mismatch: derived ${rowCapability}; recorded ${contradictoryClaim}`, + ); + } + } + expect(rule, "matrix cell combination is not allowed").toBeDefined(); + return rule as ObservationCellRule; +} + +function isCanonicalCalendarDate(value: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + const parsed = new Date(`${value}T00:00:00.000Z`); + return !Number.isNaN(parsed.valueOf()) && parsed.toISOString().slice(0, 10) === value; +} + +function dateMatchesConstraint(value: string, constraint: DateConstraint): boolean { + if (constraint === "not-recorded") return value === "not-recorded"; + return isCanonicalCalendarDate(value) && value <= utcDateOffset(0); +} + +function columnMatchesConstraint( + expectationColumn: boolean, + constraint: ColumnConstraint, +): boolean { + if (constraint === "any") return true; + return expectationColumn === (constraint === "expectation-only"); +} + +function rowCapabilityMatchesConstraint( + rowCapability: RowCapability, + constraint: RowCapabilityConstraint, +): boolean { + return constraint === "any" || constraint === rowCapability; +} + +function utcDateOffset(days: number): string { + const date = new Date(); + date.setUTCDate(date.getUTCDate() + days); + return date.toISOString().slice(0, 10); +} + +function renderObservationCellLegend(): string { + const header = [ + "State", + "Date constraint", + "Reason", + "Allowed column", + "Derived row capability", + "Recorded capability claim", + "Completion-eligible", + ]; + const rows = observationCellRules.map((rule) => { + const date = + rule.dateConstraint === "not-recorded" + ? "`not-recorded`" + : "valid `YYYY-MM-DD`, today or earlier in UTC"; + const reasons = rule.reasons + .map((reason) => (reason === undefined ? "none" : `\`${reason}\``)) + .join(" or "); + const column = + rule.columnConstraint === "any" + ? "any observation column" + : rule.columnConstraint === "expectation-only" + ? "final expectation column" + : "scenario columns"; + const rowCapability = + rule.rowCapabilityConstraint === "any" + ? "any derived capability" + : `\`${rule.rowCapabilityConstraint}\``; + const recordedCapability = rule.recordedRowCapability + ? `\`${rule.recordedRowCapability}\`` + : "none"; + return [ + `\`${rule.state}\``, + date, + reasons, + column, + rowCapability, + recordedCapability, + rule.completionEligible ? "yes" : "no", + ]; + }); + const widths = header.map((cell, index) => + Math.max(cell.length, ...rows.map((row) => row[index]?.length ?? 0)), + ); + const renderRow = (row: readonly string[]) => + `| ${row.map((cell, index) => cell.padEnd(widths[index] ?? 0)).join(" | ")} |`; + return [ + renderRow(header), + renderRow(widths.map((width) => "-".repeat(width))), + ...rows.map(renderRow), + ].join("\n"); +} diff --git a/tests/extension/ci-workflow.test.ts b/tests/extension/ci-workflow.test.ts index 38d7dff3..c262c8be 100644 --- a/tests/extension/ci-workflow.test.ts +++ b/tests/extension/ci-workflow.test.ts @@ -1,4 +1,6 @@ -import { readdir, readFile } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; +import { chmod, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; @@ -138,17 +140,154 @@ describe("Pack CI workflow", () => { path.join(rootDir, ".github", "workflows", "chrome-web-store-status.yml"), "utf8", ); + const publisher = await readFile( + path.join(rootDir, "scripts", "publish-chrome-web-store.mjs"), + "utf8", + ); + const missingCredentialContract = publisher.match( + /"(Missing Chrome Web Store credentials\.[^"]+)"/, + )?.[1]; + const contractCredentialNames = [ + ...new Set(missingCredentialContract?.match(/CWS_[A-Z_]+/g) ?? []), + ]; expect(statusWorkflow).toContain("schedule:"); expect(statusWorkflow).toContain("workflow_dispatch:"); expect(statusWorkflow).toContain("environment: chrome-web-store-status"); + expect(statusWorkflow).toContain( + 'if [[ "$GITHUB_EVENT_NAME" == "schedule" && "$CWS_REQUIRE_PUBLISHED" == "false" ]]', + ); + expect(statusWorkflow).toContain( + '[[ "$status_output" == "Missing Chrome Web Store credentials."* ]]', + ); + expect(statusWorkflow).toContain('exit "$status_code"'); + expect(statusWorkflow).toContain("Chrome Web Store status check skipped"); + expect(statusWorkflow).toContain("the non-strict scheduled status check did not run"); expect(statusWorkflow).toContain("node scripts/check-chrome-web-store-status.mjs"); expect(statusWorkflow).toContain("CWS_REQUIRE_PUBLISHED"); - expect(statusWorkflow).toContain("CWS_SERVICE_ACCOUNT_JSON"); - expect(statusWorkflow).not.toContain("CWS_REFRESH_TOKEN"); - expect(statusWorkflow).not.toContain("CWS_CLIENT_SECRET"); + expect(contractCredentialNames.length).toBeGreaterThan(0); + for (const name of contractCredentialNames) { + expect(statusWorkflow).toContain(`${name}: \${{ secrets.${name} }}`); + } expect(statusWorkflow).not.toContain("scripts/publish-chrome-web-store.mjs"); expect(statusWorkflow).not.toContain(":publish"); expect(statusWorkflow).not.toContain(":upload"); }); + + it("skips only an unconfigured non-strict schedule and admits either credential form", async () => { + const statusWorkflow = await readFile( + path.join(rootDir, ".github", "workflows", "chrome-web-store-status.yml"), + "utf8", + ); + const publisher = await readFile( + path.join(rootDir, "scripts", "publish-chrome-web-store.mjs"), + "utf8", + ); + const missingCredentials = publisher.match( + /"(Missing Chrome Web Store credentials\.[^"]+)"/, + )?.[1]; + expect(missingCredentials).toBeTruthy(); + + const fixtureDir = await mkdtemp(path.join(tmpdir(), "pack-cws-status-workflow-")); + try { + const fakeNode = path.join(fixtureDir, "node"); + await writeFile( + fakeNode, + `#!/bin/sh +if [ "$FAKE_CREDENTIAL_FORM" = "service-account" ] && [ -z "$CWS_SERVICE_ACCOUNT_JSON" ]; then exit 9; fi +if [ "$FAKE_CREDENTIAL_FORM" = "oauth" ] && { [ -z "$CWS_CLIENT_ID" ] || [ -z "$CWS_CLIENT_SECRET" ] || [ -z "$CWS_REFRESH_TOKEN" ]; }; then exit 9; fi +printf '%s\\n' "$FAKE_NODE_OUTPUT" +exit "$FAKE_NODE_EXIT" +`, + ); + await chmod(fakeNode, 0o755); + + const runScript = extractLastWorkflowRunScript(statusWorkflow); + const scheduledSummary = path.join(fixtureDir, "scheduled-summary.md"); + const scheduled = runWorkflowShell(runScript, fixtureDir, { + CWS_REQUIRE_PUBLISHED: "false", + FAKE_NODE_EXIT: "1", + FAKE_NODE_OUTPUT: missingCredentials ?? "", + GITHUB_EVENT_NAME: "schedule", + GITHUB_STEP_SUMMARY: scheduledSummary, + }); + expect(scheduled.status).toBe(0); + expect(await readFile(scheduledSummary, "utf8")).toContain( + "the non-strict scheduled status check did not run", + ); + + const strictDispatch = runWorkflowShell(runScript, fixtureDir, { + CWS_REQUIRE_PUBLISHED: "true", + FAKE_NODE_EXIT: "1", + FAKE_NODE_OUTPUT: missingCredentials ?? "", + GITHUB_EVENT_NAME: "workflow_dispatch", + GITHUB_STEP_SUMMARY: path.join(fixtureDir, "strict-summary.md"), + }); + expect(strictDispatch.status).toBe(1); + expect(strictDispatch.stderr).toContain("Missing Chrome Web Store credentials."); + + const serviceAccount = runWorkflowShell(runScript, fixtureDir, { + CWS_REQUIRE_PUBLISHED: "false", + CWS_SERVICE_ACCOUNT_JSON: "configured", + FAKE_CREDENTIAL_FORM: "service-account", + FAKE_NODE_EXIT: "0", + FAKE_NODE_OUTPUT: "status-ok", + GITHUB_EVENT_NAME: "schedule", + GITHUB_STEP_SUMMARY: path.join(fixtureDir, "service-account-summary.md"), + }); + expect(serviceAccount.status).toBe(0); + + const oauth = runWorkflowShell(runScript, fixtureDir, { + CWS_CLIENT_ID: "configured", + CWS_CLIENT_SECRET: "configured", + CWS_REFRESH_TOKEN: "configured", + CWS_REQUIRE_PUBLISHED: "false", + FAKE_CREDENTIAL_FORM: "oauth", + FAKE_NODE_EXIT: "0", + FAKE_NODE_OUTPUT: "status-ok", + GITHUB_EVENT_NAME: "schedule", + GITHUB_STEP_SUMMARY: path.join(fixtureDir, "oauth-summary.md"), + }); + expect(oauth.status).toBe(0); + } finally { + await rm(fixtureDir, { force: true, recursive: true }); + } + }); }); + +function extractLastWorkflowRunScript(workflow: string): string { + const marker = " run: |\n"; + const start = workflow.lastIndexOf(marker); + expect(start).toBeGreaterThanOrEqual(0); + const lines = workflow.slice(start + marker.length).split("\n"); + const script: string[] = []; + + for (const line of lines) { + if (line.startsWith(" ")) { + script.push(line.slice(10)); + continue; + } + if (line === "") { + script.push(""); + continue; + } + break; + } + + return script.join("\n"); +} + +function runWorkflowShell( + script: string, + fixtureDir: string, + env: Record, +): ReturnType { + return spawnSync("bash", ["-c", script], { + encoding: "utf8", + env: { + ...process.env, + ...env, + PATH: `${fixtureDir}:${process.env.PATH ?? ""}`, + }, + }); +} diff --git a/tests/scripts/export-chrome-web-store-assets.test.ts b/tests/scripts/export-chrome-web-store-assets.test.ts index 3fa36556..17ddd2e2 100644 --- a/tests/scripts/export-chrome-web-store-assets.test.ts +++ b/tests/scripts/export-chrome-web-store-assets.test.ts @@ -1,10 +1,14 @@ import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { inflateSync } from "node:zlib"; import { afterEach, describe, expect, it } from "vitest"; -import { assertOpaqueRgbPng } from "../../scripts/export-chrome-web-store-assets.mjs"; +import { + assertOpaqueRgbPng, + CHROME_WEB_STORE_ASSETS, +} from "../../scripts/export-chrome-web-store-assets.mjs"; const rootDir = process.cwd(); const createdDirs: string[] = []; @@ -26,7 +30,14 @@ describe("Chrome Web Store asset exporter", () => { const manifest = JSON.parse( await readFile(path.join(exportDir, "asset-hashes.json"), "utf8"), ) as { - assets: Array<{ file: string; sha256: string; width: number; height: number }>; + assets: Array<{ + file: string; + height: number; + sha256: string; + source: string; + sourceSha256: string; + width: number; + }>; }; expect(manifest.assets).toEqual([ expect.objectContaining({ @@ -81,6 +92,56 @@ describe("Chrome Web Store asset exporter", () => { }); expect(readPngColorMode(buffer)).toEqual({ bitDepth: 8, colorType: 2 }); expect(readNonWhitePixelRatio(buffer)).toBeGreaterThan(0.1); + const source = await readFile( + path.join(rootDir, "docs/chrome-web-store/assets", asset.source), + ); + expect(asset.sourceSha256).toBe(createHash("sha256").update(source).digest("hex")); + } + }); + + it("binds every checked-in export to its current SVG source", async () => { + const manifest = JSON.parse( + await readFile( + path.join(rootDir, "docs/chrome-web-store/assets/exports/asset-hashes.json"), + "utf8", + ), + ) as { + assets: Array<{ + file: string; + height: number; + sha256: string; + source: string; + sourceSha256: string; + width: number; + }>; + }; + + expect( + manifest.assets.map(({ file, height, source, width }) => ({ + file, + height, + source, + width, + })), + ).toEqual(CHROME_WEB_STORE_ASSETS); + + for (const asset of manifest.assets) { + const source = await readFile( + path.join(rootDir, "docs/chrome-web-store/assets", asset.source), + ); + expect( + asset.sourceSha256, + `${asset.source} changed without regenerating its checked-in export`, + ).toBe(createHash("sha256").update(source).digest("hex")); + + const exportedPng = await readFile( + path.join(rootDir, "docs/chrome-web-store/assets/exports", asset.file), + ); + expect( + createHash("sha256").update(exportedPng).digest("hex"), + `${asset.file} does not match its generated manifest digest`, + ).toBe(asset.sha256); + assertOpaqueRgbPng(exportedPng, asset); } });