@plainconceptsplatform/workflows 1.11.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -81,7 +81,7 @@ npx --yes --package @plainconceptsplatform/workflows@latest workflows search "ci
81
81
 
82
82
  ## Templates
83
83
 
84
- Install optional standalone templates with `add --template`. Available templates are `agentics-checks`, `agentics-error-report`, `agentics-maintenance`, `app-ci-dotnet-next`, `app-ci-node-monorepo`, `bug-report`, `feature-request`, `github-release`, `opencode.ci.json`, `platform-template` and `quality-gates`. CI templates are stack-specific copies, not a combined template. `quality-gates` and `platform-template` are bundles for the platform family: they stay package-managed and `update` refreshes them where they are installed; see `docs/family-templates.md` in the repository. `github-release` publishes generated release notes when a `v*` tag is pushed. Edit their top-level `env:` values for repository paths, package names, and commands.
84
+ Install optional standalone templates with `add --template`. Available templates are `agentics-checks`, `agentics-error-report`, `agentics-maintenance`, `app-ci-dotnet-next`, `app-ci-node-monorepo`, `bug-report`, `feature-request`, `github-release`, `opencode.ci.json`, `platform-template` and `quality-gates`. CI templates are stack-specific copies, not a combined template. `quality-gates` and `platform-template` are bundles: they stay package-managed and `update` refreshes them where they are installed. `platform-template` reads the platform contract, `.platform/contract.yml`; see `docs/platform-contracts.md` and `docs/family-templates.md` in the repository. `github-release` publishes generated release notes when a `v*` tag is pushed. Edit their top-level `env:` values for repository paths, package names, and commands.
85
85
 
86
86
  `agentics-error-report` is worth installing everywhere. Once a day it classifies how *this package's* workflows behaved in the repository and files what broke upstream, so the package gets fixed instead of every repository working around the same bug. It is deterministic no model runs, because a model asked to summarise a failure paraphrases whatever the log held and it sends only the names this package gives its own workflows, jobs and steps, a conclusion, a bucketed runner label, a count and a classification id. No log line, branch, title, path or issue number leaves the repository, the repository's own workflows are counted and never inspected, and a leak scanner withholds any report mentioning the repository or its owner and fails the run rather than filing it. Clear `UPSTREAM_NAME` in its `env:` to compute the report into the job summary and file nothing.
87
87
 
@@ -74,7 +74,7 @@ export const catalogTemplates = [
74
74
  { name: "feature-request", file: "feature_request.yml", description: "Feature request issue template scoped to small, well-scoped improvements (Small/Medium only; large work belongs in a planning issue).", target: ".github/ISSUE_TEMPLATE/feature_request.yml" },
75
75
  { name: "github-release", file: "github-release.yml", description: "Publishes or updates a GitHub Release with generated notes whenever a v* tag is pushed." },
76
76
  { name: "opencode.ci.json", file: "opencode.ci.json", description: "Standalone OpenCode CI config: plainconcepts provider, GLM model registration, ci-workflow-agent, and LSP defaults for consumer repositories." },
77
- { name: "platform-template", file: "scripts/check-platform-conformance.mjs", directory: "platform-template", bundle: true, description: "Platform family template sync: a weekly workflow that runs copier update against the Kitchen-Sink template and opens a pull request per new tag, and a CI guard that keeps .platform/manifest paths unchanged outside a sync. Does nothing without .copier-answers.yml." },
77
+ { name: "platform-template", file: "scripts/check-platform-conformance.mjs", directory: "platform-template", bundle: true, description: "Template sync driven by the platform contract (.platform/contract.yml): a weekly workflow that runs its sync.update command and opens or updates one pull request, and a CI guard that keeps its owned paths equal to the template outside a sync. Does nothing without a contract." },
78
78
  { name: "quality-gates", file: "scripts/check-coverage.mjs", directory: "quality-gates", bundle: true, description: "Platform family quality gates: coverage, complexity, duplication and mutation gates, the Engineering Excellence facets, the Semgrep hygiene rules, jscpd and ZAP triage. Floors come from the app's quality.config.json; baselines stay app files." },
79
79
  ];
80
80
  /** Bundle templates: several package-managed files, refreshed by `update` where installed. */
@@ -9,7 +9,7 @@
9
9
  # engineering-excellence-{coverage,complexity,security,sbom,hygiene} facets Project-Health
10
10
  # collects. The facets upload from main only, which this workflow reaches through `App: Deploy` on
11
11
  # every push and through its own weekly schedule. The platform-conformance job runs the
12
- # platform-template guard where the app has adopted the template.
12
+ # platform-template guard where the app declares a platform contract.
13
13
  #
14
14
  # Everything that differs between apps is a top-level `env:` value below or a floor in
15
15
  # quality.config.json. Job names are required status checks on main: do not rename them.
@@ -546,8 +546,10 @@ jobs:
546
546
  if: env.REPO_CHECK_COMMANDS != ''
547
547
  run: bash -euo pipefail -c "$REPO_CHECK_COMMANDS"
548
548
 
549
- # Platform files (.platform/manifest) change only through a template sync. The guard comes with the
550
- # platform-template template; a repository without .copier-answers.yml has not adopted it and passes.
549
+ # Paths a platform contract (.platform/contract.yml) marks as owned change only through a template
550
+ # sync. The guard comes with the platform-template template; a repository with no contract (nor
551
+ # .copier-answers.yml with .platform/manifest, read as one) has not adopted it and passes. The
552
+ # owned files are also compared with the template at the recorded version wherever git can read it.
551
553
  platform-conformance:
552
554
  name: Platform conformance
553
555
  runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
@@ -559,25 +561,25 @@ jobs:
559
561
  - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
560
562
  with:
561
563
  node-version: ${{ env.NODE_VERSION }}
562
- - name: Platform files change only through a template sync
564
+ - name: Owned files change only through a template sync
563
565
  env:
564
566
  EVENT: ${{ github.event_name }}
565
567
  BASE_REF: ${{ github.base_ref }}
566
568
  run: |
567
569
  set -euo pipefail
568
- if [ ! -f .copier-answers.yml ]; then
569
- echo "No .copier-answers.yml: this repository has not adopted the platform template."
570
+ if [ ! -f .platform/contract.yml ] && { [ ! -f .copier-answers.yml ] || [ ! -f .platform/manifest ]; }; then
571
+ echo "No .platform/contract.yml: this repository declares no platform contract."
570
572
  exit 0
571
573
  fi
572
574
  if [ ! -f scripts/check-platform-conformance.mjs ]; then
573
- echo "::error::.copier-answers.yml is here but scripts/check-platform-conformance.mjs is not. Install it with: npx @plainconceptsplatform/workflows add --template platform-template"
575
+ echo "::error::This repository has a platform contract but no scripts/check-platform-conformance.mjs. Install it with: npx @plainconceptsplatform/workflows add --template platform-template"
574
576
  exit 1
575
577
  fi
576
578
  if [ "$EVENT" = pull_request ]; then
577
579
  git fetch origin "$BASE_REF"
578
- node scripts/check-platform-conformance.mjs --base "origin/$BASE_REF" | tee -a "$GITHUB_STEP_SUMMARY"
580
+ node scripts/check-platform-conformance.mjs --compare --base "origin/$BASE_REF" | tee -a "$GITHUB_STEP_SUMMARY"
579
581
  else
580
- node scripts/check-platform-conformance.mjs | tee -a "$GITHUB_STEP_SUMMARY"
582
+ node scripts/check-platform-conformance.mjs --compare | tee -a "$GITHUB_STEP_SUMMARY"
581
583
  fi
582
584
 
583
585
  mutation-web:
@@ -1,19 +1,15 @@
1
1
  # Managed by @plainconceptsplatform/workflows. Source: loops/templates/platform-template/.github/workflows/platform-template-sync.yml. Update with `workflows update --force`; consumer edits may be overwritten.
2
2
  name: "Platform: Template sync"
3
3
 
4
- # Brings the platform code of a family app up to the newest Kitchen-Sink template tag. Plain YAML,
5
- # no model: copier does the merge, and a person or the merge gate takes it from the pull request.
4
+ # Pulls the newest version of the upstream template named in .platform/contract.yml (see
5
+ # docs/platform-contracts.md in agentic-workflows). Plain YAML, no model. Without a contract it does
6
+ # nothing. With one it runs `sync.update` and opens or updates one pull request from
7
+ # `platform/template-sync`. copier is installed when the command starts with `copier`; any other
8
+ # command is the repository's to provide. Conflict markers left in an owned file fail the
9
+ # conformance guard, so the merge gate repairs the pull request like any red one.
6
10
  #
7
- # Does nothing without .copier-answers.yml, the marker a repository gets when it adopts the
8
- # template. With it, `copier update` runs against the newest `v*` tag of the template named in
9
- # `_src_path`. No change means the app is current and the run ends there. Otherwise the result is
10
- # committed to `platform/sync-<tag>` and opened as a pull request titled with the tag. A file the
11
- # app had edited gets copier's inline conflict markers; scripts/check-platform-conformance.mjs
12
- # fails CI on them, so the merge gate repairs the pull request (`remediated`) like any red one.
13
- #
14
- # Kitchen-Sink is private, so the Platform App token (BOT_APP_ID / BOT_PRIVATE_KEY) reads it. The
15
- # same token pushes the branch and opens the pull request, because a push made with GITHUB_TOKEN
16
- # starts no workflow and CI would never run on the sync.
11
+ # The Platform App token (BOT_APP_ID / BOT_PRIVATE_KEY) reads a private template and pushes, because
12
+ # a push made with GITHUB_TOKEN starts no workflow and CI would never run on the sync.
17
13
  on:
18
14
  schedule:
19
15
  - cron: "17 5 * * 1"
@@ -29,6 +25,8 @@ concurrency:
29
25
  env:
30
26
  # copier 9.19.1 needs Python 3.10 or newer on the runner.
31
27
  COPIER_VERSION: "9.19.1"
28
+ NODE_VERSION: "22"
29
+ SYNC_BRANCH: platform/template-sync
32
30
 
33
31
  jobs:
34
32
  sync:
@@ -41,19 +39,40 @@ jobs:
41
39
  fetch-depth: 0
42
40
  persist-credentials: false
43
41
 
44
- - name: Has this repository adopted the template?
45
- id: marker
42
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
43
+ with:
44
+ node-version: ${{ env.NODE_VERSION }}
45
+
46
+ # An invalid contract fails here, with the file and the field in the error.
47
+ - name: Read the platform contract
48
+ id: contract
49
+ run: |
50
+ set -euo pipefail
51
+ node scripts/check-platform-conformance.mjs --contract | tee -a "$GITHUB_OUTPUT"
52
+
53
+ - name: Decide whether to sync
54
+ id: decide
55
+ env:
56
+ CONTRACT: ${{ steps.contract.outputs.contract }}
57
+ SELF: ${{ steps.contract.outputs.self }}
58
+ UPDATE: ${{ steps.contract.outputs.update }}
46
59
  run: |
47
- if [ -f .copier-answers.yml ]; then
48
- echo "adopted=true" >> "$GITHUB_OUTPUT"
60
+ set -euo pipefail
61
+ run=false
62
+ if [ "$CONTRACT" != present ]; then
63
+ echo "No .platform/contract.yml (nor .copier-answers.yml with .platform/manifest): nothing to sync."
64
+ elif [ "$SELF" = true ]; then
65
+ echo "This repository is the template source.url names: nothing to sync."
66
+ elif [ -z "$UPDATE" ]; then
67
+ echo "The platform contract declares no sync.update: nothing to sync."
49
68
  else
50
- echo "adopted=false" >> "$GITHUB_OUTPUT"
51
- echo "No .copier-answers.yml: this repository has not adopted the platform template. Nothing to do."
69
+ run=true
52
70
  fi
71
+ echo "run=$run" >> "$GITHUB_OUTPUT"
53
72
 
54
73
  - name: Generate App token
55
74
  id: app-token
56
- if: steps.marker.outputs.adopted == 'true'
75
+ if: steps.decide.outputs.run == 'true'
57
76
  uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
58
77
  with:
59
78
  app-id: ${{ secrets.BOT_APP_ID }}
@@ -62,7 +81,7 @@ jobs:
62
81
 
63
82
  # uvx where uv is installed, then pipx, then a virtual environment from the runner's Python.
64
83
  - name: Install copier
65
- if: steps.marker.outputs.adopted == 'true'
84
+ if: steps.decide.outputs.run == 'true' && startsWith(steps.contract.outputs.update, 'copier ')
66
85
  run: |
67
86
  set -euo pipefail
68
87
  bin="$RUNNER_TEMP/copier-bin"
@@ -87,8 +106,10 @@ jobs:
87
106
  # The token reaches git through process-scoped config only, never a file on the runner.
88
107
  - name: Update from the template
89
108
  id: update
90
- if: steps.marker.outputs.adopted == 'true'
109
+ if: steps.decide.outputs.run == 'true'
91
110
  env:
111
+ UPDATE: ${{ steps.contract.outputs.update }}
112
+ BEFORE: ${{ steps.contract.outputs.version }}
92
113
  GIT_CONFIG_COUNT: "1"
93
114
  GIT_CONFIG_KEY_0: url.https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/.insteadOf
94
115
  GIT_CONFIG_VALUE_0: https://github.com/
@@ -98,21 +119,19 @@ jobs:
98
119
  GIT_COMMITTER_EMAIL: ${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com
99
120
  run: |
100
121
  set -euo pipefail
101
- commit_of() { sed -n 's/^_commit:[[:space:]]*//p' .copier-answers.yml | tr -d "\"'" | head -n 1; }
102
- before=$(commit_of)
103
- copier update --trust --defaults --conflict inline
104
- after=$(commit_of)
122
+ echo "sync.update: $UPDATE"
123
+ bash -euo pipefail -c "$UPDATE"
105
124
  if [ -z "$(git status --porcelain)" ]; then
106
- echo "Already on the newest template ($before). Nothing to do."
125
+ echo "Already on the newest template (${BEFORE:-no version recorded}). Nothing to do."
107
126
  echo "changed=false" >> "$GITHUB_OUTPUT"
108
127
  exit 0
109
128
  fi
129
+ after=$(node scripts/check-platform-conformance.mjs --contract | sed -n 's/^version=//p')
110
130
  git add --all
111
131
  conflicts=$(git grep --cached -l -E '^(<<<<<<<|>>>>>>>)( |$)' -- . || true)
112
132
  {
113
133
  echo "changed=true"
114
- echo "before=$before"
115
- echo "tag=$after"
134
+ echo "tag=${after:-$(git rev-parse --short HEAD)}"
116
135
  echo "files<<EOF"
117
136
  git diff --cached --name-status
118
137
  echo "EOF"
@@ -121,14 +140,19 @@ jobs:
121
140
  echo "EOF"
122
141
  } >> "$GITHUB_OUTPUT"
123
142
 
124
- - name: Open the sync pull request
143
+ # One sync pull request at a time. An open one for the same version is left alone, so the
144
+ # conflict resolutions pushed to it survive; a newer version replaces its branch and its text.
145
+ - name: Open or update the sync pull request
125
146
  if: steps.update.outputs.changed == 'true'
126
147
  env:
127
148
  GH_TOKEN: ${{ steps.app-token.outputs.token }}
128
149
  REPO: ${{ github.repository }}
129
150
  BASE: ${{ github.event.repository.default_branch }}
130
- BEFORE: ${{ steps.update.outputs.before }}
151
+ SOURCE_NAME: ${{ steps.contract.outputs.source_name }}
152
+ SOURCE_WEB: ${{ steps.contract.outputs.source_web }}
153
+ BEFORE: ${{ steps.contract.outputs.version }}
131
154
  TAG: ${{ steps.update.outputs.tag }}
155
+ UPDATE: ${{ steps.contract.outputs.update }}
132
156
  FILES: ${{ steps.update.outputs.files }}
133
157
  CONFLICTS: ${{ steps.update.outputs.conflicts }}
134
158
  GIT_CONFIG_COUNT: "1"
@@ -140,25 +164,29 @@ jobs:
140
164
  GIT_COMMITTER_EMAIL: ${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com
141
165
  run: |
142
166
  set -euo pipefail
143
- branch="platform/sync-$TAG"
144
- if [ -n "$(gh pr list --repo "$REPO" --head "$branch" --state open --json number --jq '.[].number')" ]; then
145
- echo "A pull request for $TAG is already open on $branch. Nothing to do."
167
+ marker="<!-- platform-template-sync: $TAG -->"
168
+ open=$(gh pr list --repo "$REPO" --head "$SYNC_BRANCH" --state open --json number --jq '.[0].number // empty')
169
+ if [ -n "$open" ] && gh pr view "$open" --repo "$REPO" --json body --jq .body | grep -qF -- "$marker"; then
170
+ echo "Pull request #$open already syncs $TAG. Nothing to do."
146
171
  exit 0
147
172
  fi
148
- src=$(sed -n 's/^_src_path:[[:space:]]*//p' .copier-answers.yml | tr -d "\"'" | head -n 1)
149
- web="${src#git+}"
150
- web="${web%.git}"
151
- git switch --create "$branch"
152
- git commit --quiet --message "chore(platform): sync the platform template to $TAG"
153
- git push --force "https://github.com/$REPO.git" "HEAD:refs/heads/$branch"
173
+ title="chore(platform): sync $SOURCE_NAME to $TAG"
174
+ git switch --create "$SYNC_BRANCH"
175
+ git commit --quiet --message "$title"
176
+ git push --force "https://github.com/$REPO.git" "HEAD:refs/heads/$SYNC_BRANCH"
177
+ case "$SOURCE_WEB" in
178
+ https://github.com/*) target="[\`$TAG\`]($SOURCE_WEB/releases/tag/$TAG)" ;;
179
+ *) target="\`$TAG\`" ;;
180
+ esac
154
181
  body="$RUNNER_TEMP/sync-body.md"
155
182
  {
156
- echo "Platform template sync from \`$BEFORE\` to [\`$TAG\`]($web/releases/tag/$TAG), made by \`copier update\`."
183
+ echo "$marker"
184
+ echo "Sync of the code owned by $SOURCE_NAME from \`${BEFORE:-no recorded version}\` to $target, made by \`$UPDATE\`."
157
185
  echo
158
186
  if [ -n "$CONFLICTS" ]; then
159
187
  echo "### Conflicts"
160
188
  echo
161
- echo "These files carry inline conflict markers where the app had edited platform code. CI fails on them until they are resolved."
189
+ echo "These files carry inline conflict markers where this repository had edited owned code. CI fails on them until they are resolved."
162
190
  echo
163
191
  printf '%s\n' "$CONFLICTS" | awk '{ printf "- `%s`\n", $0 }'
164
192
  echo
@@ -167,5 +195,8 @@ jobs:
167
195
  echo
168
196
  printf '%s\n' "$FILES" | awk -F '\t' '{ printf "- `%s` %s\n", $NF, $1 }'
169
197
  } > "$body"
170
- gh pr create --repo "$REPO" --base "$BASE" --head "$branch" \
171
- --title "chore(platform): sync the platform template to $TAG" --body-file "$body"
198
+ if [ -n "$open" ]; then
199
+ gh pr edit "$open" --repo "$REPO" --title "$title" --body-file "$body"
200
+ else
201
+ gh pr create --repo "$REPO" --base "$BASE" --head "$SYNC_BRANCH" --title "$title" --body-file "$body"
202
+ fi
@@ -1,21 +1,28 @@
1
1
  #!/usr/bin/env node
2
2
  // Managed by @plainconceptsplatform/workflows. Source: loops/templates/platform-template/scripts/check-platform-conformance.mjs. Update with `workflows update --force`; consumer edits may be overwritten.
3
- // The .platform/manifest paths change only in a template sync (a pull request that moves `_commit` in
4
- // .copier-answers.yml) or under an unexpired .platform/exceptions.yml entry. Conflict markers in a
5
- // manifest file and expired exceptions fail too. Offline; without .copier-answers.yml it passes silently.
3
+ // Keeps the paths a platform contract (.platform/contract.yml) marks as `owned` equal to the template
4
+ // they come from. A pull request may change them only in a template sync (it changes `sync.marker` or
5
+ // moves `sync.version`) or under an unexpired exception; conflict markers and expired exceptions fail.
6
+ // With --compare, owned files are also compared with `source.url` at the recorded version.
7
+ // Without a contract it passes silently. .copier-answers.yml + .platform/manifest is read as one.
6
8
  //
7
- // Usage: node scripts/check-platform-conformance.mjs [--base <git ref>] (no --base: markers and exceptions only)
9
+ // Usage: node scripts/check-platform-conformance.mjs [--base <git ref>] [--compare]
10
+ // node scripts/check-platform-conformance.mjs --contract (key=value lines for $GITHUB_OUTPUT)
8
11
 
9
12
  import { execFileSync } from 'node:child_process';
10
- import { existsSync, readFileSync } from 'node:fs';
11
- import { join } from 'node:path';
13
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import { basename, join } from 'node:path';
12
16
  import { fileURLToPath, pathToFileURL } from 'node:url';
13
17
 
14
- const ANSWERS = '.copier-answers.yml';
15
- const MANIFEST = '.platform/manifest';
16
- const EXCEPTIONS = '.platform/exceptions.yml';
18
+ export const CONTRACT = '.platform/contract.yml';
19
+ const LEGACY_ANSWERS = '.copier-answers.yml';
20
+ const LEGACY_MANIFEST = '.platform/manifest';
21
+ const DEFAULT_EXCEPTIONS = '.platform/exceptions.yml';
17
22
  const CONFLICT_MARKER = /^(?:<{7}|>{7})(?: |$)/m;
18
23
 
24
+ export class ContractError extends Error {}
25
+
19
26
  // gitwildmatch, the .gitignore syntax: a pattern with no inner slash matches at any depth, `**`
20
27
  // crosses directories, and a match on a directory covers everything below it.
21
28
  export function globToRegExp(glob) {
@@ -40,13 +47,12 @@ export function globToRegExp(glob) {
40
47
  }
41
48
  } else re += c.replace(/[.+^${}()|\\]/g, '\\$&');
42
49
  }
43
- // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp — the glob comes from the committed manifest and every metacharacter outside a class is escaped
50
+ // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp — the glob comes from the committed contract and every metacharacter outside a class is escaped
44
51
  return new RegExp(`^${anchored ? '' : '(?:.*/)?'}${re}(?:/.*)?$`);
45
52
  }
46
53
 
47
- export function parseManifest(text) {
48
- return text
49
- .split(/\r?\n/)
54
+ export function parsePatterns(globs) {
55
+ return globs
50
56
  .map((line) => line.trim())
51
57
  .filter((line) => line !== '' && !line.startsWith('#'))
52
58
  .map((line) => {
@@ -56,8 +62,10 @@ export function parseManifest(text) {
56
62
  });
57
63
  }
58
64
 
65
+ export const parseManifest = (text) => parsePatterns(text.split(/\r?\n/));
66
+
59
67
  // The last pattern that matches decides, as in .gitignore.
60
- export function inManifest(patterns, path) {
68
+ export function isOwned(patterns, path) {
61
69
  let matched = false;
62
70
  for (const { negate, regex } of patterns) if (regex.test(path)) matched = !negate;
63
71
  return matched;
@@ -70,15 +78,179 @@ const unquote = (value) => {
70
78
  return v.replace(/\s+#.*$/, '');
71
79
  };
72
80
 
73
- // The subset of YAML .platform/exceptions.yml uses: one `exceptions:` list of flat mappings.
74
- export function parseExceptions(text) {
81
+ function stripComment(line) {
82
+ let quote = '';
83
+ for (let i = 0; i < line.length; i++) {
84
+ const c = line[i];
85
+ if (quote) {
86
+ if (c === quote) quote = '';
87
+ } else if (c === '"' || c === "'") quote = c;
88
+ else if (c === '#' && (i === 0 || /\s/.test(line[i - 1]))) return line.slice(0, i);
89
+ }
90
+ return line;
91
+ }
92
+
93
+ function scalar(raw, at) {
94
+ const text = raw.trim();
95
+ if (/^[|>][+-]?$/.test(text) || /^[&*!{]/.test(text)) throw new ContractError(`${at}: "${text}" is not supported; write the value on one line`);
96
+ if (text.startsWith('[')) {
97
+ if (!text.endsWith(']')) throw new ContractError(`${at}: unclosed "["`);
98
+ const inner = text.slice(1, -1).trim();
99
+ return inner === '' ? [] : (inner.match(/"[^"]*"|'[^']*'|[^,]+/g) ?? []).map((item) => scalar(item, at));
100
+ }
101
+ if (/^["']/.test(text)) return unquote(text);
102
+ if (/^-?\d+$/.test(text)) return Number(text);
103
+ if (text === 'true' || text === 'false') return text === 'true';
104
+ if (text === '' || text === '~' || text === 'null') return null;
105
+ return text;
106
+ }
107
+
108
+ // The YAML a contract needs: nested mappings, block and flow lists of scalars, quotes and comments.
109
+ export function parseYamlSubset(text, file) {
110
+ const lines = [];
111
+ text.split(/\r?\n/).forEach((raw, index) => {
112
+ const content = stripComment(raw).replace(/\s+$/, '');
113
+ if (content.trim() === '' || /^---\s*$/.test(content)) return;
114
+ if (/^\s*\t/.test(raw)) throw new ContractError(`${file}:${index + 1}: indent with spaces, not tabs`);
115
+ lines.push({ indent: content.length - content.trimStart().length, text: content.trim(), at: `${file}:${index + 1}` });
116
+ });
117
+ let next = 0;
118
+ const isItem = (line) => line.text === '-' || line.text.startsWith('- ');
119
+ const block = (indent) => {
120
+ if (isItem(lines[next])) {
121
+ const list = [];
122
+ while (next < lines.length && lines[next].indent === indent && isItem(lines[next])) {
123
+ const { text, at } = lines[next++];
124
+ if (text === '-') throw new ContractError(`${at}: a list item must be a value on the same line`);
125
+ list.push(scalar(text.slice(2), at));
126
+ }
127
+ return list;
128
+ }
129
+ const map = {};
130
+ while (next < lines.length && lines[next].indent === indent && !isItem(lines[next])) {
131
+ const { text, at } = lines[next++];
132
+ const pair = text.match(/^([A-Za-z_][\w-]*):(?:\s+(.*))?$/);
133
+ if (!pair) throw new ContractError(`${at}: cannot read "${text}"; expected "key: value"`);
134
+ const following = lines[next];
135
+ const nested = following && (following.indent > indent || (following.indent === indent && isItem(following)));
136
+ if (pair[2] !== undefined) map[pair[1]] = scalar(pair[2], at);
137
+ else map[pair[1]] = nested ? block(following.indent) : null;
138
+ }
139
+ return map;
140
+ };
141
+ if (lines.length === 0) return {};
142
+ if (lines[0].indent !== 0 || isItem(lines[0])) throw new ContractError(`${lines[0].at}: the file must be a mapping of top-level keys`);
143
+ const root = block(0);
144
+ if (next < lines.length) throw new ContractError(`${lines[next].at}: unexpected indentation`);
145
+ return root;
146
+ }
147
+
148
+ const isText = (value) => typeof value === 'string' && value.trim() !== '';
149
+ const isMapping = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
150
+ const repositoryName = (url) => basename(url.replace(/\.git$/, '').replace(/\/+$/, '')) || url;
151
+
152
+ export function validateContract(data, file = CONTRACT) {
153
+ const fail = (field, problem) => {
154
+ throw new ContractError(`${file}: ${field} ${problem}`);
155
+ };
156
+ if (!isMapping(data)) fail('the file', 'must be a YAML mapping');
157
+ if (data.version === undefined || data.version === null) fail('version', 'is required (version: 1)');
158
+ if (data.version !== 1) fail('version', `${JSON.stringify(data.version)} is not supported; this guard reads version 1`);
159
+ if (!isMapping(data.source)) fail('source', 'is required: a mapping with at least url');
160
+ if (!isText(data.source.url)) fail('source.url', 'is required: where the shared code lives');
161
+ for (const key of ['name', 'changes']) {
162
+ if (data.source[key] != null && !isText(data.source[key])) fail(`source.${key}`, 'must be text');
163
+ }
164
+ if (!Array.isArray(data.owned) || data.owned.length === 0) fail('owned', 'is required: a list of repository-relative globs');
165
+ data.owned.forEach((glob, index) => isText(glob) || fail(`owned[${index}]`, 'must be a glob'));
166
+ if (data.exceptions != null && !isText(data.exceptions)) fail('exceptions', 'must be a repository-relative path');
167
+ const sync = data.sync ?? {};
168
+ if (!isMapping(sync)) fail('sync', 'must be a mapping');
169
+ for (const key of ['marker', 'update']) if (sync[key] != null && !isText(sync[key])) fail(`sync.${key}`, 'must be text');
170
+ if (sync.version != null) {
171
+ if (!isMapping(sync.version)) fail('sync.version', 'must be a mapping with file and key');
172
+ for (const key of ['file', 'key']) if (!isText(sync.version[key])) fail(`sync.version.${key}`, 'is required');
173
+ }
174
+ return {
175
+ version: 1,
176
+ source: { name: data.source.name ?? repositoryName(data.source.url), url: data.source.url, changes: data.source.changes ?? data.source.url },
177
+ owned: data.owned,
178
+ exceptions: data.exceptions ?? DEFAULT_EXCEPTIONS,
179
+ sync: { marker: sync.marker ?? undefined, version: sync.version ?? undefined, update: sync.update ?? undefined },
180
+ file,
181
+ };
182
+ }
183
+
184
+ // What harness 3.7.0 and workflows 1.11.0 read before the contract existed.
185
+ export function legacyContract(answers, manifest) {
186
+ const source = topLevelValue(answers, '_src_path');
187
+ if (!source) throw new ContractError(`${LEGACY_ANSWERS}: _src_path is required to read it as a platform contract`);
188
+ const url = source.replace(/^git\+/, '').replace(/^gh:/, 'https://github.com/').replace(/^gl:/, 'https://gitlab.com/');
189
+ return {
190
+ version: 1,
191
+ source: { name: repositoryName(url), url, changes: url },
192
+ owned: manifest.split(/\r?\n/),
193
+ exceptions: DEFAULT_EXCEPTIONS,
194
+ sync: {
195
+ marker: LEGACY_ANSWERS,
196
+ version: { file: LEGACY_ANSWERS, key: '_commit' },
197
+ update: 'copier update --trust --defaults --conflict inline',
198
+ },
199
+ file: `${LEGACY_ANSWERS} + ${LEGACY_MANIFEST}`,
200
+ };
201
+ }
202
+
203
+ export function topLevelValue(text, key) {
204
+ if (text === undefined) return undefined;
205
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
206
+ // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp — the key is escaped
207
+ const value = text.match(new RegExp(`^${escaped}:[ \\t]*(.*?)[ \\t]*$`, 'm'))?.[1];
208
+ return value === undefined ? undefined : unquote(stripComment(value)) || undefined;
209
+ }
210
+
211
+ // undefined when the repository declares no contract, explicit or legacy.
212
+ export function readContract(root) {
213
+ const read = (path) => (existsSync(join(root, path)) ? readFileSync(join(root, path), 'utf8') : undefined);
214
+ const text = read(CONTRACT);
215
+ if (text !== undefined) return validateContract(parseYamlSubset(text, CONTRACT));
216
+ const answers = read(LEGACY_ANSWERS);
217
+ const manifest = read(LEGACY_MANIFEST);
218
+ return answers !== undefined && manifest !== undefined ? legacyContract(answers, manifest) : undefined;
219
+ }
220
+
221
+ export const recordedVersion = (contract, readFile) =>
222
+ contract.sync.version ? topLevelValue(readFile(contract.sync.version.file), contract.sync.version.key) : undefined;
223
+
224
+ export function normalizeUrl(url) {
225
+ return url
226
+ .trim()
227
+ .replace(/^git\+/, '')
228
+ .replace(/^gh:/, 'https://github.com/')
229
+ .replace(/^git@([^:]+):/, 'https://$1/')
230
+ .replace(/^(https?:\/\/)[^@/]+@/, '$1')
231
+ .replace(/\.git$/, '')
232
+ .replace(/\/+$/, '')
233
+ .toLowerCase();
234
+ }
235
+
236
+ // The template repository carries the contract too; its owned paths are its own.
237
+ function isSourceRepository(root, contract) {
238
+ try {
239
+ return normalizeUrl(git(root, ['remote', 'get-url', 'origin'])) === normalizeUrl(contract.source.url);
240
+ } catch {
241
+ return false;
242
+ }
243
+ }
244
+
245
+ // The subset of YAML the exceptions file uses: one `exceptions:` list of flat mappings.
246
+ export function parseExceptions(text, file = DEFAULT_EXCEPTIONS) {
75
247
  const entries = [];
76
248
  const problems = [];
77
249
  let inList = false;
78
250
  text.split(/\r?\n/).forEach((raw, index) => {
79
251
  const line = raw.replace(/\s+$/, '');
80
252
  if (line.trim() === '' || line.trim().startsWith('#')) return;
81
- const at = `${EXCEPTIONS}:${index + 1}`;
253
+ const at = `${file}:${index + 1}`;
82
254
  const top = line.match(/^exceptions:\s*(\[\s*\])?\s*(?:#.*)?$/);
83
255
  if (top) {
84
256
  inList = !top[1];
@@ -91,7 +263,7 @@ export function parseExceptions(text) {
91
263
  else problems.push(`${at}: cannot read "${line.trim()}"; expected exceptions: [{ path, reason, issue, expires }]`);
92
264
  });
93
265
  for (const entry of entries) {
94
- const at = `${EXCEPTIONS}:${entry.line}`;
266
+ const at = `${file}:${entry.line}`;
95
267
  const missing = ['path', 'reason', 'issue', 'expires'].filter((key) => !entry[key]);
96
268
  if (missing.length > 0) problems.push(`${at}: exception without ${missing.join(', ')}`);
97
269
  else if (!/^\d{4}-\d{2}-\d{2}$/.test(entry.expires) || Number.isNaN(Date.parse(entry.expires))) {
@@ -102,10 +274,14 @@ export function parseExceptions(text) {
102
274
  return { exceptions: valid.map((entry) => ({ ...entry, regex: globToRegExp(entry.path) })), problems };
103
275
  }
104
276
 
105
- const templateCommit = (answers) => answers?.match(/^_commit:\s*(.+?)\s*$/m)?.[1]?.replace(/^["']|["']$/g, '');
106
-
107
277
  function git(root, args) {
108
- return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
278
+ return execFileSync('git', args, {
279
+ cwd: root,
280
+ encoding: 'utf8',
281
+ stdio: ['ignore', 'pipe', 'pipe'],
282
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
283
+ maxBuffer: 64 * 1024 * 1024,
284
+ });
109
285
  }
110
286
 
111
287
  function gitShow(root, ref, path) {
@@ -116,63 +292,162 @@ function gitShow(root, ref, path) {
116
292
  }
117
293
  }
118
294
 
119
- export function checkConformance(root, { base, today = new Date().toISOString().slice(0, 10) } = {}) {
120
- const read = (path) => (existsSync(join(root, path)) ? readFileSync(join(root, path), 'utf8') : undefined);
121
- const answers = read(ANSWERS);
122
- if (answers === undefined) return { adopted: false, failures: [], notes: [] };
295
+ // `git ls-tree -r` and `git ls-files -s` lines, as path -> blob id; submodules are skipped.
296
+ function blobIds(listing, idField) {
297
+ const ids = new Map();
298
+ for (const entry of listing.split('\0').filter(Boolean)) {
299
+ const [meta, path] = entry.split('\t');
300
+ const fields = meta.split(' ');
301
+ if (fields[0] !== '160000') ids.set(path, fields[idField]);
302
+ }
303
+ return ids;
304
+ }
123
305
 
306
+ // Blob ids of the source's files at the version: a tag or commit, or `git describe` output.
307
+ function sourceBlobs(url, version) {
308
+ const scratch = mkdtempSync(join(tmpdir(), 'platform-source-'));
309
+ try {
310
+ git(scratch, ['init', '--quiet', '--bare']);
311
+ let ref = 'FETCH_HEAD';
312
+ try {
313
+ git(scratch, ['fetch', '--quiet', '--depth', '1', url, version]);
314
+ } catch {
315
+ git(scratch, ['fetch', '--quiet', url, '+refs/tags/*:refs/tags/*', '+refs/heads/*:refs/heads/*']);
316
+ ref = version.match(/-\d+-g([0-9a-f]{4,})$/)?.[1] ?? version;
317
+ }
318
+ return blobIds(git(scratch, ['ls-tree', '-r', '-z', `${ref}^{commit}`]), 2);
319
+ } finally {
320
+ rmSync(scratch, { recursive: true, force: true });
321
+ }
322
+ }
323
+
324
+ function compareWithSource(root, contract, patterns, live, version) {
124
325
  const failures = [];
125
326
  const notes = [];
126
- const manifestText = read(MANIFEST);
127
- if (manifestText === undefined) return { adopted: true, failures: [`${ANSWERS} exists but ${MANIFEST} does not`], notes };
128
- const patterns = parseManifest(manifestText);
327
+ const { name, url, changes } = contract.source;
328
+ if (!version) return { failures: [`cannot compare with ${name}: ${contract.sync.version?.file ?? 'the contract'} records no template version (sync.version)`], notes };
329
+ let source;
330
+ try {
331
+ source = sourceBlobs(url, version);
332
+ } catch (error) {
333
+ const reason = String(error.stderr ?? error.message).trim().split('\n').at(-1);
334
+ return { failures, notes: [`cannot read ${url} at ${version} (${reason}); owned files were not compared with the source`] };
335
+ }
336
+ const local = blobIds(git(root, ['ls-files', '-s', '-z']), 1);
337
+ for (const [path, id] of source) {
338
+ if (!isOwned(patterns, path) || local.get(path) === id) continue;
339
+ const problem = local.has(path) ? `differs from ${name} at ${version}` : `is missing; ${name} at ${version} has it`;
340
+ const covered = live.find((entry) => entry.regex.test(path));
341
+ if (covered) notes.push(`${path}: ${problem}, allowed by the exception until ${covered.expires} (${covered.issue})`);
342
+ else failures.push(`${path}: ${problem}; change it in ${name} (${changes}) or add an exception to ${contract.exceptions}`);
343
+ }
344
+ const onlyHere = [...local.keys()].filter((path) => isOwned(patterns, path) && !source.has(path));
345
+ if (onlyHere.length > 0) notes.push(`${onlyHere.length} owned file(s) are not in ${name} at ${version} under the same path; not compared`);
346
+ return { failures, notes };
347
+ }
348
+
349
+ export function checkConformance(root, { base, compare = false, today = new Date().toISOString().slice(0, 10) } = {}) {
350
+ const read = (path) => (existsSync(join(root, path)) ? readFileSync(join(root, path), 'utf8') : undefined);
351
+ let contract;
352
+ try {
353
+ contract = readContract(root);
354
+ } catch (error) {
355
+ if (error instanceof ContractError) return { adopted: true, failures: [error.message], notes: [] };
356
+ throw error;
357
+ }
358
+ if (contract === undefined) return { adopted: false, failures: [], notes: [] };
359
+ if (isSourceRepository(root, contract)) {
360
+ return { adopted: true, failures: [], notes: [`this repository is ${contract.source.name}, the source of its own owned paths`] };
361
+ }
129
362
 
130
- const { exceptions, problems } = parseExceptions(read(EXCEPTIONS) ?? '');
363
+ const failures = [];
364
+ const notes = [];
365
+ const patterns = parsePatterns(contract.owned);
366
+ const { exceptions, problems } = parseExceptions(read(contract.exceptions) ?? '', contract.exceptions);
131
367
  failures.push(...problems);
132
368
  const live = exceptions.filter((entry) => entry.expires >= today);
133
369
  for (const entry of exceptions.filter((e) => e.expires < today)) {
134
- failures.push(`${EXCEPTIONS}:${entry.line}: the exception for ${entry.path} expired on ${entry.expires} (${entry.issue}); converge the code or renew it`);
370
+ failures.push(`${contract.exceptions}:${entry.line}: the exception for ${entry.path} expired on ${entry.expires} (${entry.issue}); converge the code or renew it`);
135
371
  }
136
372
 
137
373
  const tracked = git(root, ['ls-files', '-z']).split('\0').filter(Boolean);
138
- for (const file of tracked.filter((path) => inManifest(patterns, path))) {
374
+ for (const file of tracked.filter((path) => isOwned(patterns, path))) {
139
375
  const text = read(file);
140
376
  if (text !== undefined && !text.includes('\0') && CONFLICT_MARKER.test(text)) {
141
377
  failures.push(`${file}: unresolved template-sync conflict markers`);
142
378
  }
143
379
  }
144
380
 
381
+ const version = recordedVersion(contract, read);
145
382
  if (base !== undefined) {
146
383
  const changed = git(root, ['diff', '--name-only', '--no-renames', `${base}...HEAD`]).split('\n').filter(Boolean);
147
- const platform = changed.filter((path) => inManifest(patterns, path));
148
- const before = templateCommit(gitShow(root, base, ANSWERS));
149
- const after = templateCommit(answers);
150
- if (before !== after) {
151
- notes.push(`template sync ${before ?? '(adoption)'} -> ${after}: ${platform.length} platform file(s) may change`);
384
+ const owned = changed.filter((path) => isOwned(patterns, path));
385
+ const before = contract.sync.version ? topLevelValue(gitShow(root, base, contract.sync.version.file), contract.sync.version.key) : undefined;
386
+ const moved = contract.sync.version !== undefined && before !== version;
387
+ if (moved || (contract.sync.marker && changed.includes(contract.sync.marker))) {
388
+ const step = moved ? `${before ?? '(adoption)'} -> ${version}` : `(${contract.sync.marker} changed)`;
389
+ notes.push(`template sync ${step}: ${owned.length} owned file(s) may change`);
152
390
  } else {
153
- for (const path of platform) {
391
+ for (const path of owned) {
154
392
  const covered = live.find((entry) => entry.regex.test(path));
155
393
  if (covered) notes.push(`${path}: allowed by the exception until ${covered.expires} (${covered.issue})`);
156
- else failures.push(`${path}: platform file changed outside a template sync; change it in Kitchen-Sink, or add an exception to ${EXCEPTIONS}`);
394
+ else failures.push(`${path}: owned by ${contract.source.name} and changed outside a template sync; change it in ${contract.source.name} (${contract.source.changes}), or add an exception to ${contract.exceptions}`);
157
395
  }
158
396
  }
159
397
  }
398
+ if (compare) {
399
+ const result = compareWithSource(root, contract, patterns, live, version);
400
+ failures.push(...result.failures);
401
+ notes.push(...result.notes);
402
+ }
160
403
  return { adopted: true, failures, notes };
161
404
  }
162
405
 
406
+ // One key=value line per field, for $GITHUB_OUTPUT; contract=absent when there is none.
407
+ export function contractOutputs(root) {
408
+ const contract = readContract(root);
409
+ if (contract === undefined) return 'contract=absent\n';
410
+ const read = (path) => (existsSync(join(root, path)) ? readFileSync(join(root, path), 'utf8') : undefined);
411
+ const web = contract.source.url.replace(/^git\+/, '').replace(/\.git$/, '');
412
+ const fields = {
413
+ contract: 'present',
414
+ self: String(isSourceRepository(root, contract)),
415
+ source_name: contract.source.name,
416
+ source_url: contract.source.url,
417
+ source_web: /^https?:\/\//.test(web) ? web : '',
418
+ version: recordedVersion(contract, read) ?? '',
419
+ update: contract.sync.update ?? '',
420
+ };
421
+ for (const [key, value] of Object.entries(fields)) {
422
+ if (/[\r\n]/.test(value)) throw new ContractError(`${contract.file}: ${key} must be a single line`);
423
+ }
424
+ return Object.entries(fields).map(([key, value]) => `${key}=${value}\n`).join('');
425
+ }
426
+
163
427
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
164
428
  const args = process.argv.slice(2);
165
429
  const baseIndex = args.indexOf('--base');
166
430
  const base = baseIndex === -1 ? undefined : args[baseIndex + 1];
167
- if (baseIndex !== -1 && !base) {
168
- console.error('usage: node scripts/check-platform-conformance.mjs [--base <git ref>]');
431
+ const unknown = args.filter((arg, i) => i !== baseIndex + 1 || baseIndex === -1).filter((arg) => !['--base', '--compare', '--contract'].includes(arg));
432
+ if ((baseIndex !== -1 && !base) || unknown.length > 0) {
433
+ console.error('usage: node scripts/check-platform-conformance.mjs [--base <git ref>] [--compare] | --contract');
169
434
  process.exit(2);
170
435
  }
171
436
  const root = fileURLToPath(new URL('..', import.meta.url));
172
- const { adopted, failures, notes } = checkConformance(root, { base });
437
+ if (args.includes('--contract')) {
438
+ try {
439
+ process.stdout.write(contractOutputs(root));
440
+ process.exit(0);
441
+ } catch (error) {
442
+ if (!(error instanceof ContractError)) throw error;
443
+ console.error(`✗ ${error.message}`);
444
+ process.exit(1);
445
+ }
446
+ }
447
+ const { adopted, failures, notes } = checkConformance(root, { base, compare: args.includes('--compare') });
173
448
  if (!adopted) process.exit(0);
174
449
  for (const note of notes) console.log(`· ${note}`);
175
450
  for (const failure of failures) console.error(`✗ ${failure}`);
176
- if (failures.length === 0) console.log('✓ platform files match the template');
451
+ if (failures.length === 0) console.log('✓ owned files match the template');
177
452
  process.exit(failures.length === 0 ? 0 : 1);
178
453
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plainconceptsplatform/workflows",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
4
  "description": "Install and update Platform GitHub agentic workflows.",
5
5
  "keywords": [
6
6
  "github-actions",