From ac11ddc649c22e810459139109e1b16623ef9f86 Mon Sep 17 00:00:00 2001 From: Pandelis Zembashis Date: Sun, 4 Oct 2026 21:40:31 -0700 Subject: [PATCH 1/2] Accept project and optional PR or branch preview context --- CHANGELOG.md | 8 ++++ README.md | 66 +++++++++++++++++++++------ action.yml | 10 +++- index.mjs | 9 +++- verify.mjs | 81 ++++++++++++++++++++++++-------- verify.test.mjs | 119 ++++++++++++++++++++++++++++++++++++++++++++---- 6 files changed, 247 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c766e26..397ba15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +- Assign preview URLs to a Composal project and track the PR number or source branch. +- Auto-detect PR, workflow-run, and branch-push context; register branch previews even before an open PR exists. +- Confirm the requested project and deployed branch, and publish the registered environment identity. +- Document project environment listing and automatic archival after PR merge. + + ## 1 - Request PR verification against a ready CI preview or a configured Composal-managed preview. diff --git a/README.md b/README.md index 3cfabc8..fcacd30 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Test the product journeys affected by your PR against its deployed preview. Veri with: token: ${{ secrets.COMPOSAL_TOKEN }} org: ${{ vars.COMPOSAL_ORG }} + project: ${{ vars.COMPOSAL_PROJECT }} preview-url: ${{ steps.deploy.outputs.url }} ``` @@ -14,7 +15,24 @@ Configure the connected repository once in **Verify → PR verification**. Selec Run this step after deploying **`github.event.pull_request.head.sha`** and waiting for the preview to become reachable. GitHub's `github.sha` can be a synthetic merge commit; Verify deliberately uses the PR head. Your existing deployment provider supplies the URL; this action does not build an arbitrary app. -The action infers the PR number and repository name. Set `repository` if the connected Composal repository has a different slug. It needs no checkout, installed CLI, or GitHub token: Composal's connected GitHub App maintains the results comment. +Set `COMPOSAL_PROJECT` to your Composal project slug or public ID. Each URL handoff automatically registers a preview in that project’s **Environments** page, labelled with its PR number and branch. Repeated handoffs reuse the environment; new requests keep separate preview records. When the connected GitHub App observes the PR merge, Composal archives its generated previews after active verification finishes. Use **Show Archived** to view their retained history. This does not delete the deployment at your provider. + +The action accepts either `pr` or `branch`; both are optional when GitHub supplies the context. It infers PR numbers from PR events and source branches from PR, `workflow_run`, or branch `push` events. Set `repository` if the connected Composal repository has a different slug. It needs no checkout, installed CLI, or GitHub token: Composal's connected GitHub App maintains the results comment. + +For a branch deployment with no open PR, supply the preview URL and project. The action registers the environment and succeeds with `status: preview_registered` and an `environment-id`; it does not report a browser test pass. When a PR later opens for that branch, Verify links the branch previews to it and archives them after merge. If a branch has multiple open PRs, supply `pr` explicitly. Branches registered in multiple projects need an explicit project selection before they can be linked automatically. + +```yaml +# After a deployment in a branch push workflow; branch and SHA are auto-detected. +- uses: composalai/verify@1 + with: + token: ${{ secrets.COMPOSAL_TOKEN }} + org: ${{ vars.COMPOSAL_ORG }} + project: ${{ vars.COMPOSAL_PROJECT }} + preview-url: ${{ steps.deploy.outputs.url }} + # For another event type, set branch OR pr and the deployed sha explicitly: + # branch: feature/checkout + # sha: +``` ## Composal-hosted previews @@ -25,6 +43,7 @@ If the repository is configured to **Build a Composal preview**, leave out `prev with: token: ${{ secrets.COMPOSAL_TOKEN }} org: ${{ vars.COMPOSAL_ORG }} + project: ${{ vars.COMPOSAL_PROJECT }} ``` For a pipeline that already readies a Verify preview environment, pass its slug with `environment` instead of `preview-url`. CI must attest the deployed commit; Composal-managed targets also validate their healthy deployments against that SHA. @@ -55,6 +74,7 @@ jobs: with: token: ${{ secrets.COMPOSAL_TOKEN }} org: ${{ vars.COMPOSAL_ORG }} + project: ${{ vars.COMPOSAL_PROJECT }} ``` The fork condition avoids running a secret-dependent step when GitHub withholds secrets. Keep your deployment and secret use on trusted workflow events; do not switch to `pull_request_target` just to expose secrets to fork code. @@ -63,20 +83,22 @@ By default the action waits up to 15 minutes. Passed or explicitly skipped reque ## Inputs and outputs -| Input | Default | Meaning | -| ------------- | ---------------------- | ----------------------------------------------------------------------------------------------- | -| `token` | Required | Composal API token, supplied through a secret. | -| `org` | Required | Composal organization slug. | -| `preview-url` | Omitted | Ready URL deployed from this PR head. Requires CI preview mode and an external Verify template. | -| `environment` | Omitted | Ready Verify preview environment slug; mutually exclusive with `preview-url`. | -| `repository` | GitHub repository name | Connected Composal repository slug. | -| `pr` | PR event | PR number. `workflow_run` works when it identifies one PR. | -| `sha` | PR head | Full deployed PR head SHA. Supply it with `pr` on other event types. | -| `wait` | `'true'` | Wait for results; `'false'` only requests verification. | -| `timeout` | `'15'` | Wait limit in minutes, between 1 and 60. Set the job timeout higher. | -| `api-url` | `https://composal.ai` | HTTPS API origin, for installations using another endpoint. | - -Outputs: `url` (PR verification history), `pull-request-id`, `run-id`, `sweep-id` (when started), and `status`. The workflow summary links to the full results. Critical/high issue recordings remain authenticated links in Verify. +| Input | Default | Meaning | +| ------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `token` | Required | Composal API token, supplied through a secret. | +| `org` | Required | Composal organization slug. | +| `preview-url` | Omitted | Ready URL deployed from this PR head or branch commit. Requires CI preview mode and an external Verify template. | +| `environment` | Omitted | Ready Verify preview environment slug; mutually exclusive with `preview-url`. | +| `repository` | GitHub repository name | Connected Composal repository slug. | +| `project` | Template’s project | Composal project slug or public ID where PR previews are listed. Set explicitly when the template has no project. | +| `branch` | GitHub source branch | Optional alternative to `pr`. Resolves an open PR or registers a branch preview when none exists. | +| `pr` | PR event | Optional alternative to `branch`. `workflow_run` works when it identifies one PR. | +| `sha` | PR head or branch push | Full deployed commit SHA. PR heads and branch pushes are inferred; supply it for other event types. | +| `wait` | `'true'` | Wait for results; `'false'` only requests verification. | +| `timeout` | `'15'` | Wait limit in minutes, between 1 and 60. Set the job timeout higher. | +| `api-url` | `https://composal.ai` | HTTPS API origin, for installations using another endpoint. | + +Outputs: `url` (PR history or registered branch environment), `pull-request-id`, `run-id`, `sweep-id` (when started), `environment-id` (branch registration), and `status`. The workflow summary links to the full results. Critical/high issue recordings remain authenticated links in Verify. Retries within the same workflow attempt reuse the same verification request. Rerunning the workflow creates a fresh request. A workflow for an obsolete head cannot start a run for the newer head. @@ -85,3 +107,17 @@ Retries within the same workflow attempt reuse the same verification request. Re This directory is the complete dependency-free action distribution. It runs on GitHub's [Node 24 action runtime](https://docs.github.com/en/actions/reference/workflows-and-actions/metadata-syntax#runs-for-javascript-actions). No build or dependency installation is needed by consumers. The `1` tag is the supported major release. To publish an update from the Vex monorepo, copy `action.yml`, `index.mjs`, `verify.mjs`, the tests, and this README to that repository's root. Run `node --test verify.test.mjs`, commit the reviewed files, and create the `1` release tag at that commit. Update that major tag deliberately for compatible releases; use immutable commit pins where your workflow requires them. + +## GitLab CI + +For GitLab.com merge request pipelines, include +`https://composal.ai/ci/verify/v1/gitlab.yml` and extend `.composal-verify` +after your preview deployment. The copyable configuration in Verify → PR & MR +Testing includes direct MR pipeline rules and the connected Composal repo slug. +The helper reads GitLab's IID and source SHA, hands off the exact preview, waits +for its own verification run, and writes safe result IDs/links to +`composal-verify.json`. + +See [GitLab setup and CI variables](https://composal.ai/docs/verify/source-control/gitlab#gitlab-ci) +for dotenv handoff, fork and synthetic-commit restrictions, merge protection, +and CLI/API examples. diff --git a/action.yml b/action.yml index b8fc57c..dff09dc 100644 --- a/action.yml +++ b/action.yml @@ -17,10 +17,14 @@ inputs: description: Ready Verify preview environment slug, instead of preview-url. repository: description: Composal repository slug. Defaults to the GitHub repository name. + project: + description: Composal project slug or ID for PR preview environments. Defaults to the configured template's project. pr: - description: PR number. Inferred from pull_request or a single-PR workflow_run event. + description: Optional PR number. Inferred from pull_request or a single-PR workflow_run event. Supply pr or branch. + branch: + description: Optional deployed source branch. Inferred from PR, workflow_run, or push context. Registers a project preview even without an open PR. sha: - description: Exact deployed PR head SHA. Inferred from the PR event, never the merge commit. + description: Exact deployed source SHA. Inferred from the PR head or branch push, never a PR merge commit. wait: description: Wait for verification results and fail on unsuccessful outcomes. default: 'true' @@ -41,6 +45,8 @@ outputs: description: Browser sweep identity, when started. status: description: Verification state or terminal outcome. queued means accepted, not passed. + environment-id: + description: Registered Verify environment identity for a branch preview without an open PR. runs: using: node24 main: index.mjs diff --git a/index.mjs b/index.mjs index 4d2837f..f2ba208 100644 --- a/index.mjs +++ b/index.mjs @@ -11,7 +11,9 @@ const inputs = Object.fromEntries( 'preview-url', 'environment', 'repository', + 'project', 'pr', + 'branch', 'sha', 'wait', 'timeout', @@ -27,6 +29,10 @@ try { runId: process.env.GITHUB_RUN_ID, runAttempt: process.env.GITHUB_RUN_ATTEMPT, job: process.env.GITHUB_JOB, + eventName: process.env.GITHUB_EVENT_NAME, + refType: process.env.GITHUB_REF_TYPE, + refName: process.env.GITHUB_REF_NAME, + sha: process.env.GITHUB_SHA, }) outputs = await verify(config, { publish: async (values) => { @@ -47,9 +53,10 @@ try { } finally { if (outputs && process.env.GITHUB_STEP_SUMMARY) { const status = outputs.status.replace(/[^a-z_]/g, '') + const label = outputs['pull-request-id'] ? 'PR verification history' : 'Preview environment' await appendFile( process.env.GITHUB_STEP_SUMMARY, - `### Composal Verify\n\n**${status}** · [PR verification history](${outputs.url})\n\nFindings, recordings, and feedback are available in Verify and the GitHub results comment.\n` + `### Composal Verify\n\n**${status}** · [${label}](${outputs.url})\n\n${outputs['pull-request-id'] ? 'Findings, recordings, and feedback are available in Verify and the GitHub results comment.' : 'The branch preview is registered in your project. No PR verification run was requested.'}\n` ) } } diff --git a/verify.mjs b/verify.mjs index b4da29b..74c8455 100644 --- a/verify.mjs +++ b/verify.mjs @@ -4,16 +4,36 @@ export function configuration(inputs, event, context) { const pr = event.pull_request ?? (event.workflow_run?.pull_requests?.length === 1 ? event.workflow_run.pull_requests[0] : null) - const number = Number(String(inputs.pr || pr?.number || '').replace(/^#/, '')) - if (!inputs.sha && event.workflow_run?.head_sha && pr?.head?.sha && - event.workflow_run.head_sha !== pr.head.sha) { - throw new Error('workflow_run has different workflow and PR head SHAs. Set sha to the exact commit deployed.') + const rawNumber = String(inputs.pr || pr?.number || '').replace(/^#/, '') + const number = rawNumber ? Number(rawNumber) : undefined + if ( + !inputs.sha && + event.workflow_run?.head_sha && + pr?.head?.sha && + event.workflow_run.head_sha !== pr.head.sha + ) { + throw new Error( + 'workflow_run has different workflow and PR head SHAs. Set sha to the exact commit deployed.' + ) } - const sha = inputs.sha || pr?.head?.sha || event.workflow_run?.head_sha - if (!inputs.token) throw new Error('Set token to a Composal API token stored in a GitHub secret.') + const sha = + inputs.sha || + pr?.head?.sha || + event.workflow_run?.head_sha || + (context.eventName === 'push' && context.refType === 'branch' ? context.sha : undefined) + const branch = + inputs.branch || + pr?.head?.ref || + event.workflow_run?.head_branch || + (!number && context.refType === 'branch' ? context.refName : undefined) + if (!inputs.token) throw new Error('Set token to a Composal API token stored as a CI secret.') if (!inputs.org) throw new Error('Set org to your Composal organization slug.') - if (!Number.isSafeInteger(number) || number < 1 || !/^[0-9a-f]{40,64}$/.test(sha ?? '')) { - throw new Error('Run on a PR event, or supply pr and sha for the exact deployed PR head.') + if ( + (rawNumber && (!Number.isSafeInteger(number) || number < 1)) || + (!number && !branch) || + !/^[0-9a-f]{40,64}$/.test(sha ?? '') + ) { + throw new Error('Supply a PR number or source branch and the exact deployed source commit.') } if (inputs['preview-url'] && inputs.environment) throw new Error('Supply preview-url or environment, not both.') @@ -47,6 +67,8 @@ export function configuration(inputs, event, context) { context.runAttempt, context.job, number, + inputs.project, + branch, sha, ]) ) @@ -55,6 +77,8 @@ export function configuration(inputs, event, context) { token: inputs.token, org: inputs.org, repository, + project: inputs.project, + branch, number, sha, previewUrl: inputs['preview-url'], @@ -75,6 +99,7 @@ export async function verify( publish = () => {}, } = {} ) { + const requests = config.provider === 'gitlab' ? '/merge_requests' : '/pull_requests' const root = `${config.base}/api/v1/organizations/${encodeURIComponent(config.org)}/verify` async function api(path, body) { for (let attempt = 0; ; attempt++) { @@ -106,12 +131,12 @@ export async function verify( const hints = { 401: 'Check the Composal token.', 403: 'The token needs administrator access to this organization.', - 404: 'Check the org, repository, and PR verification setup.', - 409: 'The PR changed or this preview handoff conflicts with an earlier request.', + 404: 'Check the org, repository, and verification setup.', + 409: 'The request changed or this preview handoff conflicts with an earlier request.', } // Do not echo response bodies: they can contain credentials or preview URL query strings. throw new Error( - `Composal returned HTTP ${response.status}. ${hints[response.status] || 'Check the PR preview configuration and supplied inputs.'}` + `Composal returned HTTP ${response.status}. ${hints[response.status] || 'Check the preview configuration and supplied inputs.'}` ) } try { @@ -121,10 +146,13 @@ export async function verify( } } } - const pr = await api('/pull_requests', { + const pr = await api(requests, { repository_id: config.repository, - number: config.number, + ...(config.number ? { number: config.number } : {}), head_sha: config.sha, + ...(config.project ? { project_id: config.project } : {}), + ...(config.branch ? { branch: config.branch } : {}), + ...(!config.number && config.previewUrl ? { preview_url: config.previewUrl } : {}), idempotency_key: config.key, }) if ( @@ -137,22 +165,37 @@ export async function verify( ) if (pr.head_sha !== config.sha) throw new Error('The workflow commit is no longer the current PR head.') + if ( + config.project && + pr.project_id !== config.project && + pr.project_slug !== config.project.toLowerCase() + ) + throw new Error( + 'Composal did not assign the preview to the requested project. Update its preview API.' + ) + if (config.branch && pr.source_branch !== config.branch) + throw new Error( + 'Composal did not confirm the deployed source branch. Check the branch and preview API version.' + ) const url = new URL(pr.web_path, config.base) if (url.origin !== config.base) throw new Error('Composal returned an invalid verification URL.') const outputs = { url: url.href, - 'pull-request-id': pr.id, + 'pull-request-id': pr.id || '', 'run-id': pr.requested_run_id || '', 'sweep-id': '', - status: pr.requested_run_id ? 'queued' : 'skipped', + status: pr.requested_run_id ? 'queued' : pr.status || 'skipped', + 'environment-id': pr.environment_id || '', } await publish(outputs) - if (!pr.requested_run_id) return outputs // Draft or closed PR; no browser run was requested. + if (!pr.requested_run_id) return outputs // Registered branch preview, draft or closed PR. if (config.previewUrl || config.environment) { - const run = await api('/pull_requests/preview', { + const run = await api(`${requests}/preview`, { repository_id: config.repository, - number: config.number, + number: pr.number || config.number, head_sha: config.sha, + ...(config.project ? { project_id: config.project } : {}), + ...(config.branch ? { branch: config.branch } : {}), ...(config.previewUrl ? { preview_url: config.previewUrl } : { environment: config.environment }), @@ -167,7 +210,7 @@ export async function verify( const deadline = now() + config.timeoutMs const active = new Set(['queued', 'waiting_for_preview', 'running', 'cancelling', 'terminal']) while (now() < deadline) { - const page = await api(`/pull_requests/${encodeURIComponent(pr.id)}`) + const page = await api(`${requests}/${encodeURIComponent(pr.id)}`) const run = page.runs.find((candidate) => candidate.id === pr.requested_run_id) if ( !run || diff --git a/verify.test.mjs b/verify.test.mjs index fb9b82a..a284285 100644 --- a/verify.test.mjs +++ b/verify.test.mjs @@ -8,7 +8,10 @@ import { fileURLToPath } from 'node:url' import { execFileSync } from 'node:child_process' const sha = '2'.repeat(40) -const event = { repository: { name: 'shop' }, pull_request: { number: 45, head: { sha } } } +const event = { + repository: { name: 'shop' }, + pull_request: { number: 45, head: { sha, ref: 'fix/checkout' } }, +} const context = { repository: 'example/shop', runId: '123', runAttempt: '1', job: 'verify' } const config = (inputs = {}, payload = event) => configuration({ token: 'private-token', org: 'acme', ...inputs }, payload, context) @@ -17,6 +20,9 @@ const pr = { head_sha: sha, web_path: '/acme/verify/pull-requests/vpr_1', requested_run_id: 'vprun_1', + project_id: 'project_storefront', + project_slug: 'storefront', + source_branch: 'fix/checkout', } const run = (status = 'passed') => ({ id: 'vprun_1', @@ -42,6 +48,7 @@ test('infers PR head rather than GitHub merge SHA and uses stable retry keys', ( assert.equal(first.number, 45) assert.equal(first.sha, sha) assert.equal(first.repository, 'shop') + assert.equal(first.branch, 'fix/checkout') assert.equal(first.key, config().key) assert.notEqual( first.key, @@ -64,13 +71,87 @@ test('infers PR head rather than GitHub merge SHA and uses stable retry keys', ( }) test('conflicting workflow and PR commits require an explicit deployed SHA', () => { - const workflowEvent = { repository: event.repository, workflow_run: { - head_sha: '3'.repeat(40), pull_requests: [{ number: 45, head: { sha } }], - } } + const workflowEvent = { + repository: event.repository, + workflow_run: { + head_sha: '3'.repeat(40), + pull_requests: [{ number: 45, head: { sha } }], + }, + } assert.throws(() => config({}, workflowEvent), /exact commit deployed/) assert.equal(config({ sha }, workflowEvent).sha, sha) }) +test('accepts explicit project, PR and branch and infers workflow_run source branches', () => { + const explicit = config({ project: 'storefront', pr: '46', branch: 'fix/payments', sha }, {}) + assert.equal(explicit.project, 'storefront') + assert.equal(explicit.number, 46) + assert.equal(explicit.branch, 'fix/payments') + assert.equal( + config( + {}, + { + workflow_run: { + head_sha: sha, + head_branch: 'fix/payments', + pull_requests: [{ number: 46 }], + }, + } + ).branch, + 'fix/payments' + ) +}) + +test('branch-only pushes infer the deployed commit and register previews without a PR', async () => { + const branchConfig = configuration( + { + token: 'private-token', + org: 'acme', + project: 'storefront', + 'preview-url': 'https://branch.preview.test', + }, + {}, + { ...context, eventName: 'push', refType: 'branch', refName: 'fix/payments', sha } + ) + assert.equal(branchConfig.number, undefined) + assert.equal(branchConfig.branch, 'fix/payments') + assert.equal(branchConfig.sha, sha) + const api = transport([ + { + id: null, + number: null, + requested_run_id: null, + head_sha: sha, + status: 'preview_registered', + project_slug: 'storefront', + source_branch: 'fix/payments', + environment_id: 'venv_branch', + web_path: '/acme/verify/storefront/environments/venv_branch', + }, + ]) + const result = await verify(branchConfig, api) + assert.equal(api.calls.length, 1) + assert.equal(api.calls[0].body.number, undefined) + assert.equal(api.calls[0].body.preview_url, 'https://branch.preview.test') + assert.equal(result.status, 'preview_registered') + assert.equal(result['environment-id'], 'venv_branch') + assert.equal(result['run-id'], '') +}) + +test('branch selectors resolve an open PR before handing off its URL', async () => { + const api = transport([{ ...pr, number: 45, source_branch: 'fix/payments' }, run('queued')]) + const result = await verify( + config( + { branch: 'fix/payments', sha, 'preview-url': 'https://branch.preview.test', wait: 'false' }, + {} + ), + api + ) + assert.equal(api.calls[0].body.number, undefined) + assert.equal(api.calls[1].body.number, 45) + assert.equal(result.status, 'queued') +}) + test('validates input authority and refuses ambiguous or missing PR context', () => { for (const inputs of [ { token: '' }, @@ -100,13 +181,20 @@ test('hands a deployed URL to the exact request, waits, and publishes the sweep { pull_request: pr, runs: [run()] }, ]) const updates = [] - const result = await verify(config({ 'preview-url': 'https://pr-45.preview.test' }), { - ...api, - sleep: async () => {}, - publish: (value) => updates.push({ ...value }), - }) + const result = await verify( + config({ project: 'storefront', 'preview-url': 'https://pr-45.preview.test' }), + { + ...api, + sleep: async () => {}, + publish: (value) => updates.push({ ...value }), + } + ) assert.equal(api.calls[0].body.head_sha, sha) assert.equal(api.calls[0].body.number, 45) + assert.equal(api.calls[0].body.project_id, 'storefront') + assert.equal(api.calls[0].body.branch, 'fix/checkout') + assert.equal(api.calls[1].body.project_id, 'storefront') + assert.equal(api.calls[1].body.branch, 'fix/checkout') assert.equal(api.calls[1].body.preview_url, 'https://pr-45.preview.test') assert.equal(api.calls[1].body.head_sha, sha) assert.equal(api.calls[0].headers.Authorization, 'Bearer private-token') @@ -134,6 +222,17 @@ test('configured managed previews need only one request when wait is false', asy assert.equal(api.calls.length, 1) }) +test('refuses APIs that silently ignore the project or deployed branch', async () => { + await assert.rejects( + verify(config({ project: 'another-project' }), transport([pr])), + /requested project/ + ) + await assert.rejects( + verify(config(), transport([{ ...pr, source_branch: undefined }])), + /source branch/ + ) +}) + test('drafts do not attempt a preview handoff', async () => { const api = transport([{ ...pr, requested_run_id: null }]) assert.equal( @@ -232,6 +331,8 @@ test('entrypoint reads Actions inputs, writes outputs and summary, and masks the ...process.env, INPUT_TOKEN: 'private-token', INPUT_ORG: 'acme', + INPUT_PROJECT: 'storefront', + INPUT_BRANCH: 'fix/checkout', INPUT_WAIT: 'false', GITHUB_EVENT_PATH: eventPath, GITHUB_OUTPUT: outputPath, From ea71c21004777e93e1968f230f6fbd5ad06b1b77 Mon Sep 17 00:00:00 2001 From: Pandelis Zembashis Date: Sun, 4 Oct 2026 22:22:16 -0700 Subject: [PATCH 2/2] Require project input and document project PR Testing setup --- README.md | 54 ++++++++++++++++++++++++++++++++----------------- action.yml | 5 ++--- index.mjs | 1 - verify.mjs | 13 ++++++------ verify.test.mjs | 23 +++++++++++++++++---- 5 files changed, 63 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index fcacd30..af4f0bb 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,13 @@ Test the product journeys affected by your PR against its deployed preview. Veri preview-url: ${{ steps.deploy.outputs.url }} ``` -Configure the connected repository once in **Verify → PR verification**. Select **Preview supplied by CI**, your scenario pack, and an external Verify environment template containing your personas and safety policy. Add a Composal administrator API token as the `COMPOSAL_TOKEN` Actions secret and your organization slug as the `COMPOSAL_ORG` variable. +Configure the Composal project in **Verify → Projects → your project → PR Testing**. Select its source repository and **Your CI**, your scenario pack, and an external Verify environment template containing your personas and safety policy. Add a Composal administrator API token as the `COMPOSAL_TOKEN` Actions secret and your organization slug as the `COMPOSAL_ORG` variable. Run this step after deploying **`github.event.pull_request.head.sha`** and waiting for the preview to become reachable. GitHub's `github.sha` can be a synthetic merge commit; Verify deliberately uses the PR head. Your existing deployment provider supplies the URL; this action does not build an arbitrary app. Set `COMPOSAL_PROJECT` to your Composal project slug or public ID. Each URL handoff automatically registers a preview in that project’s **Environments** page, labelled with its PR number and branch. Repeated handoffs reuse the environment; new requests keep separate preview records. When the connected GitHub App observes the PR merge, Composal archives its generated previews after active verification finishes. Use **Show Archived** to view their retained history. This does not delete the deployment at your provider. -The action accepts either `pr` or `branch`; both are optional when GitHub supplies the context. It infers PR numbers from PR events and source branches from PR, `workflow_run`, or branch `push` events. Set `repository` if the connected Composal repository has a different slug. It needs no checkout, installed CLI, or GitHub token: Composal's connected GitHub App maintains the results comment. +The action accepts either `pr` or `branch`; both are optional when GitHub supplies the context. It infers PR numbers from PR events and source branches from PR, `workflow_run`, or branch `push` events. The required `project` selects the saved source connection; there is no repository input. It needs no checkout, installed CLI, or GitHub token: Composal's connected GitHub App maintains the results comment. For a branch deployment with no open PR, supply the preview URL and project. The action registers the environment and succeeds with `status: preview_registered` and an `environment-id`; it does not report a browser test pass. When a PR later opens for that branch, Verify links the branch previews to it and archives them after merge. If a branch has multiple open PRs, supply `pr` explicitly. Branches registered in multiple projects need an explicit project selection before they can be linked automatically. @@ -34,9 +34,28 @@ For a branch deployment with no open PR, supply the preview URL and project. The # sha: ``` +## Set up with an agent + +Open the project's **PR Testing** page and choose **Copy Instructions for Agent**. The instructions include the current organization and project, the workflow step, and commands for creating an expiring Composal token directly in GitHub's secret store: + +```sh +com login +com whoami +gh auth status +gh repo view --json nameWithOwner --jq .nameWithOwner +set +x +set -o pipefail +com pat create --name verify-ci --expires-in 90d | gh secret set COMPOSAL_TOKEN +gh variable set COMPOSAL_ORG --body acme +gh variable set COMPOSAL_PROJECT --body storefront +gh secret list +``` + +Run these in the target GitHub checkout; replace `acme` and `storefront` with your organization and project. The token creator needs administrator access. Keep its value out of logs, prompts, and committed files. The API resolves the source repository from the project's saved PR Testing settings. + ## Composal-hosted previews -If the repository is configured to **Build a Composal preview**, leave out `preview-url`. Verify provisions and deploys the exact PR commit using the configured profile. The same minimal step works with **GitHub deployment preview** discovery. +If the project is configured to **Composal Preview**, leave out `preview-url`. Verify provisions and deploys the exact PR commit using the configured profile. The same minimal step works with **GitHub deployment preview** discovery. ```yaml - uses: composalai/verify@1 @@ -83,20 +102,19 @@ By default the action waits up to 15 minutes. Passed or explicitly skipped reque ## Inputs and outputs -| Input | Default | Meaning | -| ------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------- | -| `token` | Required | Composal API token, supplied through a secret. | -| `org` | Required | Composal organization slug. | -| `preview-url` | Omitted | Ready URL deployed from this PR head or branch commit. Requires CI preview mode and an external Verify template. | -| `environment` | Omitted | Ready Verify preview environment slug; mutually exclusive with `preview-url`. | -| `repository` | GitHub repository name | Connected Composal repository slug. | -| `project` | Template’s project | Composal project slug or public ID where PR previews are listed. Set explicitly when the template has no project. | -| `branch` | GitHub source branch | Optional alternative to `pr`. Resolves an open PR or registers a branch preview when none exists. | -| `pr` | PR event | Optional alternative to `branch`. `workflow_run` works when it identifies one PR. | -| `sha` | PR head or branch push | Full deployed commit SHA. PR heads and branch pushes are inferred; supply it for other event types. | -| `wait` | `'true'` | Wait for results; `'false'` only requests verification. | -| `timeout` | `'15'` | Wait limit in minutes, between 1 and 60. Set the job timeout higher. | -| `api-url` | `https://composal.ai` | HTTPS API origin, for installations using another endpoint. | +| Input | Default | Meaning | +| ------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `token` | Required | Composal API token, supplied through a secret. | +| `org` | Required | Composal organization slug. | +| `preview-url` | Omitted | Ready URL deployed from this PR head or branch commit. Requires CI preview mode and an external Verify template. | +| `environment` | Omitted | Ready Verify preview environment slug; mutually exclusive with `preview-url`. | +| `project` | Required | Composal project slug or public ID with a saved PR Testing source connection. | +| `branch` | GitHub source branch | Optional alternative to `pr`. Resolves an open PR or registers a branch preview when none exists. | +| `pr` | PR event | Optional alternative to `branch`. `workflow_run` works when it identifies one PR. | +| `sha` | PR head or branch push | Full deployed commit SHA. PR heads and branch pushes are inferred; supply it for other event types. | +| `wait` | `'true'` | Wait for results; `'false'` only requests verification. | +| `timeout` | `'15'` | Wait limit in minutes, between 1 and 60. Set the job timeout higher. | +| `api-url` | `https://composal.ai` | HTTPS API origin, for installations using another endpoint. | Outputs: `url` (PR history or registered branch environment), `pull-request-id`, `run-id`, `sweep-id` (when started), `environment-id` (branch registration), and `status`. The workflow summary links to the full results. Critical/high issue recordings remain authenticated links in Verify. @@ -112,7 +130,7 @@ The `1` tag is the supported major release. To publish an update from the Vex mo For GitLab.com merge request pipelines, include `https://composal.ai/ci/verify/v1/gitlab.yml` and extend `.composal-verify` -after your preview deployment. The copyable configuration in Verify → PR & MR +after your preview deployment. The copyable configuration in your project’s PR Testing includes direct MR pipeline rules and the connected Composal repo slug. The helper reads GitLab's IID and source SHA, hands off the exact preview, waits for its own verification run, and writes safe result IDs/links to diff --git a/action.yml b/action.yml index dff09dc..a792e01 100644 --- a/action.yml +++ b/action.yml @@ -15,10 +15,9 @@ inputs: description: URL deployed from this PR head. Omit when Composal deploys or discovers the preview. environment: description: Ready Verify preview environment slug, instead of preview-url. - repository: - description: Composal repository slug. Defaults to the GitHub repository name. project: - description: Composal project slug or ID for PR preview environments. Defaults to the configured template's project. + description: Composal project slug or ID. Configure its source connection in the project's PR Testing page. + required: true pr: description: Optional PR number. Inferred from pull_request or a single-PR workflow_run event. Supply pr or branch. branch: diff --git a/index.mjs b/index.mjs index f2ba208..a8bb085 100644 --- a/index.mjs +++ b/index.mjs @@ -10,7 +10,6 @@ const inputs = Object.fromEntries( 'org', 'preview-url', 'environment', - 'repository', 'project', 'pr', 'branch', diff --git a/verify.mjs b/verify.mjs index 74c8455..6bc6734 100644 --- a/verify.mjs +++ b/verify.mjs @@ -28,6 +28,8 @@ export function configuration(inputs, event, context) { (!number && context.refType === 'branch' ? context.refName : undefined) if (!inputs.token) throw new Error('Set token to a Composal API token stored as a CI secret.') if (!inputs.org) throw new Error('Set org to your Composal organization slug.') + if (!inputs.project && context.provider !== 'gitlab') + throw new Error('Set project to your Composal project slug or ID.') if ( (rawNumber && (!Number.isSafeInteger(number) || number < 1)) || (!number && !branch) || @@ -56,9 +58,6 @@ export function configuration(inputs, event, context) { const minutes = Number(inputs.timeout || '15') if (!['true', 'false'].includes(wait) || !Number.isFinite(minutes) || minutes < 1 || minutes > 60) throw new Error('wait must be true or false; timeout must be between 1 and 60 minutes.') - const repository = - inputs.repository || event.repository?.name || context.repository?.split('/').at(-1) - if (!repository) throw new Error('Set repository to the connected Composal repository slug.') const key = createHash('sha256') .update( JSON.stringify([ @@ -76,8 +75,8 @@ export function configuration(inputs, event, context) { return { token: inputs.token, org: inputs.org, - repository, project: inputs.project, + ...(context.provider === 'gitlab' ? { repository: inputs.repository } : {}), branch, number, sha, @@ -131,7 +130,7 @@ export async function verify( const hints = { 401: 'Check the Composal token.', 403: 'The token needs administrator access to this organization.', - 404: 'Check the org, repository, and verification setup.', + 404: 'Check the org, project, and project PR Testing setup.', 409: 'The request changed or this preview handoff conflicts with an earlier request.', } // Do not echo response bodies: they can contain credentials or preview URL query strings. @@ -147,7 +146,7 @@ export async function verify( } } const pr = await api(requests, { - repository_id: config.repository, + ...(config.provider === 'gitlab' ? { repository_id: config.repository } : {}), ...(config.number ? { number: config.number } : {}), head_sha: config.sha, ...(config.project ? { project_id: config.project } : {}), @@ -191,7 +190,7 @@ export async function verify( if (!pr.requested_run_id) return outputs // Registered branch preview, draft or closed PR. if (config.previewUrl || config.environment) { const run = await api(`${requests}/preview`, { - repository_id: config.repository, + ...(config.provider === 'gitlab' ? { repository_id: config.repository } : {}), number: pr.number || config.number, head_sha: config.sha, ...(config.project ? { project_id: config.project } : {}), diff --git a/verify.test.mjs b/verify.test.mjs index a284285..ea5c475 100644 --- a/verify.test.mjs +++ b/verify.test.mjs @@ -14,7 +14,11 @@ const event = { } const context = { repository: 'example/shop', runId: '123', runAttempt: '1', job: 'verify' } const config = (inputs = {}, payload = event) => - configuration({ token: 'private-token', org: 'acme', ...inputs }, payload, context) + configuration( + { token: 'private-token', org: 'acme', project: 'storefront', ...inputs }, + payload, + context + ) const pr = { id: 'vpr_1', head_sha: sha, @@ -47,13 +51,16 @@ test('infers PR head rather than GitHub merge SHA and uses stable retry keys', ( const first = config() assert.equal(first.number, 45) assert.equal(first.sha, sha) - assert.equal(first.repository, 'shop') + assert.equal(first.project, 'storefront') + assert.equal(first.repository, undefined) assert.equal(first.branch, 'fix/checkout') assert.equal(first.key, config().key) assert.notEqual( first.key, - configuration({ token: 'private-token', org: 'acme' }, event, { ...context, runAttempt: '2' }) - .key + configuration({ token: 'private-token', org: 'acme', project: 'storefront' }, event, { + ...context, + runAttempt: '2', + }).key ) assert.equal( config( @@ -192,8 +199,10 @@ test('hands a deployed URL to the exact request, waits, and publishes the sweep assert.equal(api.calls[0].body.head_sha, sha) assert.equal(api.calls[0].body.number, 45) assert.equal(api.calls[0].body.project_id, 'storefront') + assert.equal('repository_id' in api.calls[0].body, false) assert.equal(api.calls[0].body.branch, 'fix/checkout') assert.equal(api.calls[1].body.project_id, 'storefront') + assert.equal('repository_id' in api.calls[1].body, false) assert.equal(api.calls[1].body.branch, 'fix/checkout') assert.equal(api.calls[1].body.preview_url, 'https://pr-45.preview.test') assert.equal(api.calls[1].body.head_sha, sha) @@ -352,3 +361,9 @@ test('entrypoint reads Actions inputs, writes outputs and summary, and masks the rmSync(directory, { recursive: true, force: true }) } }) + +test('requires a Composal project and never infers it from a GitHub repository', () => { + assert.throws(() => config({ project: '' }), /Set project/) + assert.equal(config({ repository: 'ignored-repository' }).project, 'storefront') + assert.equal(config({ repository: 'ignored-repository' }).repository, undefined) +})