From 2e218efa74ec117f840d75e8e74f055f16a3f031 Mon Sep 17 00:00:00 2001 From: Anilkumar Reddy Date: Sat, 14 Nov 2026 21:26:12 -0600 Subject: [PATCH 1/4] feat: add consistent setup notifications and run summaries --- .github/workflows/ci.yml | 7 + .npmrc | 1 + CONTRIBUTING.md | 20 ++- README.md | 67 +++++++- RELEASING.md | 10 ++ THIRD_PARTY_NOTICES.md | 16 +- action.yml | 3 + docs/reporting-validation.md | 64 +++++++ package.json | 1 + pnpm-lock.yaml | 50 ++++++ pnpm-workspace.yaml | 2 + scripts/integration.mjs | 87 +++++++++- src/main.ts | 113 ++++++++---- src/reporting.ts | 136 +++++++++++++++ tests/main.test.ts | 321 ++++++++++++++++++++++++++++++++++- 15 files changed, 841 insertions(+), 57 deletions(-) create mode 100644 .npmrc create mode 100644 docs/reporting-validation.md create mode 100644 pnpm-workspace.yaml create mode 100644 src/reporting.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30e8cf9..7e02aa7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,9 @@ concurrency: jobs: quality: name: Quality and committed bundle + permissions: + contents: read + packages: read runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -26,12 +29,16 @@ jobs: uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version-file: .node-version + registry-url: https://npm.pkg.github.com + scope: '@commercekit-tools' - name: Enable Corepack run: corepack enable - name: Install dependencies run: pnpm install --frozen-lockfile + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Run quality gate run: pnpm quality diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..62460df --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +@commercekit-tools:registry=https://npm.pkg.github.com diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index be4fa96..418b149 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,18 @@ Thank you for helping improve Setup CommerceKit CLI. - Git The repository pins pnpm in `package.json`; do not install or publish this -package through npm. +package through npm. Development installation also reads the existing shared +notification contract from GitHub Packages. Configure package-read +authentication in user-level npm configuration; the repository scope +configuration contains no credentials. Trusted CI installation grants +`packages: read` only to its quality job. Fork workflows may lack package +access; do not use privileged untrusted PR execution to bypass that restriction. + +The reporting-consistency source requires the separately tracked unpublished +0.0.6 contract candidate. Normal manifests/lockfiles honestly retain published +0.0.5. Follow `docs/reporting-validation.md` for the isolated candidate gate; do +not commit machine-local dependency links or candidate bundles as registry +builds. ```bash corepack enable @@ -45,9 +56,10 @@ pnpm test:integration ``` It verifies release metadata, the selected archive, executable version, Action -outputs, and a second-invocation cache hit. It does not install into a system -directory or modify a shell profile. Latest-stable discovery is covered with -synthetic unit fixtures because the current public CLI releases are previews. +outputs, validated notification contexts, success/failure summaries and a +second-invocation cache hit. It does not install into a system directory or +modify a shell profile. Latest-stable discovery is covered with synthetic unit +fixtures because the current public CLI releases are previews. ## Committed bundle diff --git a/README.md b/README.md index 99c25a1..4fba608 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,12 @@ # Setup CommerceKit CLI +> **Reporting candidate:** consistent notifications and GitHub summaries require +> the unpublished notification-contract 0.0.6 extension. Source integration is +> validated with an exact packed candidate in an isolated workspace. Normal +> dependencies pin published 0.0.5 and the committed bundle is unchanged until a +> separately authorized publication and registry-backed rebuild. See +> [reporting validation](docs/reporting-validation.md). + Install and verify a requested [CommerceKit CLI](https://github.com/commercekit-tools/cli) version, or the latest stable version, on a GitHub Actions runner. @@ -104,16 +111,18 @@ fails. It never falls back to another release, Homebrew, or WinGet. ## Outputs -| Output | Meaning | Example | -| ----------- | -------------------------------------------------- | ---------------------------------------------- | -| `version` | Exact installed CLI version. | `0.1.0-preview.2` | -| `path` | Absolute path to the verified executable. | Runner tool-cache path ending in `commercekit` | -| `target` | Normalized release target selected for the runner. | `linux-x64` | -| `build-id` | Verified identity from `release-manifest.json`. | 40-character source identity | -| `cache-hit` | Lowercase string for verified tool-cache reuse. | `true` | +| Output | Meaning | Example | +| -------------------- | --------------------------------------------------- | ---------------------------------------------- | +| `version` | Exact installed CLI version. | `0.1.0-preview.2` | +| `path` | Absolute path to the verified executable. | Runner tool-cache path ending in `commercekit` | +| `target` | Normalized release target selected for the runner. | `linux-x64` | +| `build-id` | Verified identity from `release-manifest.json`. | 40-character source identity | +| `cache-hit` | Lowercase string for verified tool-cache reuse. | `true` | +| `notification-event` | Validated setup event with safe inputs and outcome. | Bounded JSON for the existing Notify action. | -Outputs are set only after the executable passes integrity and exact-version -verification. +Installation outputs are set only after the executable passes integrity and +exact-version verification. The reporting candidate also emits +`notification-event` for handled failures before the Action reports failure. ```yaml - name: Show resolved installation @@ -125,6 +134,46 @@ verification. "$COMMERCEKIT_PATH" --version ``` +## Consistent notifications and job summaries + +The reporting candidate uses the existing shared notification contract and the +existing Notify Action. It does not add a webhook input or delivery code. +`commercekit.setup.completed` reports setup success or failure with the exact +`version` input name, effective `latest` when omitted, validation/truncation +metadata, verified installed version, target, build identity, cache reuse and +duration when available. Failures include a safe code and explanation. A cache +hit remains successful; the Action does not invent a warning condition. + +Every handled success or failure writes the shared GitHub summary layout: status +icon and label, result table, input table, validation/truncation state and safe +error detail. This includes invalid version, stable-release discovery, download, +checksum and executable-verification failures. The shared visual vocabulary uses +✅ success, ❌ failure and ⚠️ warnings where applicable. Raw exceptions, URL +credentials/query strings, known secret/token patterns and absolute executable +paths are not included in summaries or notifications. Reporting errors produce a +fixed safe warning while preserving installation outputs and the original +success/failure outcome. + +After the coordinated contract/Notify upgrade has been published and validated, +replace both placeholders below with reviewed immutable Action commits: + +```yaml +- uses: commercekit-tools/setup-commercekit@REPLACE_WITH_REVIEWED_SETUP_COMMIT + id: setup + with: + version: '0.1.0-preview.2' +- uses: commercekit-tools/notify@REPLACE_WITH_COMPATIBLE_NOTIFY_COMMIT + if: ${{ always() && steps.setup.outputs.notification-event != '' }} + with: + provider: slack # or teams-workflow + event-json: ${{ steps.setup.outputs.notification-event }} + webhook-url: ${{ secrets.COMMERCEKIT_NOTIFICATION_WEBHOOK }} + fail-on-notification-error: 'false' +``` + +Older Notify versions reject the new setup event. Webhook credentials belong +only on Notify; notification delivery does not replace the setup step's outcome. + ## Supported targets The published CommerceKit CLI provides these standalone targets: diff --git a/RELEASING.md b/RELEASING.md index 9f8be57..f743932 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -18,6 +18,16 @@ This repository does not publish an npm package. major version. - CLI preview maturity does not change the Action's interface version. +## Reporting consistency gate + +The current reporting source depends on an unpublished additive +notification-contract 0.0.6 extension. Normal dependencies and committed bundle +are not a released implementation of that candidate. Publication and adoption +require separate owner authorization, registry availability/integrity +verification, exact dependency repinning, regenerated lockfile/bundle and all +normal checks. Candidate validation alone never authorizes a tag, Action release +or deployment. See [reporting-validation.md](docs/reporting-validation.md). + ## Release prerequisites Before creating a release: diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index dfe9154..e48cd9a 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -6,11 +6,17 @@ dependencies is committed as `dist/licenses.txt`. Direct runtime dependencies include: -| Dependency | License | Project | -| --------------------- | ------- | ------------------------------------ | -| `@actions/core` | MIT | | -| `@actions/tool-cache` | MIT | | -| `semver` | ISC | | +| Dependency | License | Project | +| ------------------------------------------ | ------- | ------------------------------------------------- | +| `@actions/core` | MIT | | +| `@actions/tool-cache` | MIT | | +| `semver` | ISC | | +| `@commercekit-tools/notification-contract` | MIT | | These notices do not replace the license terms shipped in the generated bundle or the dependencies' source distributions. + +The reporting-consistency candidate adds the shared contract in source while +normal committed `dist` retains the prior runtime. Candidate-generated license +notices are validated only in the isolated candidate workspace; publication and +normal bundle adoption remain separate gates. diff --git a/action.yml b/action.yml index 1af7476..d072c12 100644 --- a/action.yml +++ b/action.yml @@ -10,6 +10,9 @@ inputs: required: false outputs: + notification-event: + description: + Validated setup notification event with safe input context and outcome. version: description: Exact installed CommerceKit CLI version. path: diff --git a/docs/reporting-validation.md b/docs/reporting-validation.md new file mode 100644 index 0000000..e226e74 --- /dev/null +++ b/docs/reporting-validation.md @@ -0,0 +1,64 @@ +# Reporting consistency validation + +## Scope and provenance + +- Inspected setup main: `7f650f3162ab027bfd784cf879832f44d3248584`. +- Feature branch: `feat/consistent-action-reporting`. +- Existing version input and installation/integrity behavior remain unchanged. +- Shared APIs reused: captured input context, bounded redaction, setup event + factory/schema validation, safe GitHub context and common summary rendering. +- Normal registry baseline: notification-contract **0.0.5**, exact GitHub + Packages dependency. Registry integrity: + `sha512-SMcyyWaeHdtlztt99xtWlJycSAspSTGvCaWJH7M2+u+on77JHwo8boTBOc9UUC0BQHDPo/VHmOj4gpt/XIbqmw==`. +- New setup event/reporting helpers require unpublished **0.0.6** from + action-kit source `b88ed58976af9aee532488d82ea09340b35a4d8c`. +- Exact reviewed tarball: `commercekit-tools-notification-contract-0.0.6.tgz`, + 18852 bytes; SHA256 + `645b38e7456e63b96e7630da7d63c1f6ccbf27854b5b7001ff77db435afa2393`; integrity + `sha512-eqV2tVnjmuYtG5ExjqScPCGxxR8MW8QlbeuXMA/YjzKzujgSMf9oMQpvQDqmysGhHLN8918XtNh9gbO7UQH4Ug==`. +- Candidate consumer used an exact `file:` tarball dependency only in a private + temporary workspace. Normal manifests and lockfile contain no local links or + invented published version. Normal committed bundle remains unchanged. + +## Executed checks + +Status vocabulary: PASS, FAIL, BLOCKED, NOT RUN. + +| Check | Status | Evidence | +| ------------------------------------------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Baseline frozen installation and coverage | PASS | 143 tests/11 files; 93.75% statements, 90% branches, 93.1% functions, 93.92% lines | +| Reviewed candidate frozen installation and full quality | PASS | 154 tests/11 files; 94.06% statements, 90.29% branches, 93.54% functions, 94.21% lines; unchanged thresholds/source inclusion | +| Actual reviewed-candidate bundle integration | PASS | Public CLI preview.2 installed and verified on darwin-arm64; second invocation verified cache reuse; success and invalid-input failure event/summary checks passed | +| Candidate bundle consistency, license notices, scan and production audit | PASS | Byte consistency; public scan passed; no known vulnerabilities | +| Action/workflow lint and source formatting | PASS | Local actionlint, Prettier and git diff check | +| Normal registry/release readiness | BLOCKED | New event/helpers unavailable in published 0.0.5; requires separately authorized 0.0.6 publication and actual registry repinning/rebuild | +| Six native runner integration jobs | NOT RUN | Required on a final registry-backed implementation; local host cannot establish all six targets | +| Slack/Teams live delivery | NOT RUN | Notify owns this capability; no provider send performed | + +Commands actually executed in the isolated reviewed candidate: `pnpm install`, +`pnpm install --frozen-lockfile`, `pnpm bundle`, `pnpm quality`, +`pnpm test:integration` and local actionlint. All passed. The generated +candidate bundle SHA256 is +`f3350fba352a9e4370f50808abf0f5ae862250c4621a6511eec05645e72a4ea8`. The +candidate workspace retains a private temporary Git commit of its generated +bundle to exercise the repository's committed-dist consistency check. No +candidate bundle enters normal committed files. + +New tests cover complete input/result projection, workflow reference parity, +allowlisted GitHub environment access, safe invalid settings, JWT/URL credential +redaction, bounded truncation, malicious Markdown/HTML, successful cache reuse, +known and unknown failures, and preservation when reporting fails. The bundled +integration exercises the real entry point, verifies two successful invocations +and an invalid-input failure, and checks event outputs plus GitHub summaries in +controlled temporary files. The fixture remains an existing public CLI release; +no SAP operation is involved. + +## Remaining gates + +Package publication and consumer adoption are separate owner decisions. After +separate authorization, verify the registry artifact/public exports, pin it +exactly, regenerate normal lockfile/bundle/license notices and rerun quality, +public-release integration and all six native runners. Ensure Notify supports +`commercekit.setup.completed` before consuming its event. No OAuth, transport, +provider delivery or replacement event schema is added. No publication, release +tag, deployment, provider send or SAP operation is part of this implementation. diff --git a/package.json b/package.json index a437ad1..dddb13a 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "dependencies": { "@actions/core": "3.0.1", "@actions/tool-cache": "4.0.0", + "@commercekit-tools/notification-contract": "0.0.5", "semver": "7.8.6" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc8635a..df30e66 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,6 +172,9 @@ importers: '@actions/tool-cache': specifier: 4.0.0 version: 4.0.0 + '@commercekit-tools/notification-contract': + specifier: 0.0.5 + version: 0.0.5 semver: specifier: 7.8.6 version: 7.8.6 @@ -254,6 +257,10 @@ packages: '@cacheable/utils@2.5.0': resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==} + '@commercekit-tools/notification-contract@0.0.5': + resolution: {integrity: sha512-SMcyyWaeHdtlztt99xtWlJycSAspSTGvCaWJH7M2+u+on77JHwo8boTBOc9UUC0BQHDPo/VHmOj4gpt/XIbqmw==, tarball: https://npm.pkg.github.com/download/@commercekit-tools/notification-contract/0.0.5/dba01de9635ba79f14ac2bb477c8c828dd4d223f} + engines: {node: '>=24.21.0'} + '@eslint-community/eslint-utils@4.10.1': resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -559,9 +566,20 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -669,6 +687,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-uri@3.1.9: + resolution: {integrity: sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -743,6 +764,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -904,6 +928,10 @@ packages: resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==} engines: {node: '>=20'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + rolldown@1.2.13: resolution: {integrity: sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1136,6 +1164,11 @@ snapshots: hashery: 1.5.1 keyv: 5.6.0 + '@commercekit-tools/notification-contract@0.0.5': + dependencies: + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + '@eslint-community/eslint-utils@4.10.1(eslint@10.14.0)': dependencies: eslint: 10.14.0 @@ -1399,6 +1432,10 @@ snapshots: acorn@8.19.0: {} + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -1406,6 +1443,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.9 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + assertion-error@2.0.1: {} ast-v8-to-istanbul@1.0.7: @@ -1524,6 +1568,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-uri@3.1.9: {} + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: picomatch: 4.0.7 @@ -1580,6 +1626,8 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} keyv@5.6.0: @@ -1707,6 +1755,8 @@ snapshots: dependencies: hookified: 2.2.0 + require-from-string@2.0.2: {} + rolldown@1.2.13: dependencies: '@oxc-project/types': 0.153.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..6d49df2 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +minimumReleaseAgeExclude: + - '@commercekit-tools/notification-contract@0.0.5' diff --git a/scripts/integration.mjs b/scripts/integration.mjs index 5763ff5..8c5b8f0 100644 --- a/scripts/integration.mjs +++ b/scripts/integration.mjs @@ -2,11 +2,13 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { spawnSync } from 'node:child_process'; +import assert from 'node:assert/strict'; +import { parseNotificationEventJson } from '@commercekit-tools/notification-contract'; const VERSION = '0.1.0-preview.2'; const root = new URL('..', import.meta.url); -function runAction(environment) { +function runAction(environment, expectedExitCode = 0) { const result = spawnSync(process.execPath, ['dist/index.js'], { cwd: root, encoding: 'utf8', @@ -15,7 +17,7 @@ function runAction(environment) { windowsHide: true, }); if (result.error) throw result.error; - if (result.status !== 0) { + if (result.status !== expectedExitCode) { process.stderr.write(result.stdout); process.stderr.write(result.stderr); throw new Error('Bundled Action integration invocation failed.'); @@ -72,16 +74,28 @@ try { }; const firstOutput = join(directory, 'first-output'); + const firstSummary = join(directory, 'first-summary'); await writeFile(firstOutput, ''); - runAction({ ...baseEnvironment, GITHUB_OUTPUT: firstOutput }); + await writeFile(firstSummary, ''); + runAction({ + ...baseEnvironment, + GITHUB_OUTPUT: firstOutput, + GITHUB_STEP_SUMMARY: firstSummary, + }); const first = parseFileCommands(await readFile(firstOutput, 'utf8')); if (first.get('cache-hit') !== 'false') { throw new Error('First isolated invocation was not a cache miss.'); } const secondOutput = join(directory, 'second-output'); + const secondSummary = join(directory, 'second-summary'); await writeFile(secondOutput, ''); - runAction({ ...baseEnvironment, GITHUB_OUTPUT: secondOutput }); + await writeFile(secondSummary, ''); + runAction({ + ...baseEnvironment, + GITHUB_OUTPUT: secondOutput, + GITHUB_STEP_SUMMARY: secondSummary, + }); const second = parseFileCommands(await readFile(secondOutput, 'utf8')); if (second.get('cache-hit') !== 'true') { throw new Error('Second isolated invocation was not a verified cache hit.'); @@ -94,6 +108,68 @@ try { } runBinary(executablePath, ['--help']); + for (const [output, summaryFile, expectedCacheHit] of [ + [first, firstSummary, false], + [second, secondSummary, true], + ]) { + const event = parseNotificationEventJson(output.get('notification-event')); + assert.equal(event.eventType, 'commercekit.setup.completed'); + assert.equal(event.status, 'success'); + assert.equal(event.source.component, 'setup-commercekit'); + assert.equal(event.data.version, VERSION); + assert.equal(event.data.target, output.get('target')); + assert.equal(event.data.buildId, output.get('build-id')); + assert.equal(event.data.cacheHit, expectedCacheHit); + assert.equal(event.data.inputsValidated, true); + assert.equal(event.data.inputsTruncated, false); + assert.deepEqual(event.data.inputs, { version: VERSION }); + assert( + Number.isSafeInteger(event.data.durationMs) && event.data.durationMs >= 0, + ); + assert(!JSON.stringify(event).includes(directory)); + const summary = await readFile(summaryFile, 'utf8'); + for (const detail of [ + '✅', + 'Succeeded', + 'Inputs', + VERSION, + output.get('target'), + output.get('build-id'), + ]) + assert(summary.includes(detail)); + assert(!summary.includes(directory)); + } + + const failedOutput = join(directory, 'failed-output'); + const failedSummary = join(directory, 'failed-summary'); + await writeFile(failedOutput, ''); + await writeFile(failedSummary, ''); + const invalidToken = ['eyJsynthetic', 'payload', 'signature'].join('.'); + const failedLogs = runAction( + { + ...baseEnvironment, + INPUT_VERSION: invalidToken, + GITHUB_OUTPUT: failedOutput, + GITHUB_STEP_SUMMARY: failedSummary, + }, + 1, + ); + const failed = parseFileCommands(await readFile(failedOutput, 'utf8')); + const failureEvent = parseNotificationEventJson( + failed.get('notification-event'), + ); + assert.equal(failureEvent.eventType, 'commercekit.setup.completed'); + assert.equal(failureEvent.status, 'failure'); + assert.equal(failureEvent.data.inputsValidated, false); + assert.deepEqual(failureEvent.data.inputs, { version: '[REDACTED]' }); + assert.equal(failureEvent.data.error.code, 'invalid-input'); + assert.equal(failed.get('version'), undefined); + const failureSummary = await readFile(failedSummary, 'utf8'); + assert(failureSummary.includes('❌') && failureSummary.includes('Failed')); + assert(!JSON.stringify(failureEvent).includes(invalidToken)); + assert(!failureSummary.includes(invalidToken)); + assert(!failedLogs.includes(invalidToken)); + process.stdout.write( `${JSON.stringify( { @@ -101,6 +177,9 @@ try { target: second.get('target'), buildId: second.get('build-id'), cacheHit: second.get('cache-hit'), + notificationContextsVerified: true, + summariesVerified: true, + handledFailureVerified: true, }, null, 2, diff --git a/src/main.ts b/src/main.ts index 4d521a3..98c852e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,12 +1,15 @@ import * as core from '@actions/core'; +import { captureActionInputs } from '@commercekit-tools/notification-contract'; import { readInputs } from './inputs.js'; import { executableDirectory, installCommerceKit, type InstallDependencies, + type InstallResult, } from './install.js'; import { resolveTarget } from './platform.js'; +import { reportSetup, setupInputDefinitions } from './reporting.js'; import { resolveCommerceKitVersion, type VersionResolutionDependencies, @@ -18,45 +21,89 @@ export interface ActionDependencies { readonly installDependencies?: Partial; readonly resolveVersion?: typeof resolveCommerceKitVersion; readonly versionResolutionDependencies?: Partial; + readonly now?: () => number; } export async function runAction( dependencies: ActionDependencies = {}, ): Promise { - const inputs = readInputs(); - const target = resolveTarget(); - const install = dependencies.install ?? installCommerceKit; - const resolveVersion = - dependencies.resolveVersion ?? resolveCommerceKitVersion; - const resolvesLatestStable = - inputs.version === undefined || inputs.version === LATEST_STABLE_VERSION; + const now = dependencies.now ?? Date.now; + const started = now(); + const occurredAt = new Date(started).toISOString(); + let rawInputs: Record = {}; + let inputsValidated = false; + let requestedVersion: string | undefined; + let target: string | undefined; + let result: InstallResult | undefined; + let failed = false; + let originalError: unknown; - core.info( - resolvesLatestStable - ? 'Resolving latest stable CommerceKit CLI' - : `Resolving CommerceKit CLI ${inputs.version}`, - ); - core.info(`Runner target: ${target.target}`); - const version = await resolveVersion( - inputs.version, - dependencies.versionResolutionDependencies, - ); - if (resolvesLatestStable) { - core.info(`Resolved latest stable CommerceKit CLI ${version}`); - } - const result = await install(version, target, { - log: core.info, - debug: core.debug, - ...dependencies.installDependencies, - }); + try { + rawInputs = captureActionInputs( + setupInputDefinitions, + (name) => core.getInput(name, { required: false, trimWhitespace: false }), + core.setSecret, + ); + const inputs = readInputs((name) => rawInputs[name] ?? ''); + inputsValidated = true; + requestedVersion = inputs.version; + const definition = resolveTarget(); + target = definition.target; + const install = dependencies.install ?? installCommerceKit; + const resolveVersion = + dependencies.resolveVersion ?? resolveCommerceKitVersion; + const resolvesLatestStable = + inputs.version === undefined || inputs.version === LATEST_STABLE_VERSION; + + core.info( + resolvesLatestStable + ? 'Resolving latest stable CommerceKit CLI' + : `Resolving CommerceKit CLI ${inputs.version}`, + ); + core.info(`Runner target: ${definition.target}`); + const version = await resolveVersion( + inputs.version, + dependencies.versionResolutionDependencies, + ); + if (resolvesLatestStable) { + core.info(`Resolved latest stable CommerceKit CLI ${version}`); + } + result = await install(version, definition, { + log: core.info, + debug: core.debug, + ...dependencies.installDependencies, + }); - core.addPath(executableDirectory(result)); - core.setOutput('version', result.version); - core.setOutput('path', result.executablePath); - core.setOutput('target', result.target); - core.setOutput('build-id', result.buildId); - core.setOutput('cache-hit', result.cacheHit ? 'true' : 'false'); + core.addPath(executableDirectory(result)); + core.setOutput('version', result.version); + core.setOutput('path', result.executablePath); + core.setOutput('target', result.target); + core.setOutput('build-id', result.buildId); + core.setOutput('cache-hit', result.cacheHit ? 'true' : 'false'); - core.info(`Installed CommerceKit CLI ${result.version}`); - core.info('Added CommerceKit CLI to PATH'); + core.info(`Installed CommerceKit CLI ${result.version}`); + core.info('Added CommerceKit CLI to PATH'); + } catch (error) { + failed = true; + originalError = error; + throw error; + } finally { + try { + await reportSetup({ + rawInputs, + inputsValidated, + ...(requestedVersion ? { requestedVersion } : {}), + ...(target ? { target } : {}), + ...(result ? { result } : {}), + failed, + ...(failed ? { error: originalError } : {}), + occurredAt, + durationMs: Math.max(0, Math.floor(now() - started)), + }); + } catch { + core.warning( + 'Setup reporting could not be completed; the installation outcome is preserved.', + ); + } + } } diff --git a/src/reporting.ts b/src/reporting.ts new file mode 100644 index 0000000..88e902b --- /dev/null +++ b/src/reporting.ts @@ -0,0 +1,136 @@ +import * as core from '@actions/core'; +import { + createActionInputContext, + createSetupEvent, + extractSafeGitHubContext, + sanitizeActionText, + redactJwtLikeTokens, + renderActionSummary, + sanitizeErrorMessage, + validateNotificationEvent, +} from '@commercekit-tools/notification-contract'; + +import { safeErrorMessage } from './errors.js'; +import type { InstallResult } from './install.js'; + +export const setupInputDefinitions = [{ name: 'version' }] as const; + +export interface SetupOutcome { + readonly rawInputs: Readonly>; + readonly inputsValidated: boolean; + readonly requestedVersion?: string; + readonly target?: string; + readonly result?: InstallResult; + readonly failed: boolean; + readonly error?: unknown; + readonly occurredAt: string; + readonly durationMs: number; +} + +/** Shared contracts own sanitization, schema validation and summary formatting. */ +export async function reportSetup( + outcome: SetupOutcome, + env: Readonly> = process.env, +): Promise { + const context = Object.fromEntries( + Object.entries(extractSafeGitHubContext(env)).flatMap(([name, value]) => + typeof value === 'string' + ? name === 'runUrl' && sanitizeActionText(value, 2048) !== value + ? [] + : [[name, sanitizeActionText(value, 2048)]] + : [[name, value]], + ), + ); + const inputContext = createActionInputContext( + setupInputDefinitions, + outcome.rawInputs, + { + validated: outcome.inputsValidated, + ...(outcome.inputsValidated + ? { effective: { version: outcome.requestedVersion ?? 'latest' } } + : {}), + }, + ); + const status = outcome.failed ? 'failure' : 'success'; + const error = outcome.failed + ? { + code: outcome.inputsValidated ? 'setup-failed' : 'invalid-input', + message: sanitizeErrorMessage( + redactJwtLikeTokens(safeErrorMessage(outcome.error)), + ), + } + : undefined; + const description = outcome.failed + ? 'CommerceKit CLI setup did not complete.' + : 'CommerceKit CLI was installed and verified.'; + + try { + const event = createSetupEvent({ + status, + occurredAt: outcome.occurredAt, + context, + env: {}, + data: { + ...inputContext, + ...(inputContext.inputs['version'] + ? { requestedVersion: inputContext.inputs['version'] } + : {}), + ...(outcome.target + ? { target: sanitizeActionText(outcome.target, 128) } + : {}), + ...(outcome.result + ? { + version: sanitizeActionText(outcome.result.version, 128), + buildId: sanitizeActionText(outcome.result.buildId, 128), + cacheHit: outcome.result.cacheHit, + } + : {}), + durationMs: outcome.durationMs, + ...(error ? { error } : {}), + }, + }); + core.setOutput( + 'notification-event', + JSON.stringify(validateNotificationEvent(event)), + ); + } catch { + core.warning( + 'Setup notification could not be created; the installation outcome is preserved.', + ); + } + + try { + const summary = renderActionSummary({ + title: 'CommerceKit CLI Setup', + status, + description, + inputContext, + facts: [ + { label: 'Duration (ms)', value: outcome.durationMs }, + ...(outcome.target ? [{ label: 'Target', value: outcome.target }] : []), + ...(outcome.result + ? [ + { + label: 'Verified CLI version', + value: sanitizeActionText(outcome.result.version, 128), + }, + { + label: 'Build identity', + value: sanitizeActionText(outcome.result.buildId, 128), + }, + { label: 'Cache reused', value: outcome.result.cacheHit }, + ] + : []), + ], + ...(error ? { error } : {}), + ...(typeof context['runUrl'] === 'string' + ? { links: [{ label: 'View workflow', url: context['runUrl'] }] } + : {}), + }); + await core.summary.addRaw(summary).write(); + } catch { + core.warning( + 'Setup job summary could not be written; the installation outcome is preserved.', + ); + } +} diff --git a/tests/main.test.ts b/tests/main.test.ts index 45061c2..5ef49f8 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -1,10 +1,13 @@ import { join } from 'node:path'; import * as core from '@actions/core'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { parseNotificationEventJson } from '@commercekit-tools/notification-contract'; import { runAction } from '../src/main.js'; import { resolveTarget } from '../src/platform.js'; +import { SetupError } from '../src/errors.js'; +import { reportSetup } from '../src/reporting.js'; vi.mock('@actions/core', () => ({ addPath: vi.fn(), @@ -12,10 +15,23 @@ vi.mock('@actions/core', () => ({ getInput: vi.fn(() => '0.1.0-preview.2'), info: vi.fn(), setOutput: vi.fn(), + setSecret: vi.fn(), + warning: vi.fn(), + summary: { + addRaw: vi.fn().mockReturnThis(), + write: vi.fn(async () => undefined), + }, })); describe('runAction', () => { - afterEach(() => vi.clearAllMocks()); + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(core.getInput).mockReset().mockReturnValue('0.1.0-preview.2'); + vi.mocked(core.setOutput).mockReset(); + vi.mocked(core.summary.addRaw).mockReset().mockReturnValue(core.summary); + vi.mocked(core.summary.write).mockReset().mockResolvedValue(core.summary); + }); + afterEach(() => vi.restoreAllMocks()); it('adds the executable directory and sets trusted outputs', async () => { const executablePath = join('/tool-cache', 'commercekit'); @@ -41,6 +57,26 @@ describe('runAction', () => { expect(core.setOutput).toHaveBeenCalledWith('target', target.target); expect(core.setOutput).toHaveBeenCalledWith('build-id', 'b'.repeat(40)); expect(core.setOutput).toHaveBeenCalledWith('cache-hit', 'true'); + expect(core.getInput).toHaveBeenCalledTimes(1); + const event = notification(); + expect(event.status).toBe('success'); + expect(event.data).toMatchObject({ + version: '0.1.0-preview.2', + target: target.target, + buildId: 'b'.repeat(40), + cacheHit: true, + inputs: { version: '0.1.0-preview.2' }, + inputsValidated: true, + inputsTruncated: false, + }); + expect(event.data).not.toHaveProperty('path'); + expect(summary()).toContain('✅'); + expect(summary()).toContain('Succeeded'); + expect(summary()).toContain('|'); + expect(summary()).toContain('Verified CLI version'); + expect(summary()).toContain('0.1.0-preview.2'); + expect(summary()).not.toContain(executablePath); + expect(core.warning).not.toHaveBeenCalled(); }); it('resolves the latest stable version when the input is omitted', async () => { @@ -60,6 +96,12 @@ describe('runAction', () => { expect(resolveVersion).toHaveBeenCalledWith(undefined, undefined); expect(install).toHaveBeenCalledWith('1.0.0', target, expect.any(Object)); expect(core.setOutput).toHaveBeenCalledWith('version', '1.0.0'); + expect(notification().data).toMatchObject({ + requestedVersion: 'latest', + inputs: { version: 'latest' }, + inputsValidated: true, + cacheHit: false, + }); }); it('resolves the latest stable version when explicitly requested', async () => { @@ -85,4 +127,279 @@ describe('runAction', () => { 'Resolved latest stable CommerceKit CLI 1.0.0', ); }); + + it('shares the safe workflow reference between the event and summary without reading unrelated environment values', async () => { + const accessed: string[] = []; + const values: Record = { + GITHUB_REPOSITORY: 'synthetic/setup', + GITHUB_RUN_ID: '42', + GITHUB_SERVER_URL: 'https://github.example.test', + GITHUB_WORKFLOW: 'Setup integration', + GITHUB_ACTOR: 'synthetic-actor', + }; + const env = new Proxy(values, { + get: (target, name: string) => { + accessed.push(name); + return target[name]; + }, + }); + await reportSetup( + { + rawInputs: { version: 'latest' }, + inputsValidated: true, + requestedVersion: 'latest', + failed: false, + occurredAt: '2026-11-14T12:00:00Z', + durationMs: 1, + }, + env, + ); + expect(notification().context).toMatchObject({ + repository: 'synthetic/setup', + runUrl: 'https://github.example.test/synthetic/setup/actions/runs/42', + }); + expect(summary()).toContain( + 'https://github.example.test/synthetic/setup/actions/runs/42', + ); + expect(accessed).not.toContain('GITHUB_TOKEN'); + expect(accessed).not.toContain('UNRELATED_SECRET'); + expect(accessed.every((name) => name.startsWith('GITHUB_'))).toBe(true); + }); + + it('omits unsafe workflow URLs and redacts untrusted display metadata in event and summary', async () => { + const token = ['eyJsynthetic', 'payload', 'signature'].join('.'); + await reportSetup( + { + rawInputs: { version: 'latest' }, + inputsValidated: true, + requestedVersion: 'latest', + target: token, + result: { + version: token, + target: 'darwin-arm64', + buildId: + 'https://user:password@invalid.example.test/metadata?private=yes', + cacheHit: false, + executablePath: '/private/tool-cache/commercekit', + }, + failed: false, + occurredAt: '2026-11-14T12:00:00Z', + durationMs: 1, + }, + { + GITHUB_REPOSITORY: 'synthetic/setup', + GITHUB_RUN_ID: '42', + GITHUB_SERVER_URL: 'https://user:password@github.example.test', + }, + ); + const event = notification(); + expect(event.context?.runUrl).toBeUndefined(); + expect(event.data).toMatchObject({ + version: '[REDACTED]', + target: '[REDACTED]', + buildId: '[REDACTED URL]', + }); + const text = JSON.stringify(event) + summary(); + for (const value of [ + token, + 'user:password', + 'private=yes', + '/private/tool-cache/commercekit', + ]) + expect(text).not.toContain(value); + }); + + it('reports invalid input without starting installation or exposing token-like values', async () => { + const token = ['eyJsynthetic', 'payload', 'signature'].join('.'); + vi.mocked(core.getInput).mockReturnValueOnce(token); + const install = vi.fn(); + await expect(runAction({ install })).rejects.toThrow( + 'version input must be', + ); + expect(install).not.toHaveBeenCalled(); + expect(core.setOutput).not.toHaveBeenCalledWith( + 'version', + expect.anything(), + ); + const event = notification(); + expect(event.status).toBe('failure'); + expect(event.data.inputsValidated).toBe(false); + expect(event.data.inputs?.['version']).toBe('[REDACTED]'); + expect(event.data.error).toMatchObject({ code: 'invalid-input' }); + expect(JSON.stringify(event)).not.toContain(token); + expect(summary()).toContain('❌'); + expect(summary()).toContain('Failed'); + expect(summary()).not.toContain(token); + }); + + it('masks credentials, query and fragment in an invalid version URL', async () => { + vi.mocked(core.getInput).mockReturnValueOnce( + 'https://synthetic-user:synthetic-password@invalid.example.test/asset?private-value=yes#private-fragment', + ); + await expect(runAction({ install: vi.fn() })).rejects.toThrow( + 'version input must be', + ); + const rendered = JSON.stringify(notification()) + summary(); + for (const secret of [ + 'synthetic-user', + 'synthetic-password', + 'private-value', + 'private-fragment', + ]) + expect(rendered).not.toContain(secret); + }); + + it('reports checked installation errors while preserving the original failure', async () => { + const failure = new SetupError('Archive checksum mismatch.'); + const install = vi.fn(async () => { + throw failure; + }); + const now = vi + .fn() + .mockReturnValueOnce(Date.parse('2026-11-14T12:00:00Z')) + .mockReturnValueOnce(Date.parse('2026-11-14T12:00:00.125Z')); + await expect(runAction({ install, now })).rejects.toBe(failure); + const event = notification(); + expect(event).toMatchObject({ + eventType: 'commercekit.setup.completed', + status: 'failure', + occurredAt: '2026-11-14T12:00:00.000Z', + source: { component: 'setup-commercekit' }, + data: { + requestedVersion: '0.1.0-preview.2', + target: resolveTarget().target, + inputsValidated: true, + durationMs: 125, + error: { code: 'setup-failed', message: 'Archive checksum mismatch.' }, + }, + }); + expect(event.data).not.toHaveProperty('version'); + expect(event.data).not.toHaveProperty('buildId'); + expect(summary()).toContain('Archive checksum mismatch.'); + expect(core.summary.write).toHaveBeenCalledOnce(); + }); + + it('does not expose arbitrary download/runtime exceptions', async () => { + const failure = new Error('private-network-payload'); + await expect( + runAction({ + install: async () => { + throw failure; + }, + }), + ).rejects.toBe(failure); + expect(JSON.stringify(notification())).not.toContain( + 'private-network-payload', + ); + expect(summary()).not.toContain('private-network-payload'); + expect(notification().data.error?.message).toContain('failed unexpectedly'); + }); + + it('reports latest-version discovery failure before installation', async () => { + vi.mocked(core.getInput).mockReturnValueOnce(''); + const install = vi.fn(); + const failure = new SetupError( + 'No stable CommerceKit release is available.', + ); + await expect( + runAction({ + install, + resolveVersion: async () => { + throw failure; + }, + }), + ).rejects.toBe(failure); + expect(install).not.toHaveBeenCalled(); + expect(notification().status).toBe('failure'); + expect(notification().data.inputs).toEqual({ version: 'latest' }); + expect(summary()).toContain('No stable CommerceKit release is available.'); + }); + + it('preserves a thrown undefined value as failure rather than reporting success', async () => { + await expect( + runAction({ + install: vi.fn().mockRejectedValueOnce(undefined), + }), + ).rejects.toBeUndefined(); + expect(notification().status).toBe('failure'); + expect(notification().data.error?.message).toContain('failed unexpectedly'); + }); + + it('keeps notification output when writing the summary fails', async () => { + vi.mocked(core.summary.write).mockRejectedValueOnce( + new Error('private-summary-error'), + ); + await expect( + runAction({ install: verifiedInstall }), + ).resolves.toBeUndefined(); + expect(notification().status).toBe('success'); + expect(core.warning).toHaveBeenCalledWith( + 'Setup job summary could not be written; the installation outcome is preserved.', + ); + expect(JSON.stringify(vi.mocked(core.warning).mock.calls)).not.toContain( + 'private-summary-error', + ); + }); + + it('keeps the original installation failure if notification and summary writing both fail', async () => { + const failure = new SetupError('Executable verification failed.'); + vi.mocked(core.setOutput).mockImplementation((name) => { + if (name === 'notification-event') + throw new Error('private-output-error'); + }); + vi.mocked(core.summary.write).mockRejectedValueOnce( + new Error('private-summary-error'), + ); + await expect( + runAction({ + install: async () => { + throw failure; + }, + }), + ).rejects.toBe(failure); + expect(core.warning).toHaveBeenCalledWith( + 'Setup notification could not be created; the installation outcome is preserved.', + ); + expect(core.warning).toHaveBeenCalledWith( + 'Setup job summary could not be written; the installation outcome is preserved.', + ); + }); + + it('escapes malformed input in the failure summary and marks long input truncated', async () => { + vi.mocked(core.getInput).mockReturnValueOnce( + ' | injected\n' + 'a'.repeat(400), + ); + await expect(runAction({ install: vi.fn() })).rejects.toThrow( + 'version input must be', + ); + expect(notification().data.inputsTruncated).toBe(true); + expect(notification().data.inputs?.['version']?.length).toBeLessThanOrEqual( + 256, + ); + expect(summary()).not.toContain('