@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 +1 -1
- package/dist/workflow-catalog.js +1 -1
- package/loops/templates/ci/app-ci-dotnet-next.yml +11 -9
- package/loops/templates/platform-template/.github/workflows/platform-template-sync.yml +74 -43
- package/loops/templates/platform-template/scripts/check-platform-conformance.mjs +317 -42
- package/package.json +1 -1
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
|
|
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
|
|
package/dist/workflow-catalog.js
CHANGED
|
@@ -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: "
|
|
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
|
|
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
|
-
#
|
|
550
|
-
# platform-template template; a repository
|
|
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:
|
|
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 .
|
|
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
|
|
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
|
-
#
|
|
5
|
-
#
|
|
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
|
-
#
|
|
8
|
-
#
|
|
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
|
-
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
102
|
-
|
|
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 ($
|
|
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 "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
git
|
|
152
|
-
|
|
153
|
-
|
|
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 "
|
|
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
|
|
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
|
-
|
|
171
|
-
|
|
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
|
-
//
|
|
4
|
-
// .
|
|
5
|
-
//
|
|
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>]
|
|
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 {
|
|
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
|
|
15
|
-
const
|
|
16
|
-
const
|
|
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
|
|
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
|
|
48
|
-
return
|
|
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
|
|
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
|
-
|
|
74
|
-
|
|
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 = `${
|
|
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 = `${
|
|
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, {
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
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
|
|
127
|
-
if (
|
|
128
|
-
|
|
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
|
|
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(`${
|
|
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) =>
|
|
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
|
|
148
|
-
const before =
|
|
149
|
-
const
|
|
150
|
-
if (
|
|
151
|
-
|
|
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
|
|
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}:
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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('✓
|
|
451
|
+
if (failures.length === 0) console.log('✓ owned files match the template');
|
|
177
452
|
process.exit(failures.length === 0 ? 0 : 1);
|
|
178
453
|
}
|