@plainconceptsplatform/workflows 1.11.0 → 1.13.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.
@@ -100,7 +100,7 @@ flowchart LR
100
100
  gate -.->|"CI failed or conflict"| fix("Fix, push, CI again") --> ci
101
101
  gate -.->|".github, secrets<br/>or 10000+ lines"| human(("Human review"))
102
102
  review("Someone reviews the PR") --> applyReview("apply-review") --> ci
103
- audit("audit, weekly") -->|"files one issue"| refine
103
+ audit("audit, weekly") -->|"files stories"| refine
104
104
 
105
105
  classDef action fill:#eef0ff,stroke:#554cff,stroke-width:2px,color:#172033
106
106
  classDef decision fill:#fff8e8,stroke:#c75b00,stroke-width:2px,color:#172033
@@ -114,9 +114,7 @@ flowchart LR
114
114
  class merged success
115
115
  ```
116
116
 
117
- Audit creates work rather than consuming it, and files into `refine` rather than straight to
118
- `implement`, so a report of several unrelated findings becomes one properly sized issue per finding
119
- instead of one pull request that has to fix them all.
117
+ Audit creates work rather than consuming it, and files into `refine` rather than straight to `implement`: one story issue per top finding, each under refine's size limit, plus a tracking report refine never sees, instead of one pull request that has to fix them all.
120
118
 
121
119
  ### The merge train
122
120
 
@@ -489,7 +487,7 @@ before anything changes. Applying them one at a time produces contradictory comm
489
487
 
490
488
  ## agent-audit.md
491
489
 
492
- Creates work rather than consuming it. Read-only: it files an issue and changes nothing.
490
+ Creates work rather than consuming it. Read-only: it files a tracking report and one story issue per top finding, and changes nothing else.
493
491
 
494
492
  ```mermaid
495
493
  flowchart TD
@@ -506,8 +504,9 @@ flowchart TD
506
504
  auDedupe{"Already tracked, or<br/>previously rejected?"}
507
505
  auDedupe -.->|"all of them"| auQuiet
508
506
  auDedupe -->|"some are new"| auFile
509
- auFile("One issue: every finding,<br/>top 3 refined into stories") --> auReport
510
- auReport(("Filed<br/>labelled refine, so it gets<br/>sized and split per finding"))
507
+ auFile("Report of every finding,<br/>plus one story per top 3") --> auMeasure
508
+ auMeasure("conclude (rung 6)<br/>each body measured with<br/>refine's rule, trimmed to fit") --> auReport
509
+ auReport(("Filed<br/>report: audit, not refine<br/>stories: refine, sub-issues"))
511
510
  auQuiet(("Nothing to file<br/>the right outcome on a<br/>clean codebase"))
512
511
  auIdle(("Idle<br/>backlog already full"))
513
512
 
@@ -517,12 +516,14 @@ flowchart TD
517
516
  classDef idle fill:#202c40,stroke:#738198,stroke-width:2px,color:#ffffff
518
517
  classDef success fill:#e8f8ec,stroke:#18883c,stroke-width:2px,color:#145a32
519
518
  class auStart start
520
- class auFacts,auRun,auScore,auFile action
519
+ class auFacts,auRun,auScore,auFile,auMeasure action
521
520
  class auBack,auFilter,auDedupe decision
522
521
  class auIdle idle
523
522
  class auReport,auQuiet success
524
523
  ```
525
524
 
525
+ The report never goes to refine: refine's size gate refused every audit filed as one issue of every finding and three stories, from 2026-09-21 until 1.12.1. Each story is filed on its own, labelled `refine`, linked under the report and listed in it, so `audit-close` closes the report once its stories are closed. A story already tracked by an open issue is listed against that issue instead of filed again.
526
+
526
527
  Memory matters here: open issues only cover what is still open, so a finding reported weeks ago and
527
528
  consciously not acted on would come back every single run. The audit records its dispositions and
528
529
  reads them next time.
@@ -549,8 +550,9 @@ flowchart TD
549
550
  daDedupe{"New finding, proven<br/>on both sides?"}
550
551
  daDedupe -.->|"none left"| daQuiet
551
552
  daDedupe -->|"some"| daFile
552
- daFile("One issue with<br/>evidence per finding") --> daReport
553
- daReport(("Filed<br/>labelled docs-audit and refine,<br/>sized and split per finding"))
553
+ daFile("Report of every finding,<br/>plus one story per top 3") --> daMeasure
554
+ daMeasure("conclude (rung 6)<br/>each body measured with<br/>refine's rule, trimmed to fit") --> daReport
555
+ daReport(("Filed<br/>report: docs-audit, not refine<br/>stories: refine, sub-issues"))
554
556
  daQuiet(("Nothing to file<br/>the documentation is current"))
555
557
  daIdle(("Idle<br/>no agent, no spend"))
556
558
 
@@ -560,7 +562,7 @@ flowchart TD
560
562
  classDef idle fill:#202c40,stroke:#738198,stroke-width:2px,color:#ffffff
561
563
  classDef success fill:#e8f8ec,stroke:#18883c,stroke-width:2px,color:#145a32
562
564
  class daStart start
563
- class daFacts,daRun,daFile action
565
+ class daFacts,daRun,daFile,daMeasure action
564
566
  class daBack,daMoved,daDedupe decision
565
567
  class daIdle idle
566
568
  class daReport,daQuiet success
@@ -569,6 +571,8 @@ flowchart TD
569
571
  Reports join the audit chain: `audit-close` closes one when its children are closed, and marks it
570
572
  `stale-audit` when nobody acted on it, which frees the slot.
571
573
 
574
+ It files the way the audit does, through the same `file-audit-issues` step: the report never goes to refine, which refused a single report of every finding once it grew past its size gate. Each top story is filed on its own, labelled `refine`, linked under the report and listed in it.
575
+
572
576
  ---
573
577
 
574
578
  ## agent-release.md
@@ -652,7 +656,8 @@ flowchart TB
652
656
  hkBranch -->|yes| hkDelete("Delete the branch")
653
657
  hkBranch -.->|"open PR, human PR,<br/>or the default branch"| hkDigest
654
658
  hkDelete --> hkDigest
655
- hkDigest("Rewrite one issue:<br/>Needs a human") --> hkEnd
659
+ hkDigest("Rewrite one issue:<br/>Needs a human") --> hkDependabot
660
+ hkDependabot("Squash-merge Dependabot PRs<br/>whose checks are all green") --> hkEnd
656
661
  hkEnd(("One place to look<br/>instead of four repos"))
657
662
 
658
663
  classDef start fill:#ffffff,stroke:#172033,stroke-width:2px,color:#172033
@@ -660,7 +665,7 @@ flowchart TB
660
665
  classDef decision fill:#fff8e8,stroke:#c75b00,stroke-width:2px,color:#172033
661
666
  classDef success fill:#e8f8ec,stroke:#18883c,stroke-width:2px,color:#145a32
662
667
  class hkCron start
663
- class hkScan,hkRetry,hkStrand,hkParent,hkDelete,hkDigest,hkStackClose action
668
+ class hkScan,hkRetry,hkStrand,hkParent,hkDelete,hkDigest,hkStackClose,hkDependabot action
664
669
  class hkPark,hkWhy,hkBudget,hkBranch,hkStack,hkStackWait decision
665
670
  class hkEnd success
666
671
  ```
@@ -681,6 +686,8 @@ held for days after its announcement, a root closed by accident with children op
681
686
  root pull request each get one line in the digest. No labels move, so nothing the router listens
682
687
  for is raised by the sweep itself.
683
688
 
689
+ The last step merges Dependabot's updates through the `merge-dependabot` action: only pull requests Dependabot opened with every commit verified and its own, squash-merged once every check is green, a stale one asked once to rebase, a failing one listed in the job summary. It reads nothing of the pull request but the API, and `DEPENDABOT_MERGE` sets its scope. See `docs/dependabot-merge.md` in the package.
690
+
684
691
  ---
685
692
 
686
693
  ## agentics-app-errors
@@ -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:
@@ -961,6 +963,9 @@ jobs:
961
963
  # If a deploy pipeline calls this workflow with `uses:`, that caller must also grant
962
964
  # `actions: write`. A called workflow may not request a scope its caller withheld, and the
963
965
  # whole run fails to start with zero jobs and no annotation when it does.
966
+ #
967
+ # Not for Dependabot: its runs get a read-only token, so the dispatch would fail its pull request,
968
+ # and the router's maintenance run merges Dependabot updates instead of the train.
964
969
  dispatch-merge-gate:
965
970
  name: Dispatch merge gate
966
971
  needs: [api, mutation-api, web, mutation-web, secret-scan, deps-and-iac-scan, sast-scan, sbom, engineering-excellence, platform-conformance, full-ci]
@@ -970,6 +975,7 @@ jobs:
970
975
  github.event_name == 'pull_request' &&
971
976
  github.event.pull_request.head.repo.full_name == github.repository &&
972
977
  github.event.pull_request.draft == false &&
978
+ github.event.pull_request.user.login != 'dependabot[bot]' &&
973
979
  (github.event.pull_request.user.login == 'app/github-actions' || endsWith(github.event.pull_request.user.login, '[bot]'))
974
980
  runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
975
981
  timeout-minutes: 5
@@ -239,6 +239,9 @@ jobs:
239
239
  # If a deploy pipeline calls this workflow with `uses:`, that caller must also grant
240
240
  # `actions: write`. A called workflow may not request a scope its caller withheld, and the
241
241
  # whole run fails to start with zero jobs and no annotation when it does.
242
+ #
243
+ # Not for Dependabot: its runs get a read-only token, so the dispatch would fail its pull request,
244
+ # and the router's maintenance run merges Dependabot updates instead of the train.
242
245
  dispatch-merge-train:
243
246
  name: Dispatch merge train
244
247
  needs: [lint, web, mutation-web, e2e, desktop, mobile]
@@ -246,7 +249,8 @@ jobs:
246
249
  always() &&
247
250
  github.event_name == 'pull_request' &&
248
251
  github.event.pull_request.head.repo.full_name == github.repository &&
249
- github.event.pull_request.draft == false
252
+ github.event.pull_request.draft == false &&
253
+ github.event.pull_request.user.login != 'dependabot[bot]'
250
254
  runs-on: agents-arc
251
255
  timeout-minutes: 5
252
256
  permissions:
@@ -0,0 +1,78 @@
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/templates/dependabot-merge/.github/workflows/dependabot-merge.yml. Update with `workflows update --force`; consumer edits may be overwritten.
2
+ name: "Dependabot: Merge"
3
+
4
+ # Every six hours, squash-merges the open Dependabot pull requests whose checks are all green, for a
5
+ # repository that does not run the work router (the router does the same in its maintenance run).
6
+ # Plain YAML, no model, and the pull request's code is never checked out: the checkout below is the
7
+ # default branch, for the action. Only pull requests Dependabot opened, with every commit
8
+ # Dependabot's, are touched. See docs/dependabot-merge.md in agentic-workflows.
9
+ #
10
+ # With the Platform App secrets (BOT_APP_ID / BOT_PRIVATE_KEY) the merge runs as the App, and the
11
+ # merge commit starts the default branch's push workflows. Without them it runs as GITHUB_TOKEN,
12
+ # and GitHub starts no workflow from a merge made with that token: nothing deploys on push until
13
+ # the next commit that is not a Dependabot merge.
14
+ #
15
+ # To turn it off: set DEPENDABOT_MERGE to "off" below, or the repository variable DEPENDABOT_MERGE
16
+ # to "off", or AGENTIC_WORKFLOWS_DISABLED to anything, or delete this file.
17
+ on:
18
+ schedule:
19
+ - cron: "41 */6 * * *"
20
+ workflow_dispatch:
21
+ inputs:
22
+ dry-run:
23
+ description: "Decide and report, merge nothing"
24
+ required: false
25
+ type: boolean
26
+ default: false
27
+
28
+ permissions:
29
+ contents: read
30
+
31
+ concurrency:
32
+ group: dependabot-merge
33
+ cancel-in-progress: false
34
+
35
+ env:
36
+ # "all", "minor" (minor and patch), "patch", or "off". CI is the gate.
37
+ DEPENDABOT_MERGE: "all"
38
+ # "true" also merges in a repository with no checks at all, once the head is an hour old.
39
+ DEPENDABOT_MERGE_WITHOUT_CHECKS: "true"
40
+
41
+ jobs:
42
+ merge:
43
+ name: Merge Dependabot pull requests
44
+ if: "!github.event.repository.fork && vars.AGENTIC_WORKFLOWS_DISABLED == ''"
45
+ runs-on: ${{ vars.CI_RUNNER || 'ubuntu-latest' }}
46
+ timeout-minutes: 15
47
+ permissions:
48
+ # Merging, deleting the merged branch, approving where a ruleset asks for it, and commenting
49
+ # `@dependabot rebase`. All of it through the API.
50
+ contents: write
51
+ pull-requests: write
52
+ issues: write
53
+ checks: read
54
+ statuses: read
55
+ env:
56
+ HAS_APP: ${{ secrets.BOT_APP_ID != '' && secrets.BOT_PRIVATE_KEY != '' }}
57
+ steps:
58
+ - name: Checkout the action
59
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
60
+ with:
61
+ persist-credentials: false
62
+ sparse-checkout: .github/actions/merge-dependabot
63
+
64
+ - name: Generate App token
65
+ id: app-token
66
+ if: env.HAS_APP == 'true'
67
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
68
+ with:
69
+ app-id: ${{ secrets.BOT_APP_ID }}
70
+ private-key: ${{ secrets.BOT_PRIVATE_KEY }}
71
+
72
+ - uses: ./.github/actions/merge-dependabot
73
+ with:
74
+ token: ${{ steps.app-token.outputs.token || github.token }}
75
+ scope: ${{ vars.DEPENDABOT_MERGE || env.DEPENDABOT_MERGE }}
76
+ merge-without-checks: ${{ env.DEPENDABOT_MERGE_WITHOUT_CHECKS }}
77
+ post-merge-event: ""
78
+ dry-run: ${{ github.event.inputs.dry-run || 'false' }}
@@ -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