diff --git a/.github/workflows/_selftest.yml b/.github/workflows/_selftest.yml index 614d1ab..c5cf73e 100644 --- a/.github/workflows/_selftest.yml +++ b/.github/workflows/_selftest.yml @@ -18,6 +18,10 @@ jobs: uses: ./steps/setup with: install: "false" + # No pnpm-lock.yaml exists here either, and setup-node FAILS when its pnpm cache finds no + # lockfile to hash — disabling it also exercises the lockfile-less consumer shape + # (Deno repos) that install/node-cache exist for. + node-cache: "" - name: Unit tests (lib + action scripts) run: node --test 'lib/*.test.js' 'steps/**/*.test.js' diff --git a/.github/workflows/deploy-vercel.yml b/.github/workflows/deploy-vercel.yml index 00b72bb..e6bc427 100644 --- a/.github/workflows/deploy-vercel.yml +++ b/.github/workflows/deploy-vercel.yml @@ -50,6 +50,41 @@ on: required: false type: string default: "" + submodules: + description: "Pass-through to steps/setup's `submodules` (in turn actions/checkout's: false / true / recursive)." + required: false + type: string + default: "false" + op-submodules-token-path: + description: | + Optional 1Password path to a GitHub PAT used ONLY for fetching submodules — separate from + the main checkout, which keeps using the default job token. Needed when `submodules` pulls + in a PRIVATE submodule the default token (scoped to this repo only) can't read, e.g. a + fine-grained PAT that can read only the submodule's repo. Leave empty for public submodules + or no submodules at all. + required: false + type: string + default: "" + setup-install: + description: | + Whether steps/setup runs `pnpm install --frozen-lockfile`. Defaults to true (this + workflow always installs pnpm + Node either way, since `pnpm vercel build/deploy/alias` + below needs pnpm present). Set to "false" for a project that doesn't manage its own + dependencies via pnpm (no pnpm-lock.yaml) — e.g. a Deno-based project — so this step + doesn't fail looking for a lockfile that was never meant to exist. + required: false + type: string + default: "true" + setup-node-cache: + description: | + Pass-through to steps/setup's `node-cache` (in turn actions/setup-node's `cache`). + Defaults to "pnpm". actions/setup-node's own caching looks for a matching lockfile + (pnpm-lock.yaml) to hash for the cache key and FAILS the step if none exists — set this + to "" to disable caching entirely for a project with no pnpm-lock.yaml (see + setup-install above; the two normally go together). + required: false + type: string + default: "pnpm" setup-command: description: "Optional command run in before the Vercel build (e.g. 'pnpm run setup production')" required: false @@ -114,7 +149,8 @@ jobs: steps: # The only place 1Password is read in this workflow — see steps/credential-retrieval. All # credentials come from the same vault, one item per name (see op-infra-vault). The Sentry - # refs resolve to empty strings unless source-map upload is requested. + # refs resolve to empty strings unless source-map upload is requested. SUBMODULES_TOKEN is + # separate and optional — see op-submodules-token-path. - name: Resolve Vercel credentials id: op uses: aragon/github-templates/steps/credential-retrieval@main @@ -128,11 +164,16 @@ jobs: SENTRY_AUTH_TOKEN=${{ inputs.upload-sentry-source-maps && format('op://{0}/SENTRY_AUTH_TOKEN/credential', inputs.op-infra-vault) || '' }} SENTRY_ORG=${{ inputs.upload-sentry-source-maps && format('op://{0}/SENTRY_ORG/credential', inputs.op-infra-vault) || '' }} SENTRY_PROJECT=${{ inputs.upload-sentry-source-maps && format('op://{0}/SENTRY_PROJECT/credential', inputs.op-infra-vault) || '' }} + SUBMODULES_TOKEN=${{ inputs.op-submodules-token-path }} - name: Setup uses: aragon/github-templates/steps/setup@main with: ref: ${{ inputs.ref }} + submodules: ${{ inputs.submodules }} + submodules-token: ${{ fromJSON(steps.op.outputs.secrets).SUBMODULES_TOKEN }} + install: ${{ inputs.setup-install }} + node-cache: ${{ inputs.setup-node-cache }} - name: Run setup command if: inputs.setup-command != '' diff --git a/contracts/README.md b/contracts/README.md new file mode 100644 index 0000000..7b1a18b --- /dev/null +++ b/contracts/README.md @@ -0,0 +1,35 @@ +# Interface contracts + +Backward-compatibility guard for the public interface of this repo: the `inputs`, `secrets` and +`outputs` of every reusable workflow (`.github/workflows/*.yml` with `on: workflow_call`) and +every composite action (`steps/*/action.yml`). Consumer repos call these `@main`, so anything +that lands on `main` must keep existing callers working. + +`lib/backcompat.test.js` runs in the normal CI test suite (every PR and push to `main`) and +compares the interfaces extracted from the YAML sources (`lib/workflowInterfaces.js`) against +two committed files: + +- **`interfaces.json`** — snapshot of the full published interface. CI fails on any + **breaking** difference: + - an input, secret or output was removed (callers passing a removed input fail GitHub's + validation outright; composite actions instead silently ignore it, which is worse); + - an optional input/secret became required, or a **new** required one appeared + (`workflow_call` enforces `required` even when a default exists); + - an input's `default` or `type` changed (silently changes behavior for callers that omit it). + + Backward-compatible **additions** (new workflow/action, new optional input, new output) don't + break anything, but CI asks you to register them — run `npm run contracts:update` and commit + the diff. That keeps the snapshot complete so a future removal of your new input is caught. + + For an *intentional* breaking change, run `ALLOW_BREAKING=1 npm run contracts:update`: the + snapshot diff makes the break explicit in PR review. Describe the consumer migration in the PR. + +- **`consumers.json`** — the exact call shapes of known consumer repos. CI fails if a called + workflow stops defining an input/secret a consumer passes, or starts requiring one it doesn't + pass. Add an entry when a repo starts consuming a workflow `@main`; update it when the + consumer's `with:`/`secrets:` blocks change. + +The YAML parsing is a vendored strict-subset parser (same policy as `lib/flatYaml.js`): no +dependencies, and a hard error on shapes it doesn't recognize rather than a silent misparse. If +you add YAML constructs it rejects, extend `lib/workflowInterfaces.js` (with tests) rather than +loosening it. diff --git a/contracts/consumers.json b/contracts/consumers.json new file mode 100644 index 0000000..5878514 --- /dev/null +++ b/contracts/consumers.json @@ -0,0 +1,39 @@ +{ + "description": "Known consumer call shapes, checked by lib/backcompat.test.js: every input/secret listed here must stay defined on the called workflow, and every required input/secret of the called workflow must be listed here. Add an entry when a consumer repo starts calling a reusable workflow @main; keep the lists in sync with the consumer's `with:`/`secrets:` blocks.", + "consumers": [ + { + "repo": "aragon/protocol-doc-ui", + "workflowFile": ".github/workflows/deploy-staging.yml", + "calls": "deploy-vercel.yml", + "inputs": [ + "env", + "ref", + "domain", + "vercel-scope", + "op-infra-vault", + "op-submodules-token-path", + "submodules", + "setup-install", + "setup-node-cache", + "setup-command" + ], + "secrets": ["OP_SERVICE_ACCOUNT_TOKEN"] + }, + { + "repo": "aragon/protocol-doc-ui", + "workflowFile": ".github/workflows/deploy-preview.yml", + "calls": "deploy-vercel.yml", + "inputs": [ + "env", + "vercel-scope", + "op-infra-vault", + "op-submodules-token-path", + "submodules", + "setup-install", + "setup-node-cache", + "setup-command" + ], + "secrets": ["OP_SERVICE_ACCOUNT_TOKEN"] + } + ] +} diff --git a/contracts/interfaces.json b/contracts/interfaces.json new file mode 100644 index 0000000..cd203cc --- /dev/null +++ b/contracts/interfaces.json @@ -0,0 +1,927 @@ +{ + "workflows": { + "deploy-docker.yml": { + "inputs": { + "allow-unsafe-pr-checkout": { + "required": false, + "type": "boolean", + "default": "false" + }, + "deploy-command": { + "required": true, + "type": "string" + }, + "env": { + "required": true, + "type": "string" + }, + "env-file-path": { + "required": false, + "type": "string", + "default": ".env" + }, + "op-env-vault": { + "required": false, + "type": "string", + "default": "" + }, + "op-slack-bot-token-path": { + "required": false, + "type": "string", + "default": "" + }, + "op-slack-channel-id-path": { + "required": false, + "type": "string", + "default": "" + }, + "op-slack-codeowners-group-path": { + "required": false, + "type": "string", + "default": "" + }, + "op-ssh-host-path": { + "required": true, + "type": "string" + }, + "op-ssh-key-path": { + "required": true, + "type": "string" + }, + "op-ssh-user-path": { + "required": true, + "type": "string" + }, + "ref": { + "required": false, + "type": "string" + }, + "remote-path": { + "required": false, + "type": "string", + "default": "~/app" + }, + "slack-thread-ts": { + "required": false, + "type": "string", + "default": "" + }, + "version": { + "required": false, + "type": "string", + "default": "" + } + }, + "secrets": { + "OP_SERVICE_ACCOUNT_TOKEN": { + "required": true + } + }, + "outputs": [ + "gate-ts" + ] + }, + "deploy-vercel.yml": { + "inputs": { + "domain": { + "required": false, + "type": "string", + "default": "" + }, + "env": { + "required": true, + "type": "string" + }, + "env-overrides": { + "required": false, + "type": "string", + "default": "" + }, + "op-env-vault": { + "required": false, + "type": "string", + "default": "" + }, + "op-infra-vault": { + "required": true, + "type": "string" + }, + "op-submodules-token-path": { + "required": false, + "type": "string", + "default": "" + }, + "patch-root-directory": { + "required": false, + "type": "boolean", + "default": "false" + }, + "prod": { + "required": false, + "type": "boolean", + "default": "false" + }, + "ref": { + "required": false, + "type": "string" + }, + "runtime-env": { + "required": false, + "type": "string", + "default": "" + }, + "runtime-env-keys": { + "required": false, + "type": "string", + "default": "" + }, + "setup-command": { + "required": false, + "type": "string", + "default": "" + }, + "setup-install": { + "required": false, + "type": "string", + "default": "true" + }, + "setup-node-cache": { + "required": false, + "type": "string", + "default": "pnpm" + }, + "submodules": { + "required": false, + "type": "string", + "default": "false" + }, + "upload-sentry-source-maps": { + "required": false, + "type": "boolean", + "default": "false" + }, + "vercel-scope": { + "required": true, + "type": "string" + }, + "workspace": { + "required": false, + "type": "string", + "default": "." + } + }, + "secrets": { + "OP_SERVICE_ACCOUNT_TOKEN": { + "required": true + } + }, + "outputs": [ + "deploymentUrl" + ] + }, + "e2e.yml": { + "inputs": { + "artifact-viewer-host": { + "required": false, + "type": "string", + "default": "" + }, + "deployment-url": { + "required": true, + "type": "string" + }, + "env-secret-refs": { + "required": false, + "type": "string", + "default": "" + }, + "environment": { + "required": true, + "type": "string" + }, + "extra-cache-key-files": { + "required": false, + "type": "string", + "default": "" + }, + "extra-cache-path": { + "required": false, + "type": "string", + "default": "" + }, + "install-command": { + "required": false, + "type": "string", + "default": "pnpm e2e:install" + }, + "mode": { + "required": false, + "type": "string", + "default": "smoke" + }, + "pre-test-command": { + "required": false, + "type": "string", + "default": "" + }, + "report-path": { + "required": false, + "type": "string", + "default": "" + }, + "results-path": { + "required": false, + "type": "string", + "default": "" + }, + "test-command": { + "required": false, + "type": "string", + "default": "" + }, + "timeout-minutes": { + "required": false, + "type": "number", + "default": "15" + }, + "use-xvfb": { + "required": false, + "type": "boolean", + "default": "false" + }, + "working-directory": { + "required": false, + "type": "string", + "default": "." + } + }, + "secrets": { + "OP_SERVICE_ACCOUNT_TOKEN": { + "required": false + } + }, + "outputs": [ + "report_artifact_id", + "result", + "result_label", + "summary" + ] + }, + "release-finalize.yml": { + "inputs": { + "changelog-path": { + "required": false, + "type": "string", + "default": "" + }, + "changesets-guard": { + "required": false, + "type": "string", + "default": "" + }, + "op-release-token-path": { + "required": true, + "type": "string" + }, + "op-slack-bot-token-path": { + "required": false, + "type": "string", + "default": "" + }, + "op-slack-channel-id-path": { + "required": false, + "type": "string", + "default": "" + }, + "package-dir": { + "required": false, + "type": "string", + "default": "." + }, + "pr-body": { + "required": false, + "type": "string", + "default": "" + }, + "scopes-file": { + "required": false, + "type": "string", + "default": ".github/release-scopes.yml" + }, + "sha": { + "required": true, + "type": "string" + }, + "tag-prefix": { + "required": false, + "type": "string", + "default": "v" + }, + "version": { + "required": false, + "type": "string", + "default": "" + } + }, + "secrets": { + "OP_SERVICE_ACCOUNT_TOKEN": { + "required": true + } + }, + "outputs": [ + "tag", + "version" + ] + }, + "release-pr-refresh.yml": { + "inputs": { + "body-extras": { + "required": false, + "type": "string", + "default": "" + }, + "op-linear-token-path": { + "required": false, + "type": "string", + "default": "" + }, + "op-release-token-path": { + "required": true, + "type": "string" + }, + "op-slack-bot-token-path": { + "required": false, + "type": "string", + "default": "" + }, + "op-slack-channel-id-path": { + "required": false, + "type": "string", + "default": "" + }, + "op-slack-codeowners-group-path": { + "required": false, + "type": "string", + "default": "" + }, + "release-branch-prefix": { + "required": false, + "type": "string", + "default": "release/" + }, + "summary-filters-file": { + "required": false, + "type": "string", + "default": ".github/filters.yml" + }, + "summary-path-filter": { + "required": false, + "type": "string", + "default": "" + }, + "tag-prefix": { + "required": false, + "type": "string", + "default": "v" + } + }, + "secrets": { + "OP_SERVICE_ACCOUNT_TOKEN": { + "required": true + } + }, + "outputs": [] + }, + "release-start.yml": { + "inputs": { + "base-ref": { + "required": false, + "type": "string", + "default": "${{ github.ref_name }}" + }, + "body-extras": { + "required": false, + "type": "string", + "default": "" + }, + "branch-suffix": { + "required": false, + "type": "string", + "default": "version" + }, + "commit-message": { + "required": false, + "type": "string", + "default": "chore(release): {tag}" + }, + "engine": { + "required": true, + "type": "string" + }, + "git-user-email": { + "required": false, + "type": "string", + "default": "41898282+github-actions[bot]@users.noreply.github.com" + }, + "git-user-name": { + "required": false, + "type": "string", + "default": "github-actions[bot]" + }, + "ignore-packages": { + "required": false, + "type": "string", + "default": "" + }, + "on-active-release": { + "required": false, + "type": "string", + "default": "fail" + }, + "op-gpg-key-path": { + "required": false, + "type": "string", + "default": "" + }, + "op-gpg-passphrase-path": { + "required": false, + "type": "string", + "default": "" + }, + "op-linear-token-path": { + "required": false, + "type": "string", + "default": "" + }, + "op-release-token-path": { + "required": true, + "type": "string" + }, + "op-slack-bot-token-path": { + "required": false, + "type": "string", + "default": "" + }, + "op-slack-channel-id-path": { + "required": false, + "type": "string", + "default": "" + }, + "op-slack-codeowners-group-path": { + "required": false, + "type": "string", + "default": "" + }, + "package-dir": { + "required": false, + "type": "string", + "default": "." + }, + "release-branch-prefix": { + "required": false, + "type": "string", + "default": "release/" + }, + "require-version-above-latest-tag": { + "required": false, + "type": "boolean", + "default": "false" + }, + "scope": { + "required": false, + "type": "string", + "default": "" + }, + "scopes-file": { + "required": false, + "type": "string", + "default": ".github/release-scopes.yml" + }, + "summary-filters-file": { + "required": false, + "type": "string", + "default": ".github/filters.yml" + }, + "summary-mode": { + "required": false, + "type": "string", + "default": "history" + }, + "summary-path-filter": { + "required": false, + "type": "string", + "default": "" + }, + "tag-prefix": { + "required": false, + "type": "string", + "default": "v" + }, + "target-branch": { + "required": false, + "type": "string", + "default": "main" + } + }, + "secrets": { + "OP_SERVICE_ACCOUNT_TOKEN": { + "required": true + } + }, + "outputs": [ + "branch", + "pr-number", + "pr-url", + "released", + "tag", + "version" + ] + } + }, + "steps": { + "build-release-notes": { + "inputs": { + "changes": { + "required": true + }, + "path": { + "required": false, + "default": "release-notes.md" + }, + "slack-ts": { + "required": false, + "default": "" + } + }, + "outputs": [ + "path" + ] + }, + "changesets-guard": { + "inputs": { + "scope": { + "required": false, + "default": "" + }, + "scopes-file": { + "required": false, + "default": ".github/release-scopes.yml" + } + }, + "outputs": [] + }, + "compute-version": { + "inputs": { + "base-branch": { + "required": false, + "default": "main" + }, + "engine": { + "required": true + }, + "github-token": { + "required": false, + "default": "" + }, + "ignore-packages": { + "required": false, + "default": "" + }, + "package-dir": { + "required": false, + "default": "." + }, + "prettier-changelog": { + "required": false, + "default": "true" + }, + "scope": { + "required": false, + "default": "" + }, + "scopes-file": { + "required": false, + "default": ".github/release-scopes.yml" + }, + "tag-prefix": { + "required": false, + "default": "v" + } + }, + "outputs": [ + "released", + "tag", + "version" + ] + }, + "credential-retrieval": { + "inputs": { + "file-format": { + "required": false, + "default": "env" + }, + "mode": { + "required": false, + "default": "alltoenv" + }, + "op-token": { + "required": true + }, + "op-vault": { + "required": false, + "default": "" + }, + "secret-filepath": { + "required": false, + "default": ".env" + }, + "secret-refs": { + "required": false, + "default": "" + } + }, + "outputs": [ + "secrets" + ] + }, + "extract-slack-ts": { + "inputs": { + "body": { + "required": true + } + }, + "outputs": [ + "ts" + ] + }, + "generate-release-summary": { + "inputs": { + "base-ref": { + "required": false, + "default": "" + }, + "filters-file": { + "required": false, + "default": ".github/filters.yml" + }, + "linear-api-token": { + "required": false, + "default": "" + }, + "path-filter": { + "required": false, + "default": "" + }, + "path-patterns": { + "required": false, + "default": "" + }, + "release-commit-pattern": { + "required": false, + "default": "" + }, + "repo": { + "required": false, + "default": "${{ github.repository }}" + }, + "tag-glob": { + "required": false, + "default": "v*" + } + }, + "outputs": [ + "summary" + ] + }, + "generate-version-summary": { + "inputs": { + "package-dir": { + "required": false, + "default": "." + }, + "packages": { + "required": false, + "default": "" + }, + "scope": { + "required": false, + "default": "" + }, + "scopes-file": { + "required": false, + "default": ".github/release-scopes.yml" + } + }, + "outputs": [ + "summary" + ] + }, + "gh-ensure-pr": { + "inputs": { + "base": { + "required": true + }, + "body": { + "required": true + }, + "head": { + "required": true + }, + "title": { + "required": true + }, + "token": { + "required": true + } + }, + "outputs": [ + "number", + "url" + ] + }, + "gh-ensure-release": { + "inputs": { + "notes-path": { + "required": true + }, + "tag": { + "required": true + }, + "title": { + "required": false, + "default": "" + }, + "token": { + "required": true + } + }, + "outputs": [] + }, + "gh-ensure-tag": { + "inputs": { + "remote": { + "required": false, + "default": "origin" + }, + "sha": { + "required": true + }, + "tag": { + "required": true + } + }, + "outputs": [ + "created" + ] + }, + "gh-pr-edit-body": { + "inputs": { + "body": { + "required": true + }, + "pr-number": { + "required": true + }, + "token": { + "required": true + } + }, + "outputs": [] + }, + "gh-pr-get-body": { + "inputs": { + "pr-number": { + "required": true + }, + "token": { + "required": true + } + }, + "outputs": [ + "body" + ] + }, + "git-ensure-branch": { + "inputs": { + "base-ref": { + "required": true + }, + "branch": { + "required": true + }, + "remote": { + "required": false, + "default": "origin" + } + }, + "outputs": [ + "created" + ] + }, + "parse-playwright-results": { + "inputs": { + "report-path": { + "required": true + }, + "step-outcome": { + "required": true + } + }, + "outputs": [ + "result", + "result_label", + "summary" + ] + }, + "read-changelog": { + "inputs": { + "path": { + "required": false, + "default": "./CHANGELOG.md" + }, + "version": { + "required": true + } + }, + "outputs": [ + "changes" + ] + }, + "setup": { + "inputs": { + "allow-unsafe-pr-checkout": { + "required": false, + "default": "false" + }, + "fetch-depth": { + "required": false, + "default": "1" + }, + "install": { + "required": false, + "default": "true" + }, + "node-cache": { + "required": false, + "default": "pnpm" + }, + "node-version": { + "required": false, + "default": "24" + }, + "node-version-file": { + "required": false, + "default": "" + }, + "ref": { + "required": false + }, + "registry-url": { + "required": false, + "default": "" + }, + "repository": { + "required": false, + "default": "${{ github.repository }}" + }, + "submodules": { + "required": false, + "default": "false" + }, + "submodules-token": { + "required": false, + "default": "" + }, + "token": { + "required": false, + "default": "${{ github.token }}" + } + }, + "outputs": [] + }, + "slack-notify": { + "inputs": { + "message": { + "required": true + }, + "slack-bot-token": { + "required": true + }, + "slack-channel-id": { + "required": true + }, + "thread-ts": { + "required": false, + "default": "" + }, + "update-ts": { + "required": false, + "default": "" + } + }, + "outputs": [ + "ts" + ] + } + } +} diff --git a/lib/backcompat.test.js b/lib/backcompat.test.js new file mode 100644 index 0000000..b10eef1 --- /dev/null +++ b/lib/backcompat.test.js @@ -0,0 +1,67 @@ +// Backward-compatibility gate for the public interface of this repo's reusable workflows and +// composite actions. Runs in CI on every PR and push to main (via the standard `node --test` +// globs), comparing the interfaces extracted from the YAML sources against the committed +// snapshot in contracts/interfaces.json, plus the known consumer call shapes in +// contracts/consumers.json. See contracts/README.md for the policy and how to update. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { collectInterfaces } = require('./workflowInterfaces'); +const { diffContracts } = require('./contractDiff'); + +const root = path.join(__dirname, '..'); +const snapshot = require('../contracts/interfaces.json'); +const consumers = require('../contracts/consumers.json'); +const current = collectInterfaces(root); + +test('no breaking change to any published workflow/action interface', () => { + const { breaking } = diffContracts(snapshot, current); + assert.deepEqual(breaking, [], [ + 'Breaking interface changes detected against contracts/interfaces.json:', + ...breaking.map((b) => ` - ${b}`), + 'Existing consumer repos calling these workflows/actions would break.', + 'If the break is intentional, run: ALLOW_BREAKING=1 npm run contracts:update', + 'and call out the migration in the PR description.', + ].join('\n')); +}); + +test('every interface addition is registered in contracts/interfaces.json', () => { + const { additions } = diffContracts(snapshot, current); + assert.deepEqual(additions, [], [ + 'New interface surface is not yet registered in contracts/interfaces.json:', + ...additions.map((a) => ` - ${a}`), + 'Additions are backward-compatible; register them so future removals are caught.', + 'Run: npm run contracts:update', + ].join('\n')); +}); + +test('known consumer call shapes remain valid against the current interfaces', () => { + for (const consumer of consumers.consumers) { + const where = `${consumer.repo} (${consumer.workflowFile})`; + const iface = current.workflows[consumer.calls]; + assert.ok(iface, `${where}: called workflow ${consumer.calls} no longer exists as workflow_call`); + + for (const input of consumer.inputs) { + assert.ok(iface.inputs[input], + `${where}: passes input "${input}", which ${consumer.calls} no longer defines — ` + + 'the caller run would fail validation with "Invalid input"'); + } + for (const secret of consumer.secrets) { + assert.ok(iface.secrets[secret], + `${where}: passes secret "${secret}", which ${consumer.calls} no longer defines`); + } + for (const [name, spec] of Object.entries(iface.inputs)) { + if (spec.required) { + assert.ok(consumer.inputs.includes(name), + `${where}: ${consumer.calls} now requires input "${name}", which this consumer does not pass`); + } + } + for (const [name, spec] of Object.entries(iface.secrets)) { + if (spec.required) { + assert.ok(consumer.secrets.includes(name), + `${where}: ${consumer.calls} now requires secret "${name}", which this consumer does not pass`); + } + } + } +}); diff --git a/lib/contractDiff.js b/lib/contractDiff.js new file mode 100644 index 0000000..c0597e9 --- /dev/null +++ b/lib/contractDiff.js @@ -0,0 +1,92 @@ +// Compares two interface snapshots (see lib/workflowInterfaces.js) and classifies every +// difference as either BREAKING for existing callers or a benign ADDITION that just needs to be +// registered in contracts/interfaces.json. Shared by lib/backcompat.test.js (which fails CI on +// breaking changes) and lib/updateContracts.js (which refuses to bake them into the snapshot +// without ALLOW_BREAKING=1). The rules, from the caller's point of view: +// +// - Removing an input/secret makes every caller that passes it fail validation ("Invalid +// input, X is not defined in the referenced workflow") — breaking. For composite actions the +// runner only warns, but the value silently stops being honored — worse, so same rule. +// - Flipping optional -> required (or adding a NEW required input/secret) makes callers that +// omit it fail — breaking. Loosening required -> optional is fine. +// - Changing a default (including adding/removing one) silently changes behavior for every +// caller that omits the input — breaking. +// - Changing an input's type changes how the caller's value is coerced — breaking. +// - Removing an output breaks callers that read it — breaking. +// - Everything else (new workflow/action, new optional input, new output) is an addition. + +const eq = (a, b) => (a ?? null) === (b ?? null); + +const diffEntryMaps = (oldMap, newMap, label, opts, breaking, additions) => { + for (const name of Object.keys(oldMap)) { + const o = oldMap[name]; + const n = newMap[name]; + if (!n) { + breaking.push(`${label} "${name}" was removed`); + continue; + } + if (!o.required && n.required) breaking.push(`${label} "${name}" went from optional to required`); + if (opts.type && !eq(o.type, n.type)) breaking.push(`${label} "${name}" changed type: ${o.type ?? '(none)'} -> ${n.type ?? '(none)'}`); + if (opts.default && !eq(o.default, n.default)) { + breaking.push(`${label} "${name}" changed default: ${JSON.stringify(o.default ?? null)} -> ${JSON.stringify(n.default ?? null)}`); + } + } + for (const name of Object.keys(newMap)) { + if (oldMap[name]) continue; + const n = newMap[name]; + // workflow_call enforces `required` even when a default exists, so a new required + // input/secret always breaks existing callers — new interface must be optional. + if (n.required) { + breaking.push(`new ${label} "${name}" is required — existing callers that do not pass it break`); + } else { + additions.push(`new ${label} "${name}"`); + } + } +}; + +const diffOutputs = (oldList, newList, label, breaking, additions) => { + for (const name of oldList) { + if (!newList.includes(name)) breaking.push(`${label} output "${name}" was removed`); + } + for (const name of newList) { + if (!oldList.includes(name)) additions.push(`new ${label} output "${name}"`); + } +}; + +const diffContracts = (snapshot, current) => { + const breaking = []; + const additions = []; + + for (const file of Object.keys(snapshot.workflows)) { + const o = snapshot.workflows[file]; + const n = current.workflows[file]; + if (!n) { + breaking.push(`reusable workflow ${file} was removed (or is no longer workflow_call)`); + continue; + } + diffEntryMaps(o.inputs, n.inputs, `${file} input`, { type: true, default: true }, breaking, additions); + diffEntryMaps(o.secrets, n.secrets, `${file} secret`, {}, breaking, additions); + diffOutputs(o.outputs, n.outputs, file, breaking, additions); + } + for (const file of Object.keys(current.workflows)) { + if (!snapshot.workflows[file]) additions.push(`new reusable workflow ${file}`); + } + + for (const step of Object.keys(snapshot.steps)) { + const o = snapshot.steps[step]; + const n = current.steps[step]; + if (!n) { + breaking.push(`composite action steps/${step} was removed`); + continue; + } + diffEntryMaps(o.inputs, n.inputs, `steps/${step} input`, { default: true }, breaking, additions); + diffOutputs(o.outputs, n.outputs, `steps/${step}`, breaking, additions); + } + for (const step of Object.keys(current.steps)) { + if (!snapshot.steps[step]) additions.push(`new composite action steps/${step}`); + } + + return { breaking, additions }; +}; + +module.exports = { diffContracts }; diff --git a/lib/updateContracts.js b/lib/updateContracts.js new file mode 100644 index 0000000..843ffb4 --- /dev/null +++ b/lib/updateContracts.js @@ -0,0 +1,45 @@ +// Regenerates contracts/interfaces.json from the current workflow/action sources. +// +// npm run contracts:update — registers additions; REFUSES breaking changes +// ALLOW_BREAKING=1 npm run contracts:update — consciously bakes in a breaking change (the +// snapshot diff then shows it in PR review) +// +// See contracts/README.md for what counts as breaking. + +const fs = require('node:fs'); +const path = require('node:path'); +const { collectInterfaces } = require('./workflowInterfaces'); +const { diffContracts } = require('./contractDiff'); + +const root = path.join(__dirname, '..'); +const file = path.join(root, 'contracts', 'interfaces.json'); + +const current = collectInterfaces(root); +// Read-and-catch instead of exists-then-read: no window for the file to change between the +// check and the use (CodeQL js/file-system-race), and a first run without a snapshot still +// starts from an empty contract. +let snapshot = { workflows: {}, steps: {} }; +try { + snapshot = JSON.parse(fs.readFileSync(file, 'utf8')); +} catch (err) { + if (err.code !== 'ENOENT') throw err; +} + +const { breaking, additions } = diffContracts(snapshot, current); + +if (breaking.length > 0 && !process.env.ALLOW_BREAKING) { + console.error('Refusing to update contracts/interfaces.json: breaking interface changes detected.'); + for (const b of breaking) console.error(` - ${b}`); + console.error('If intentional, re-run with ALLOW_BREAKING=1 and describe the migration in your PR.'); + process.exit(1); +} + +if (breaking.length === 0 && additions.length === 0) { + console.log('contracts/interfaces.json is already up to date.'); + process.exit(0); +} + +fs.writeFileSync(file, `${JSON.stringify(current, null, 4)}\n`); +for (const a of additions) console.log(`registered: ${a}`); +for (const b of breaking) console.log(`BREAKING (accepted via ALLOW_BREAKING=1): ${b}`); +console.log('contracts/interfaces.json updated.'); diff --git a/lib/workflowInterfaces.js b/lib/workflowInterfaces.js new file mode 100644 index 0000000..bc9831d --- /dev/null +++ b/lib/workflowInterfaces.js @@ -0,0 +1,172 @@ +// Extracts the public interface — inputs, secrets, outputs — of every reusable workflow +// (`on: workflow_call`) and composite action in this repo, straight from the YAML source. +// Vendored strict-subset parser, same policy as flatYaml.js: no YAML dependency (these checks +// must run on a bare runner via `node --test`), and a hard error on shapes it does not +// recognize rather than a silent misparse. It understands exactly the layout this repo uses: +// 2-space indentation, one `name:` entry per line, scalar `required`/`type`/`default` props, +// and block-scalar descriptions (which it skips by indentation). +// +// Consumed by lib/backcompat.test.js, which compares the extracted interfaces against the +// committed snapshot in contracts/interfaces.json — see contracts/README.md for the rules. + +const fs = require('node:fs'); +const path = require('node:path'); + +// Props that form the compat contract. `description` is parsed (so single-line descriptions are +// recognized as props, not entries) but dropped from the result — prose is not part of the +// contract. `value` (workflow outputs) is likewise dropped: only the output's existence is. +const PROP_RE = /^(description|required|type|default|value):(.*)$/; +const ENTRY_RE = /^([A-Za-z0-9_.-]+):\s*(#.*)?$/; + +const indentOf = (line) => line.length - line.trimStart().length; + +const isSkippable = (line) => { + const t = line.trim(); + return t === '' || t.startsWith('#'); +}; + +const parseScalar = (raw) => { + const v = raw.trim(); + if (v.length >= 2 && v.startsWith('"') && v.endsWith('"')) return v.slice(1, -1); + if (v.length >= 2 && v.startsWith("'") && v.endsWith("'")) return v.slice(1, -1); + return v.replace(/\s+#.*$/, ''); +}; + +// First non-skippable line at or below `indent` — the exclusive end of the block starting at +// `start`. +const blockEnd = (lines, start, indent) => { + for (let i = start; i < lines.length; i++) { + if (isSkippable(lines[i])) continue; + if (indentOf(lines[i]) <= indent) return i; + } + return lines.length; +}; + +// Collects the `name:` entries of an inputs/secrets/outputs section whose entries sit at +// `entryIndent`, with scalar props two spaces deeper. Anything deeper than the props level is +// block-scalar description content and is skipped; a non-entry line at the entry level is a +// shape this parser does not understand and fails hard. +const collectEntries = (lines, start, entryIndent, file) => { + const entries = {}; + let current = null; + let i = start; + for (; i < lines.length; i++) { + const line = lines[i]; + if (isSkippable(line)) continue; + const indent = indentOf(line); + if (indent < entryIndent) break; + if (indent === entryIndent) { + const m = line.trim().match(ENTRY_RE); + if (!m) throw new Error(`${file}: unexpected line in interface section: "${line.trim()}"`); + current = m[1]; + entries[current] = {}; + } else if (indent === entryIndent + 2 && current !== null) { + const m = line.trim().match(PROP_RE); + if (m) entries[current][m[1]] = parseScalar(m[2]); + } + } + return { entries, end: i }; +}; + +// Normalizes raw props into the contract shape: `required` becomes a boolean (absent = false), +// `type` and `default` are kept verbatim when present (a missing default is distinct from +// `default: ""` — both matter for callers), prose props are dropped. +const normalizeEntry = (props) => { + const out = { required: props.required === 'true' }; + if (props.type !== undefined) out.type = props.type; + if (props.default !== undefined) out.default = props.default; + return out; +}; + +const normalizeSection = (entries, keep) => { + const out = {}; + for (const name of Object.keys(entries).sort()) { + const entry = normalizeEntry(entries[name]); + const kept = {}; + for (const k of keep) if (entry[k] !== undefined) kept[k] = entry[k]; + out[name] = kept; + } + return out; +}; + +// Returns the interface of a reusable workflow, or null when the file is not `workflow_call` +// (e.g. this repo's own CI) and therefore has no caller-facing contract. +const extractWorkflowInterface = (text, file) => { + const lines = text.split('\n'); + const onIdx = lines.findIndex((l) => /^on:\s*(#.*)?$/.test(l)); + if (onIdx === -1) return null; + const onEnd = blockEnd(lines, onIdx + 1, 0); + + let wcIdx = -1; + for (let i = onIdx + 1; i < onEnd; i++) { + if (!isSkippable(lines[i]) && indentOf(lines[i]) === 2 + && /^workflow_call:\s*(#.*)?$/.test(lines[i].trim())) { wcIdx = i; break; } + } + if (wcIdx === -1) return null; + const wcEnd = blockEnd(lines, wcIdx + 1, 2); + + const sections = { inputs: {}, secrets: {}, outputs: {} }; + for (let i = wcIdx + 1; i < wcEnd; i++) { + const line = lines[i]; + if (isSkippable(line) || indentOf(line) !== 4) continue; + const m = line.trim().match(/^(inputs|secrets|outputs):\s*(#.*)?$/); + if (!m) throw new Error(`${file}: unexpected workflow_call section: "${line.trim()}"`); + const { entries, end } = collectEntries(lines, i + 1, 6, file); + sections[m[1]] = entries; + i = end - 1; + } + return { + inputs: normalizeSection(sections.inputs, ['required', 'type', 'default']), + secrets: normalizeSection(sections.secrets, ['required']), + outputs: Object.keys(sections.outputs).sort(), + }; +}; + +// Composite actions: top-level `inputs:` / `outputs:` sections, entries at indent 2, props at 4. +const extractActionInterface = (text, file) => { + const lines = text.split('\n'); + const sections = { inputs: {}, outputs: {} }; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (isSkippable(line) || indentOf(line) !== 0) continue; + const m = line.trim().match(/^(inputs|outputs):\s*(#.*)?$/); + if (!m) continue; + const { entries, end } = collectEntries(lines, i + 1, 2, file); + sections[m[1]] = entries; + i = end - 1; + } + return { + inputs: normalizeSection(sections.inputs, ['required', 'default']), + outputs: Object.keys(sections.outputs).sort(), + }; +}; + +// The full contract of the repo at `root`: every workflow_call workflow under +// .github/workflows/ and every steps//action.yml. +const collectInterfaces = (root) => { + const workflows = {}; + const wfDir = path.join(root, '.github', 'workflows'); + for (const f of fs.readdirSync(wfDir).sort()) { + if (!f.endsWith('.yml') && !f.endsWith('.yaml')) continue; + const iface = extractWorkflowInterface(fs.readFileSync(path.join(wfDir, f), 'utf8'), f); + if (iface) workflows[f] = iface; + } + const steps = {}; + const stepsDir = path.join(root, 'steps'); + for (const d of fs.readdirSync(stepsDir).sort()) { + const p = path.join(stepsDir, d, 'action.yml'); + // Read-and-catch, not exists-then-read (CodeQL js/file-system-race): a directory entry + // without an action.yml is simply not a composite action. + let text; + try { + text = fs.readFileSync(p, 'utf8'); + } catch (err) { + if (err.code === 'ENOENT') continue; + throw err; + } + steps[d] = extractActionInterface(text, `steps/${d}/action.yml`); + } + return { workflows, steps }; +}; + +module.exports = { extractWorkflowInterface, extractActionInterface, collectInterfaces }; diff --git a/lib/workflowInterfaces.test.js b/lib/workflowInterfaces.test.js new file mode 100644 index 0000000..50a7732 --- /dev/null +++ b/lib/workflowInterfaces.test.js @@ -0,0 +1,126 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { extractWorkflowInterface, extractActionInterface, collectInterfaces } = require('./workflowInterfaces'); + +test('extracts a workflow_call interface: inputs with block descriptions, secrets, outputs', () => { + const text = [ + 'name: Example', + '', + 'on:', + ' workflow_call:', + ' inputs:', + ' env:', + ' description: "Target environment"', + ' required: true', + ' type: string', + ' domain:', + ' description: |', + ' Multi-line description that must be skipped, even when a line of it', + ' looks like a prop, e.g.:', + ' default: not-a-real-default', + ' required: false', + ' type: string', + ' default: ""', + ' submodules:', + ' required: false', + ' type: string', + ' default: "false"', + ' secrets:', + ' OP_SERVICE_ACCOUNT_TOKEN:', + ' description: "token"', + ' required: true', + ' outputs:', + ' deploymentUrl:', + ' description: "url"', + ' value: ${{ jobs.deploy.outputs.deploymentUrl }}', + '', + 'jobs:', + ' deploy:', + ' runs-on: ubuntu-latest', + ' outputs:', + ' deploymentUrl: ${{ steps.deploy.outputs.deploymentUrl }}', + ].join('\n'); + + assert.deepEqual(extractWorkflowInterface(text, 'example.yml'), { + inputs: { + domain: { required: false, type: 'string', default: '' }, + env: { required: true, type: 'string' }, + submodules: { required: false, type: 'string', default: 'false' }, + }, + secrets: { OP_SERVICE_ACCOUNT_TOKEN: { required: true } }, + outputs: ['deploymentUrl'], + }); +}); + +test('a workflow without workflow_call has no caller-facing interface', () => { + const text = ['name: CI', '', 'on:', ' pull_request:', ' push:', ' branches: [main]'].join('\n'); + assert.equal(extractWorkflowInterface(text, 'ci.yml'), null); +}); + +test('a job-level outputs block never bleeds into the workflow_call interface', () => { + const text = [ + 'on:', + ' workflow_call:', + ' inputs:', + ' env:', + ' required: true', + ' type: string', + 'jobs:', + ' x:', + ' outputs:', + ' leaked: value', + ].join('\n'); + assert.deepEqual(extractWorkflowInterface(text, 'x.yml').outputs, []); +}); + +test('fails hard on an interface section shape it does not understand', () => { + const text = [ + 'on:', + ' workflow_call:', + ' inputs:', + ' env: {required: true, type: string}', + ].join('\n'); + assert.throws(() => extractWorkflowInterface(text, 'x.yml'), /unexpected line in interface section/); +}); + +test('extracts a composite action interface, keeping expression defaults verbatim', () => { + const text = [ + 'name: "Setup"', + 'description: "Checks out the repository."', + '', + 'inputs:', + ' repository:', + ' description: "The repository to checkout"', + ' required: false', + ' default: ${{ github.repository }}', + ' install:', + ' required: false', + ' default: "true"', + '', + 'runs:', + ' using: "composite"', + ' steps:', + ' - name: Checkout repository', + ' uses: actions/checkout@abc', + ].join('\n'); + + assert.deepEqual(extractActionInterface(text, 'steps/setup/action.yml'), { + inputs: { + install: { required: false, default: 'true' }, + repository: { required: false, default: '${{ github.repository }}' }, + }, + outputs: [], + }); +}); + +test('collectInterfaces parses every real workflow and action in this repo without errors', () => { + const { workflows, steps } = collectInterfaces(path.join(__dirname, '..')); + // Spot-check against interfaces that certainly exist; the full set lives in + // contracts/interfaces.json and is exercised by backcompat.test.js. + assert.ok(workflows['deploy-vercel.yml'], 'deploy-vercel.yml should expose a workflow_call interface'); + assert.equal(workflows['deploy-vercel.yml'].inputs.env.required, true); + assert.ok(steps.setup, 'steps/setup should expose an interface'); + assert.equal(steps.setup.inputs.install.default, 'true'); + assert.ok(!workflows['ci.yml'], 'ci.yml is not workflow_call and must not appear'); +}); diff --git a/package.json b/package.json index dad5369..f497be2 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "private": true, "description": "Shared composite actions and reusable workflows. No runtime dependencies on purpose: action scripts must run on a bare runner.", "scripts": { - "test": "node --test 'lib/*.test.js' 'steps/**/*.test.js'" + "test": "node --test 'lib/*.test.js' 'steps/**/*.test.js'", + "contracts:update": "node lib/updateContracts.js" } } diff --git a/steps/credential-retrieval/action.yml b/steps/credential-retrieval/action.yml index 66f6f2a..6f93356 100644 --- a/steps/credential-retrieval/action.yml +++ b/steps/credential-retrieval/action.yml @@ -55,13 +55,18 @@ inputs: secret-refs: | RELEASE_TOKEN=op://kv_app_infra/ARABOT_PAT/credential - SLACK_BOT_TOKEN=${{ inputs.op-slack-bot-token-path }} + SLACK_BOT_TOKEN={{ inputs.op-slack-bot-token-path }} + + (Write the second example with a leading dollar sign — the runner template-evaluates this + manifest when loading the action, prose included, so the literal expression syntax cannot + appear anywhere in this file's descriptions.) NAME must match [A-Za-z_][A-Za-z0-9_]*. A line whose ref side is empty (e.g. `SLACK_BOT_TOKEN=`) is treated as "optional secret not configured" and resolves to an empty string instead of failing — this lets callers wire through optional op-*-path inputs unconditionally. A line with no '=' at all is a malformed entry and fails the step (this is NOT the same as an empty - ref — it usually means a caller forgot to append `=${{ inputs.op-foo-path }}`). + ref — it usually means a caller forgot to append `={{ inputs.op-foo-path }}`, dollar sign + included). required: false default: "" @@ -70,8 +75,9 @@ outputs: description: | 'byref' only. JSON object mapping each NAME from `secret-refs` to its resolved value; always valid JSON — '{}' for any other mode (or when secret-refs is empty), so fromJSON() is always - safe to call unconditionally. Read a field with fromJSON(), e.g.: - ${{ fromJSON(steps..outputs.secrets).RELEASE_TOKEN }} + safe to call unconditionally. Read a field with fromJSON(), adding the leading dollar sign + (omitted here — see secret-refs above): + {{ fromJSON(steps..outputs.secrets).RELEASE_TOKEN }} value: ${{ steps.byref.outputs.secrets || '{}' }} runs: diff --git a/steps/setup/action.yml b/steps/setup/action.yml index 2b634c8..e7a5b12 100644 --- a/steps/setup/action.yml +++ b/steps/setup/action.yml @@ -17,6 +17,20 @@ inputs: ref: description: "The branch, tag or SHA to checkout." required: false + submodules: + description: "Pass-through to actions/checkout's `submodules` (false / true / recursive). A private submodule needs `token` (or `submodules-token`) to have read access to it." + required: false + default: "false" + submodules-token: + description: | + Optional dedicated token used ONLY to fetch submodules, via a per-command git credential that + is never written to any persisted git config — separate from `token`, which is used for the + main checkout. Use this when the submodule needs a token scoped differently than the main + repo's (e.g. a fine-grained PAT that can read only the submodule's repo, not this one). Leave + empty to fetch submodules with the same `token` as the main checkout (actions/checkout's + normal, built-in behavior). + required: false + default: "" allow-unsafe-pr-checkout: description: | Pass-through to actions/checkout's `allow-unsafe-pr-checkout`. Only set this to "true" if the @@ -59,8 +73,36 @@ runs: fetch-depth: ${{ inputs.fetch-depth }} token: ${{ inputs.token }} ref: ${{ inputs.ref }} + # When a dedicated submodules-token is given, skip actions/checkout's own submodule fetch + # here (it would use `token`, not `submodules-token`) — the next step does it instead. + submodules: ${{ inputs.submodules-token != '' && 'false' || inputs.submodules }} allow-unsafe-pr-checkout: ${{ inputs.allow-unsafe-pr-checkout }} + - name: Checkout submodules with a dedicated token + if: ${{ inputs.submodules != 'false' && inputs.submodules-token != '' }} + shell: bash + env: + SUBMODULES_TOKEN: ${{ inputs.submodules-token }} + RECURSIVE_FLAG: ${{ inputs.submodules == 'recursive' && '--recursive' || '' }} + run: | + set -euo pipefail + # actions/checkout itself rewrites SSH submodule URLs (git@github.com:...) to HTTPS when + # using token auth — replicate that here, since we're bypassing its built-in submodule + # handling specifically to use a separate, differently-scoped token. + # + # MUST be --global, not --local: `git submodule update --init` clones each submodule via a + # freshly spawned `git clone ` subprocess, and that subprocess's target + # directory has no git repo (hence no --local config) until the clone itself creates one — + # so a --local rule on the superproject is invisible to it. Confirmed empirically: with + # --local the clone still attempted the raw SSH URL and failed; with --global it correctly + # saw the rewritten HTTPS URL. Safe here since every workflow in this repo runs on a fresh, + # ephemeral GitHub-hosted runner — nothing else in the job is affected by this rule. + git config --global url."https://github.com/".insteadOf "git@github.com:" + AUTH_HEADER="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$SUBMODULES_TOKEN" | base64 -w0)" + # `-c` applies only to this one git invocation — never written to any persisted git config, + # unlike the url.insteadOf rewrite above. + git -c "http.https://github.com/.extraheader=${AUTH_HEADER}" submodule update --init $RECURSIVE_FLAG + - name: Install pnpm uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: