foundry-implementation-actor 0.5.0__tar.gz → 0.5.1__tar.gz

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.
Files changed (48) hide show
  1. foundry_implementation_actor-0.5.1/.github/workflows/release.yml +210 -0
  2. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/CLAUDE.md +14 -4
  3. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/PKG-INFO +31 -3
  4. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/README.md +30 -2
  5. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/adr/ADR-FIA-0004-the-three-amigos-round.md +12 -3
  6. foundry_implementation_actor-0.5.1/adr/ADR-FIA-0006-the-image-is-published-to-two-registries.md +104 -0
  7. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/adr/README.md +1 -0
  8. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/examples/ACME.PARTS.CAP.SUP.007.WID-implementation/Dockerfile +8 -1
  9. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/examples/README.md +18 -1
  10. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/pyproject.toml +1 -1
  11. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/src/foundry_implementation_actor/engine.py +18 -1
  12. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/tests/test_engine.py +41 -0
  13. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/uv.lock +1 -1
  14. foundry_implementation_actor-0.5.0/.github/workflows/release.yml +0 -106
  15. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/.github/workflows/ci.yml +0 -0
  16. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/.gitignore +0 -0
  17. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/adr/ADR-FIA-0001-the-machinery-leaves-the-capability.md +0 -0
  18. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/adr/ADR-FIA-0002-testing-leaves-this-actors-contract.md +0 -0
  19. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/adr/ADR-FIA-0003-the-dev-test-agreement-has-no-home-yet.md +0 -0
  20. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/adr/ADR-FIA-0005-the-actor-ships-an-image.md +0 -0
  21. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/adr/template.md +0 -0
  22. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/docker/Dockerfile +0 -0
  23. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/examples/ACME.PARTS.CAP.SUP.007.WID-implementation/actor-agentic-context.yaml +0 -0
  24. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/scripts/probe_grounding.py +0 -0
  25. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/src/foundry_implementation_actor/__init__.py +0 -0
  26. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/src/foundry_implementation_actor/cards/actor-data.yaml +0 -0
  27. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/src/foundry_implementation_actor/cards/actor-message.yaml +0 -0
  28. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/src/foundry_implementation_actor/cards/actor-synchronous-messaging.yaml +0 -0
  29. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/src/foundry_implementation_actor/cards/actor.yaml +0 -0
  30. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/src/foundry_implementation_actor/cli.py +0 -0
  31. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/src/foundry_implementation_actor/config.py +0 -0
  32. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/src/foundry_implementation_actor/conformance.py +0 -0
  33. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/src/foundry_implementation_actor/correlation.py +0 -0
  34. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/src/foundry_implementation_actor/grounding.py +0 -0
  35. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/src/foundry_implementation_actor/handler.py +0 -0
  36. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/src/foundry_implementation_actor/instance.py +0 -0
  37. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/src/foundry_implementation_actor/schemas/agentic-context.schema.yaml +0 -0
  38. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/src/foundry_implementation_actor/serve.py +0 -0
  39. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/tests/conftest.py +0 -0
  40. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/tests/fixtures/broken/actor-agentic-context.yaml +0 -0
  41. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/tests/test_cards.py +0 -0
  42. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/tests/test_cli.py +0 -0
  43. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/tests/test_config.py +0 -0
  44. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/tests/test_conformance.py +0 -0
  45. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/tests/test_grounding.py +0 -0
  46. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/tests/test_handler.py +0 -0
  47. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/tests/test_instance.py +0 -0
  48. {foundry_implementation_actor-0.5.0 → foundry_implementation_actor-0.5.1}/tests/test_portability.py +0 -0
@@ -0,0 +1,210 @@
1
+ name: release
2
+ on:
3
+ push:
4
+ tags: ["v*"]
5
+
6
+ # A REGISTRY ADDED AFTER A TAG WAS ALREADY CUT. The image job below publishes to two registries,
7
+ # and the second one was not always there. Running this workflow by hand backfills the image for
8
+ # a version whose wheel is already on PyPI — without inventing a version number whose only
9
+ # content is "the last release missed a registry".
10
+ #
11
+ # THE TAG IS AN INPUT, NOT THE REF THIS RUNS FROM. GitHub takes the workflow FILE from the ref it
12
+ # is dispatched on, and the tag needing a backfill is by definition older than the workflow that
13
+ # can do it — dispatching on that tag would run the very file that lacks this trigger. So run it
14
+ # from the default branch and name the tag here; the checkout below takes the CONTENT from the
15
+ # tag, and the version gate still refuses a tag that disagrees with the `pyproject.toml` beside
16
+ # it.
17
+ workflow_dispatch:
18
+ inputs:
19
+ tag:
20
+ description: "Existing tag to publish an image for, e.g. v0.5.0"
21
+ required: true
22
+
23
+ # PyPI Trusted Publishing (OIDC) — no token is stored anywhere. The one-time setup is a pending
24
+ # publisher on pypi.org naming this repo and this workflow; after the first release it becomes a
25
+ # normal publisher. See README.
26
+ permissions:
27
+ id-token: write # PyPI Trusted Publishing
28
+ contents: read
29
+ packages: write # the image, to this repo's own container registry
30
+
31
+ jobs:
32
+ publish:
33
+ # ONLY ON A TAG PUSH. A manual run exists to backfill an IMAGE (see `on:` above), and PyPI
34
+ # refuses a version it already holds — so re-uploading is not merely redundant, it fails, and
35
+ # would take the image job down with it.
36
+ if: github.event_name == 'push'
37
+ runs-on: ubuntu-latest
38
+ environment: pypi
39
+ steps:
40
+ - uses: actions/checkout@v4
41
+ - uses: astral-sh/setup-uv@v5
42
+
43
+ - name: build
44
+ # No fetch step and no external token: the contract is committed here. A release depends
45
+ # on nothing but this checkout and PyPI.
46
+ run: uv build
47
+
48
+ - name: the wheel must carry its contract
49
+ run: |
50
+ uv venv /tmp/probe
51
+ uv pip install --python /tmp/probe/bin/python -q dist/*.whl
52
+ /tmp/probe/bin/foundry-implementation-actor lint examples/ACME.PARTS.CAP.SUP.007.WID-implementation
53
+
54
+ - name: the wheel must carry the actor's own cards
55
+ # Same reason as the line above, for the other half of what this package ships. The
56
+ # `-actor` suffix asserts a papeete-actor underneath (ADR-ECO-0022); a wheel published
57
+ # without its cards makes the name a claim the artifact cannot honour, and a published
58
+ # version is not something to discover that from.
59
+ run: |
60
+ /tmp/probe/bin/papeete-actor-synchronous-messaging lint-card \
61
+ "$(/tmp/probe/bin/python -c 'from foundry_implementation_actor import cards_path; print(cards_path())')"
62
+
63
+ - name: grounding renders a CLAUDE.md whose imports resolve
64
+ # BEFORE the upload, not after. A wheel that ships without its schema, or whose grounding
65
+ # emits an @-import to a file it never wrote, produces sessions grounded in nothing that
66
+ # look identical to correct ones. That is not something to discover from a published
67
+ # version.
68
+ run: /tmp/probe/bin/python scripts/probe_grounding.py examples/ACME.PARTS.CAP.SUP.007.WID-implementation
69
+
70
+ - name: publish to PyPI
71
+ run: uv publish --trusted-publishing always
72
+
73
+ # ── the image, from the same tag and the same checkout ────────────────────────────────────────
74
+ #
75
+ # TWO ARTIFACTS, ONE RELEASE (ADR-FIA-0005). A use pins an image; an embedder pins a wheel; both
76
+ # are this package at this version. A tag that produced one and not the other leaves a consumer
77
+ # unable to take the release at all — so this job builds the wheel again from the same checkout
78
+ # rather than installing the one just uploaded: the image must not depend on PyPI having already
79
+ # indexed it, and must not be able to pick up a different build of the same version.
80
+ #
81
+ # It runs AFTER the wheel is published, not beside it. A failed upload should not leave an image
82
+ # published for a version that does not exist on PyPI; the reverse — a wheel with the image still
83
+ # to come — is recoverable by re-running this job alone.
84
+ image:
85
+ needs: publish
86
+ # `needs` still orders this after the wheel on a tag push. On a manual backfill `publish` is
87
+ # skipped, and a skipped dependency skips its dependents too unless `always()` says otherwise —
88
+ # so the condition has to name both cases rather than relying on `needs` alone.
89
+ if: always() && (needs.publish.result == 'success' || github.event_name == 'workflow_dispatch')
90
+ runs-on: ubuntu-latest
91
+ steps:
92
+ - uses: actions/checkout@v4
93
+ with:
94
+ # Empty on a tag push, which is `checkout`'s own default — the ref that triggered the
95
+ # run. On a manual backfill it is the tag being republished, so every step below reads
96
+ # that tag's own tree rather than whatever the default branch has moved on to.
97
+ ref: ${{ inputs.tag }}
98
+
99
+ - uses: astral-sh/setup-uv@v5
100
+
101
+ - name: the version is the tag
102
+ # The tag is what a consumer writes in a FROM line. A tag that disagrees with
103
+ # pyproject.toml would publish an image whose own `version()` contradicts its name, which
104
+ # is exactly the confusion the CI gate above exists to prevent for the wheel. On a backfill
105
+ # this compares the named tag against the `pyproject.toml` checked out FROM that tag, so a
106
+ # typo in the input is refused rather than published under the wrong name.
107
+ id: version
108
+ env:
109
+ TAG: ${{ inputs.tag || github.ref_name }}
110
+ run: |
111
+ tag="${TAG#v}"
112
+ declared="$(uv run --quiet python -c 'import tomllib,pathlib; print(tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')"
113
+ [ "$tag" = "$declared" ] || { echo "::error::tag $TAG does not match pyproject version $declared"; exit 1; }
114
+ echo "version=$tag" >> "$GITHUB_OUTPUT"
115
+
116
+ - name: build
117
+ run: uv build
118
+
119
+ - uses: docker/login-action@v3
120
+ with:
121
+ registry: ghcr.io
122
+ username: ${{ github.actor }}
123
+ password: ${{ secrets.GITHUB_TOKEN }}
124
+
125
+ - name: the image, tagged and latest
126
+ env:
127
+ VERSION: ${{ steps.version.outputs.version }}
128
+ # `latest` FOLLOWS THE RELEASE STREAM, NOT A BACKFILL. A manual run exists to give an
129
+ # already-released version an image in a registry it missed; run against an older tag it
130
+ # would otherwise drag `latest` backwards onto it, silently, in whichever registry a
131
+ # consumer happens to pull from. The version tag is the whole point of a backfill —
132
+ # `latest` already names something newer, and stays there.
133
+ MOVE_LATEST: ${{ github.event_name == 'push' }}
134
+ run: |
135
+ image="ghcr.io/${{ github.repository }}"
136
+ docker build -f docker/Dockerfile -t "$image:$VERSION" -t "$image:latest" .
137
+ docker push "$image:$VERSION"
138
+ if [ "$MOVE_LATEST" = "true" ]; then docker push "$image:latest"; fi
139
+
140
+ # ── the product's own registry ────────────────────────────────────────────────────────────
141
+ #
142
+ # TWO REGISTRIES, ONE IMAGE. GHCR is where this package publishes; a product that RUNS uses
143
+ # of it pulls from its own registry, which its cluster already authenticates to and which a
144
+ # `FROM` line inside that cluster's builder can reach without a second credential. The base
145
+ # image has to exist in both, because the two are reached by different things: an embedder
146
+ # writing a Dockerfile reads the README and pins GHCR, while a use built by an in-cluster
147
+ # builder resolves its `FROM` against the registry that builder has a push token for.
148
+ #
149
+ # THE SAME IMAGE, NOT ANOTHER BUILD. `docker tag` re-points the build the step above already
150
+ # pushed, so both registries hold one digest. Building a second time would let them diverge —
151
+ # a base-image refresh between two steps of one job is enough — and nothing downstream would
152
+ # ever notice that `:0.5.0` meant two different things depending on where you pulled it.
153
+ #
154
+ # ONE VARIABLE NAMES THE WHOLE TARGET. `vars.PRODUCT_IMAGE` is the full repository path, e.g.
155
+ # `<name>.azurecr.io/<product>/foundry-implementation-actor`; the login server is its first
156
+ # segment. This package is generic across capabilities and names no product of its own — the
157
+ # same rule `tests/test_portability.py` enforces over `src/` — so the product that hosts a
158
+ # registry names itself in a repository variable rather than in this file.
159
+ #
160
+ # UNSET IS A VALID STATE, AND IT SAYS SO. A fork, or this repo before its credentials were
161
+ # added, publishes to GHCR alone; that is a `::notice`, not a failure. Configured-but-broken
162
+ # IS a failure — the push below is not best-effort, because an image the product's cluster
163
+ # cannot pull is the whole reason this block exists.
164
+ - name: is a product registry configured?
165
+ id: product
166
+ env:
167
+ PRODUCT_IMAGE: ${{ vars.PRODUCT_IMAGE }}
168
+ run: |
169
+ if [ -z "$PRODUCT_IMAGE" ]; then
170
+ echo "::notice::PRODUCT_IMAGE is unset — publishing to GHCR only"
171
+ echo "configured=false" >> "$GITHUB_OUTPUT"
172
+ exit 0
173
+ fi
174
+ case "$PRODUCT_IMAGE" in
175
+ */*/*) : ;;
176
+ *) echo "::error::PRODUCT_IMAGE must be <registry>/<path>/<repository>, got $PRODUCT_IMAGE"; exit 1 ;;
177
+ esac
178
+ echo "server=${PRODUCT_IMAGE%%/*}" >> "$GITHUB_OUTPUT"
179
+ echo "configured=true" >> "$GITHUB_OUTPUT"
180
+
181
+ - uses: docker/login-action@v3
182
+ if: steps.product.outputs.configured == 'true'
183
+ with:
184
+ registry: ${{ steps.product.outputs.server }}
185
+ username: ${{ secrets.ACR_PUSH_USERNAME }}
186
+ password: ${{ secrets.ACR_PUSH_PASSWORD }}
187
+
188
+ - name: the same image, in the product's registry
189
+ if: steps.product.outputs.configured == 'true'
190
+ env:
191
+ PRODUCT_IMAGE: ${{ vars.PRODUCT_IMAGE }}
192
+ VERSION: ${{ steps.version.outputs.version }}
193
+ MOVE_LATEST: ${{ github.event_name == 'push' }}
194
+ run: |
195
+ ghcr="ghcr.io/${{ github.repository }}"
196
+ docker tag "$ghcr:$VERSION" "$PRODUCT_IMAGE:$VERSION"
197
+ docker push "$PRODUCT_IMAGE:$VERSION"
198
+ if [ "$MOVE_LATEST" = "true" ]; then
199
+ docker tag "$ghcr:latest" "$PRODUCT_IMAGE:latest"
200
+ docker push "$PRODUCT_IMAGE:latest"
201
+ fi
202
+
203
+ - name: a use built on it is still an actor
204
+ # BEFORE the image is something anyone can pin — the same reason the grounding probe runs
205
+ # before the upload above. An image that builds but produces a use that does not conform is
206
+ # not something to discover from a published tag.
207
+ run: |
208
+ docker build --build-arg ACTOR_IMAGE=ghcr.io/${{ github.repository }}:${{ steps.version.outputs.version }} \
209
+ -t use:release examples/ACME.PARTS.CAP.SUP.007.WID-implementation
210
+ docker run --rm use:release foundry-implementation-actor lint /actor
@@ -88,6 +88,11 @@ Beside them, two folders of committed contract, both shipped in the wheel:
88
88
  - **The clone is full, never `--depth 1`.** `papeete_version.compute()` runs `git describe --tags`
89
89
  against it and needs the matching tag's commit reachable.
90
90
  - **The generated `CLAUDE.md` appends** to one the repo already commits. Never overwrite.
91
+ - **The image is one build in two registries.** GHCR is where this package publishes; a product's
92
+ own registry is where the in-cluster builder that resolves a use's `FROM` line can actually
93
+ authenticate (ADR-FIA-0006). `release.yml` `docker tag`s one build into both so they hold one
94
+ digest — never add a second `docker build`, and never hardcode a registry: the product names
95
+ itself in `vars.PRODUCT_IMAGE`, the same reason `src/` names no capability.
91
96
  - **Never set `ANTHROPIC_API_KEY` in a container running this.** In `claude -p` non-interactive
92
97
  mode an API key in the environment is always preferred over `CLAUDE_CODE_OAUTH_TOKEN`, silently
93
98
  routing every session through metered billing. There is no warning; the only symptom is the bill.
@@ -104,7 +109,12 @@ similar weight rather than only writing it into code comments.
104
109
  ## Releasing
105
110
 
106
111
  Tag-triggered (`v*`) via `.github/workflows/release.yml`, publishing to PyPI through Trusted
107
- Publishing (OIDC) — no stored token. The release job builds the wheel, installs it into a throwaway
108
- venv, and renders a `CLAUDE.md` from a fixture sidecar, asserting its `@`-imports resolve, before
109
- publishing. `ci.yml` runs the suite plus two gates — *the wheel must carry its contract* and *the
110
- gate must run* — and reaches nothing outside its own checkout.
112
+ Publishing (OIDC) — no stored token. The image goes to GHCR and, when `vars.PRODUCT_IMAGE` names
113
+ one, to a product's own registry as well (ADR-FIA-0006). The workflow also accepts a manual run
114
+ that takes a tag as an INPUT — dispatched from the default branch, because the file comes from the
115
+ dispatched ref and a tag needing a backfill predates the workflow that can do it — to give an
116
+ already-released version an image in a registry it missed; that run skips PyPI and does not move
117
+ `latest`. The release job builds the wheel, installs it into
118
+ a throwaway venv, and renders a `CLAUDE.md` from a fixture sidecar, asserting its `@`-imports
119
+ resolve, before publishing. `ci.yml` runs the suite plus two gates — *the wheel must carry its
120
+ contract* and *the gate must run* — and reaches nothing outside its own checkout.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: foundry-implementation-actor
3
- Version: 0.5.0
3
+ Version: 0.5.1
4
4
  Summary: Runs a headless Claude Code implementation session against one capability's own repo — a papeete-actor for one use, with the capability supplied by a sidecar.
5
5
  Project-URL: Homepage, https://github.com/papeete-hub/foundry-implementation-actor
6
6
  Author-email: Papeete Consulting <yoann.remy@outlook.com>
@@ -70,7 +70,7 @@ ground_in:
70
70
  and, beside it, a four-line Dockerfile:
71
71
 
72
72
  ```dockerfile
73
- FROM ghcr.io/papeete-hub/foundry-implementation-actor:0.5.0
73
+ FROM ghcr.io/papeete-hub/foundry-implementation-actor:0.5.1
74
74
  RUN pip install --no-cache-dir kpack==2.0.1 kontract==0.1.0 # what this sidecar's ground_in names
75
75
  COPY actor-agentic-context.yaml /actor/
76
76
  RUN foundry-implementation-actor render-cards /actor && foundry-implementation-actor lint /actor
@@ -193,7 +193,9 @@ task does not determine this"* is a legitimate answer, and it is the most useful
193
193
 
194
194
  `assess-task` is a **query**, not an action — *"a promise to answer, from this actor's own state
195
195
  and nothing invented."* It clones read-only, grounds itself exactly as the implement door does, and
196
- is invoked with `Read,Glob,Grep` and no `Write`, `Edit` or `Bash`. A door that **cannot** write
196
+ is invoked with `--tools Read,Glob,Grep`, so `Write`, `Edit` and `Bash` are absent from the session
197
+ rather than merely unapproved (0.5.0 passed only `--allowedTools`, which removes nothing — see
198
+ ADR-FIA-0004's amendment). A door that **cannot** write
197
199
  beats a door asked not to. It registers no handler either: with an engine and no handler,
198
200
  `Actor.receive()` returns the judged dict as the reply, and there is nothing to contain because
199
201
  nothing is produced.
@@ -329,6 +331,32 @@ move, not a rewrite."*
329
331
 
330
332
  `adr/` records the decisions. Design rationale belongs there, not in commit messages.
331
333
 
334
+ ## Releasing, and which registry to pin
335
+
336
+ A tag (`v*`) publishes two artifacts at one version — the wheel to PyPI, and the image to **two
337
+ registries** holding the same digest (ADR-FIA-0006):
338
+
339
+ | Registry | Pin it when |
340
+ |---|---|
341
+ | `ghcr.io/papeete-hub/foundry-implementation-actor` | you are writing a use's Dockerfile yourself, and build it with your own Docker |
342
+ | a product's own registry, `vars.PRODUCT_IMAGE` | the use is built **in-cluster**, by a builder whose one registry credential is that product's |
343
+
344
+ The second exists because `buildctl` resolves a `FROM` line client-side against the single registry
345
+ credential it was handed — so a use built by the cluster's shared builder can only reach the
346
+ product's own registry, whatever the README says. The push is skipped, with a notice, when
347
+ `vars.PRODUCT_IMAGE` is unset.
348
+
349
+ To give an already-released version an image in a registry it missed, run the workflow by hand from
350
+ the default branch with the tag as its input — it checks that tag out, and refuses it if it
351
+ disagrees with the `pyproject.toml` beside it:
352
+
353
+ ```bash
354
+ gh workflow run release.yml --ref main -f tag=v0.5.0
355
+ ```
356
+
357
+ That run skips PyPI (which refuses a version it already holds) and does not move `latest` in either
358
+ registry.
359
+
332
360
  ## Development
333
361
 
334
362
  ```bash
@@ -47,7 +47,7 @@ ground_in:
47
47
  and, beside it, a four-line Dockerfile:
48
48
 
49
49
  ```dockerfile
50
- FROM ghcr.io/papeete-hub/foundry-implementation-actor:0.5.0
50
+ FROM ghcr.io/papeete-hub/foundry-implementation-actor:0.5.1
51
51
  RUN pip install --no-cache-dir kpack==2.0.1 kontract==0.1.0 # what this sidecar's ground_in names
52
52
  COPY actor-agentic-context.yaml /actor/
53
53
  RUN foundry-implementation-actor render-cards /actor && foundry-implementation-actor lint /actor
@@ -170,7 +170,9 @@ task does not determine this"* is a legitimate answer, and it is the most useful
170
170
 
171
171
  `assess-task` is a **query**, not an action — *"a promise to answer, from this actor's own state
172
172
  and nothing invented."* It clones read-only, grounds itself exactly as the implement door does, and
173
- is invoked with `Read,Glob,Grep` and no `Write`, `Edit` or `Bash`. A door that **cannot** write
173
+ is invoked with `--tools Read,Glob,Grep`, so `Write`, `Edit` and `Bash` are absent from the session
174
+ rather than merely unapproved (0.5.0 passed only `--allowedTools`, which removes nothing — see
175
+ ADR-FIA-0004's amendment). A door that **cannot** write
174
176
  beats a door asked not to. It registers no handler either: with an engine and no handler,
175
177
  `Actor.receive()` returns the judged dict as the reply, and there is nothing to contain because
176
178
  nothing is produced.
@@ -306,6 +308,32 @@ move, not a rewrite."*
306
308
 
307
309
  `adr/` records the decisions. Design rationale belongs there, not in commit messages.
308
310
 
311
+ ## Releasing, and which registry to pin
312
+
313
+ A tag (`v*`) publishes two artifacts at one version — the wheel to PyPI, and the image to **two
314
+ registries** holding the same digest (ADR-FIA-0006):
315
+
316
+ | Registry | Pin it when |
317
+ |---|---|
318
+ | `ghcr.io/papeete-hub/foundry-implementation-actor` | you are writing a use's Dockerfile yourself, and build it with your own Docker |
319
+ | a product's own registry, `vars.PRODUCT_IMAGE` | the use is built **in-cluster**, by a builder whose one registry credential is that product's |
320
+
321
+ The second exists because `buildctl` resolves a `FROM` line client-side against the single registry
322
+ credential it was handed — so a use built by the cluster's shared builder can only reach the
323
+ product's own registry, whatever the README says. The push is skipped, with a notice, when
324
+ `vars.PRODUCT_IMAGE` is unset.
325
+
326
+ To give an already-released version an image in a registry it missed, run the workflow by hand from
327
+ the default branch with the tag as its input — it checks that tag out, and refuses it if it
328
+ disagrees with the `pyproject.toml` beside it:
329
+
330
+ ```bash
331
+ gh workflow run release.yml --ref main -f tag=v0.5.0
332
+ ```
333
+
334
+ That run skips PyPI (which refuses a version it already holds) and does not move `latest` in either
335
+ registry.
336
+
309
337
  ## Development
310
338
 
311
339
  ```bash
@@ -69,6 +69,13 @@ with `Read,Glob,Grep` and no `Write`, `Edit` or `Bash`. **A door that cannot wri
69
69
  asked not to** — the same discipline as `handler.py`'s containment check standing behind the
70
70
  implement door's write boundary.
71
71
 
72
+ *Amended in 0.5.1, after the first live run.* Up to 0.5.0 that list was passed only as
73
+ `--allowedTools`, which PRE-APPROVES the tools it names and removes nothing: a live assess session
74
+ ran Bash three times. The door was asked not to write after all. It is now also passed as
75
+ `--tools`, which is what takes every other built-in out of the session, and a test pins both flags.
76
+ The same run showed commitments coming back as prose strings ("E1: …", "E4/E5: …") that nothing
77
+ could attach to an expectation, so the prompt now requires one `{id, commitment}` object per entry.
78
+
72
79
  **4. `acceptance_surface` is an OPTIONAL payload field on `implement-task`.** The door works
73
80
  exactly as before without it. The DoD is what the caller asked for; the surface is what dev and
74
81
  test agreed it means, and where the surface is more specific, it wins.
@@ -124,9 +131,11 @@ tuning, and it is a constructor keyword.
124
131
  - **Expectation ids are worth more than this round.** Because each carries a stable `id`, a failing
125
132
  verdict can name agreed expectations instead of scraped pytest lines, and `remediation_context`
126
133
  can stop being free prose. Neither is done here.
127
- - **The other two halves are specified, not built.** The testing actor's `propose-acceptance` and
128
- the orchestrating actor's round 0 live in instance repos that are not yet `foundry-*` packages.
129
- The round belongs after that actor unpacks its payload and before its attempt loop begins.
134
+ - **The other two halves are specified here and built elsewhere.** The testing actor's
135
+ `propose-acceptance` is `foundry-testing-actor` (ADR-FTA-0002), and the orchestrating actor's
136
+ round 0 is `foundry-task-orchestration-actor` (ADR-FTOA-0002), both extracted from their
137
+ instance repos the way ADR-FIA-0005 extracted this one. The round runs after that actor unpacks
138
+ its payload and before its attempt loop begins.
130
139
  - **Not decided here:** whether the agreed surface should also be committed somewhere durable
131
140
  rather than only passed between doors, and whether `definition_of_done` should eventually be
132
141
  absorbed into it rather than sitting beside it.
@@ -0,0 +1,104 @@
1
+ ---
2
+ id: ADR-FIA-0006
3
+ title: "The image is published to two registries, and names neither in its source"
4
+ status: Proposed
5
+ date: 2026-09-11
6
+ supersedes: []
7
+ references:
8
+ - ../.github/workflows/release.yml
9
+ - ADR-FIA-0005-the-actor-ships-an-image.md
10
+ ---
11
+
12
+ # ADR-FIA-0006 — The image is published to two registries, and names neither in its source
13
+
14
+ ## Context
15
+
16
+ ADR-FIA-0005 made the base image half of what a release is: a use is one sidecar and a `FROM`
17
+ line, so the image is not a convenience beside the wheel, it is the thing a use is built out of.
18
+ The first release to carry one (0.5.0) published it to GHCR, because that is the registry a
19
+ GitHub-hosted package gets for free and the only one the workflow had a credential for.
20
+
21
+ That is the wrong registry for the consumer that actually exists. A use is built **inside a
22
+ cluster**, by the shared rootless builder `papeete-platform`'s `modules/buildkit` installs, which
23
+ resolves a `FROM` line client-side against the registry it holds a push token for — an Azure
24
+ Container Registry, provisioned by that repo's `modules/acr` with scope maps over the product's own
25
+ repository prefixes. `buildctl` has exactly one registry credential, mounted as its
26
+ `$DOCKER_CONFIG/config.json`, and it is not a GHCR one. So a `FROM ghcr.io/...` line inside that
27
+ builder resolves against a registry it cannot authenticate to, and the failure is a pull error at
28
+ build time rather than anything a gate here would catch.
29
+
30
+ Meanwhile GHCR is the right registry for the other consumer: someone reading this README and
31
+ writing a Dockerfile on their laptop, who has a GitHub account and no relationship with any
32
+ product's Azure subscription.
33
+
34
+ Two consumers, reached by two different credentials. The image has to be in both.
35
+
36
+ ## Decision
37
+
38
+ The release workflow's `image` job pushes **one build to two registries**:
39
+
40
+ - **GHCR**, unconditionally, at `ghcr.io/<this repo>` — unchanged.
41
+ - **A product's own registry**, when `vars.PRODUCT_IMAGE` names one. The variable is the full
42
+ repository path (`<name>.azurecr.io/<product>/foundry-implementation-actor`); the login server
43
+ is its first segment; `secrets.ACR_PUSH_USERNAME` / `ACR_PUSH_PASSWORD` are the scope-mapped push
44
+ token `modules/acr` already emits as outputs.
45
+
46
+ `docker tag` re-points the build GHCR already holds, so both registries carry **one digest**. The
47
+ job does not build twice.
48
+
49
+ `vars.PRODUCT_IMAGE` unset is a valid state: a fork, or this repo before its credentials existed,
50
+ publishes to GHCR alone and emits a `::notice` saying so. Set-but-broken is a hard failure.
51
+
52
+ The workflow also gains `workflow_dispatch`, so a version whose wheel is already on PyPI can be
53
+ given an image in a registry it missed without inventing a version number. It **takes the tag as an
54
+ input** and is run from the default branch: GitHub takes the workflow file from the ref it is
55
+ dispatched on, and a tag old enough to need a backfill is by definition older than the workflow
56
+ that can perform one — so the checkout takes its content from the named tag while the file comes
57
+ from the branch. On a manual run the `publish` job is skipped — PyPI refuses a version it already
58
+ holds — and `latest` is **not** moved in either registry, so backfilling an old tag cannot drag it
59
+ backwards.
60
+
61
+ ## Rationale
62
+
63
+ **Why a variable and not a literal.** This package is generic across capabilities and carries no
64
+ product's name; `tests/test_portability.py` enforces exactly that over `src/`, and a product name
65
+ hardcoded in `release.yml` would be the same mistake one directory over. A product that hosts a
66
+ registry names itself in its own repository variable. It also makes "no product registry
67
+ configured" expressible rather than a broken default.
68
+
69
+ **Why one build rather than two.** Two `docker build` invocations in one job can produce two
70
+ different images — a base-image tag refreshed between them is enough — and nothing downstream
71
+ would ever notice that `:0.5.0` meant different bytes depending on where it was pulled from. That
72
+ is the same failure the existing gate *the image and the wheel are one release* exists to prevent,
73
+ one level up.
74
+
75
+ **Why not mirror instead.** ACR can import from another registry (`az acr import`), which would
76
+ keep one publishing path. It needs a credential for the source, which means making the GHCR package
77
+ public or minting a PAT for it, and it puts a second system between the tag and the image. Pushing
78
+ the build we already have in hand is fewer moving parts.
79
+
80
+ **Why this repo decides this at all.** Where an ecosystem's artifacts live is an `ADR-ECO-*`
81
+ question, and there is no ecosystem ADR about registries today. What this ADR owns is narrower and
82
+ squarely this repo's: that a release publishes to more than one place, and that this package names
83
+ none of them in its own source.
84
+
85
+ ## Consequences
86
+
87
+ - **Two secrets and one variable must exist** on this repo before the product push does anything:
88
+ `PRODUCT_IMAGE`, `ACR_PUSH_USERNAME`, `ACR_PUSH_PASSWORD`. The credentials come from
89
+ `terraform output` on `papeete-platform`'s `examples/acr-local`, and they are set by a human —
90
+ `papeete-foundry-product/GetSecrets.sh` is TTY-only by design so no credential passes through an
91
+ assistant's context, and that convention holds here.
92
+ - **0.5.0 shipped to GHCR only.** Its image exists at
93
+ `ghcr.io/papeete-hub/foundry-implementation-actor:0.5.0` and nowhere else until the manual run
94
+ above is used to backfill it.
95
+ - **The token's scope map must admit the repository.** `modules/acr`'s `repository_patterns` is the
96
+ caller's input, and a path outside it is refused at push time with a permissions error rather
97
+ than at configuration time. Widening it is a `papeete-platform` change, not one here.
98
+ - **A rotated push token silently stops releases.** The token password is created by Terraform with
99
+ no expiry by default; when that changes, the secret here has to change with it. Nothing in this
100
+ repo can detect the drift — the first symptom is a failed release job.
101
+ - **Still to realize:** a use's `FROM` line points at GHCR today
102
+ (`examples/…/Dockerfile`, and the real use in `papeete-foundry`). Pointing it at the product
103
+ registry is what makes the in-cluster builder work, and it belongs in those repos rather than
104
+ here — this ADR only makes the image available to be pointed at.
@@ -16,6 +16,7 @@ are named or placed (`ADR-ECO-*` in
16
16
  | [ADR-FIA-0003](./ADR-FIA-0003-the-dev-test-agreement-has-no-home-yet.md) | The dev↔test agreement has no home yet — and it is not this actor's to emit | Superseded by 0004 |
17
17
  | [ADR-FIA-0004](./ADR-FIA-0004-the-three-amigos-round.md) | The three amigos round — the tester proposes, this actor answers, a human breaks the tie | Accepted |
18
18
  | [ADR-FIA-0005](./ADR-FIA-0005-the-actor-ships-an-image.md) | The actor ships an image, and renders its own cards into it — a use is one sidecar | Proposed |
19
+ | [ADR-FIA-0006](./ADR-FIA-0006-the-image-is-published-to-two-registries.md) | The image is published to two registries, and names neither in its source | Proposed |
19
20
 
20
21
  ## Authoring
21
22
 
@@ -11,7 +11,14 @@
11
11
  # The whole ref is one ARG, not just the tag: this repo's own CI builds the base image from the
12
12
  # checkout and points this line at it, which is how the example is proven to build against the
13
13
  # version being released rather than against the last one published.
14
- ARG ACTOR_IMAGE=ghcr.io/papeete-hub/foundry-implementation-actor:0.5.0
14
+ #
15
+ # The default names the product registry rather than GHCR for one reason: it is the copy a reader
16
+ # can actually pull. The GHCR package is private, so an unauthenticated `docker build` here fails
17
+ # with a 401 and the example teaches nothing. ACR is where the pull token, the node bypass and the
18
+ # `acr-pull` Secret already point (ADR-FIA-0006 publishes the same digest to both). Override it if
19
+ # you are somewhere else:
20
+ # docker build --build-arg ACTOR_IMAGE=ghcr.io/papeete-hub/foundry-implementation-actor:0.5.1 .
21
+ ARG ACTOR_IMAGE=papeetefoundry.azurecr.io/foundry/foundry-implementation-actor:0.5.1
15
22
  FROM ${ACTOR_IMAGE}
16
23
 
17
24
  # THE KNOWLEDGE TOOLS THIS SIDECAR NAMES, and the one thing the base image deliberately does not
@@ -172,6 +172,22 @@ This runs the real grounding path — fetch, write the envelopes into a clone, r
172
172
  `CLAUDE.md`, then check every `@`-import resolves. A session grounded in nothing looks exactly
173
173
  like a correctly grounded one, which is why this is a gate rather than a hope.
174
174
 
175
+ **4. Build it — this one needs a credential.** The three above run offline; a `docker build` has to
176
+ pull the base image, and both registries that carry it are private. The `ARG ACTOR_IMAGE` default
177
+ names the product registry, so log into that one:
178
+
179
+ ```bash
180
+ ACR_TF=../../papeete-platform/examples/acr-local # wherever modules/acr was applied
181
+ terraform -chdir="$ACR_TF" output -raw pull_password | docker login papeetefoundry.azurecr.io \
182
+ --username "$(terraform -chdir="$ACR_TF" output -raw pull_username)" --password-stdin
183
+
184
+ docker build -t acme-wid-actor ACME.PARTS.CAP.SUP.007.WID-implementation
185
+ docker run --rm acme-wid-actor foundry-implementation-actor lint /actor
186
+ ```
187
+
188
+ The read-only pull token is enough — nothing here pushes. The last line is the interesting one: it
189
+ lints the cards the image actually rendered, which is what a caller will be validated against.
190
+
175
191
  ---
176
192
 
177
193
  ## What a real request looks like
@@ -233,7 +249,8 @@ docker build -t my-actor . && docker run --rm my-actor foundry-implementation-ac
233
249
  ```
234
250
 
235
251
  The last one is the interesting one: it lints the cards the image actually rendered, which is what
236
- a caller will be validated against.
252
+ a caller will be validated against. It needs the same registry login as step 4 above — or your own
253
+ `--build-arg ACTOR_IMAGE=`, if you mirror the base image somewhere of your own.
237
254
 
238
255
  **Embedding it instead.** If you need your own base image, or the actor inside a larger process,
239
256
  install the wheel and wire four lines yourself (`assess-task` needs no entry — it is a query with
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "foundry-implementation-actor"
3
- version = "0.5.0"
3
+ version = "0.5.1"
4
4
  description = "Runs a headless Claude Code implementation session against one capability's own repo — a papeete-actor for one use, with the capability supplied by a sidecar."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -569,6 +569,18 @@ class ClaudeCodeEngine:
569
569
  if schema else
570
570
  " with `feasible` (boolean), `objections` (one entry per expectation you cannot "
571
571
  "meet, each naming its `id`) and `commitments` (what you undertake to pin).")
572
+ # THE ENTRY SHAPE, said in words whether or not a schema was rendered above. The card
573
+ # types both lists as `list` and stops there, so a rendered schema says nothing about
574
+ # what goes inside — and a live session, left to choose, answered commitments as
575
+ # prose strings prefixed "E1: …", which the orchestrating actor cannot attach to the
576
+ # expectation they name. One object per entry, keyed by the expectation's own `id`.
577
+ + "\n\nWrite each entry as an object keyed by the expectation it is about:\n"
578
+ "- `objections`: `{\"id\": \"<expectation id>\", \"reason\": \"...\", "
579
+ "\"counter_proposal\": \"...\"}` (omit `counter_proposal` when you have none).\n"
580
+ "- `commitments`: `{\"id\": \"<expectation id>\", \"commitment\": \"<the exact "
581
+ "value you will pin, and where>\"}` — one entry per expectation, never several ids in "
582
+ "one entry and never a bare string. A commitment that concerns no single expectation "
583
+ "takes `\"id\": null`."
572
584
  )
573
585
  return "\n\n".join(sections)
574
586
 
@@ -595,9 +607,14 @@ class ClaudeCodeEngine:
595
607
  self.claude_bin, "--print", "--output-format", "stream-json", "--verbose",
596
608
  "--append-system-prompt", system,
597
609
  "--permission-mode", "acceptEdits",
598
- # The assess door passes a list with no Write, Edit or Bash in it. That is the
610
+ # `--tools`, not only `--allowedTools`. The second merely PRE-APPROVES the tools it names;
611
+ # every other built-in stays available, and a live assess session was seen reaching
612
+ # for Bash through it. `--tools` is what removes the rest from the session, so a door
613
+ # passed a list with no Write, Edit or Bash in it genuinely has none. That is the
599
614
  # enforcement, not the prompt's own "you are reading only" — the same discipline as
600
615
  # handler.py's containment check standing behind the implement door's write boundary.
616
+ # Both are variadic: each is followed by another option, never by the prompt.
617
+ "--tools", allowed_tools,
601
618
  "--allowedTools", allowed_tools,
602
619
  "--max-turns", str(max_turns),
603
620
  situational_prompt,
@@ -7,6 +7,7 @@ hardcoded, and that no line it emits can exceed the budget Loki rejects outright
7
7
  from __future__ import annotations
8
8
 
9
9
  import json
10
+ import sys
10
11
  from pathlib import Path
11
12
 
12
13
  import pytest
@@ -190,6 +191,18 @@ def test_the_answers_shape_is_the_cards_own_when_one_is_given(engine):
190
191
  assert "`feasible` (boolean)" in without
191
192
 
192
193
 
194
+ def test_every_entry_is_keyed_by_the_expectation_it_is_about(engine):
195
+ """A live assess session answered commitments as prose strings ("E1: …", "E4/E5: …"), which
196
+ the orchestrating actor could not attach to anything. The card types the lists and no more,
197
+ so the shape is said in words — with a schema rendered and without."""
198
+ schema = {"properties": {"feasible": {"type": "boolean"}}, "required": ["feasible"]}
199
+ for prompt in (engine._assessment_prompt(PAYLOAD, schema),
200
+ engine._assessment_prompt(PAYLOAD, None)):
201
+ assert '`{"id": "<expectation id>", "commitment":' in prompt
202
+ assert '`{"id": "<expectation id>", "reason":' in prompt
203
+ assert "never a bare string" in prompt
204
+
205
+
193
206
  def test_an_empty_surface_is_said_out_loud(engine):
194
207
  """A caller that proposed nothing gets asked what it expected, rather than a bare yes."""
195
208
  assert "empty" in engine._assessment_prompt(PAYLOAD, None)
@@ -267,6 +280,34 @@ def test_the_assess_session_is_given_no_tool_that_writes(assessed):
267
280
  assert IMPLEMENT_TOOLS != ASSESS_TOOLS
268
281
 
269
282
 
283
+ FAKE_CLAUDE = """
284
+ import json, sys
285
+ open(sys.argv[0] + ".argv", "w").write(json.dumps(sys.argv[1:]))
286
+ print(json.dumps({"type": "result", "subtype": "success", "is_error": False, "result": "done"}))
287
+ """
288
+
289
+
290
+ def test_the_tool_list_removes_tools_rather_than_only_approving_some(config, tmp_path,
291
+ monkeypatch):
292
+ """`--allowedTools` only pre-approves what it names; every other built-in stays available,
293
+ and a live assess session reached for Bash through it. `--tools` is what takes the rest
294
+ away, so it has to carry the same list — and, being variadic, never sit last."""
295
+ script = tmp_path / "claude"
296
+ script.write_text(f"#!{sys.executable}\n{FAKE_CLAUDE}")
297
+ script.chmod(0o755)
298
+ monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(tmp_path / "gitconfig"))
299
+ engine = ClaudeCodeEngine(config, github_token="ghs_fake", claude_bin=str(script))
300
+
301
+ assert engine._invoke_claude(tmp_path, "system", "THE PROMPT",
302
+ allowed_tools=ASSESS_TOOLS) == "done"
303
+ argv = json.loads((tmp_path / "claude.argv").read_text())
304
+
305
+ assert argv[argv.index("--tools") + 1] == ASSESS_TOOLS
306
+ assert argv[argv.index("--allowedTools") + 1] == ASSESS_TOOLS
307
+ assert argv[-1] == "THE PROMPT"
308
+ assert argv[argv.index("--tools") + 2].startswith("--")
309
+
310
+
270
311
  def test_the_assess_session_gets_a_smaller_budget(assessed, engine):
271
312
  _, seen = assessed('```json\n{"feasible": true}\n```')
272
313
  assert seen["max_turns"] == engine.assess_max_turns < engine.max_turns
@@ -18,7 +18,7 @@ wheels = [
18
18
 
19
19
  [[package]]
20
20
  name = "foundry-implementation-actor"
21
- version = "0.5.0"
21
+ version = "0.5.1"
22
22
  source = { editable = "." }
23
23
  dependencies = [
24
24
  { name = "papeete-actor-synchronous-messaging" },
@@ -1,106 +0,0 @@
1
- name: release
2
- on:
3
- push:
4
- tags: ["v*"]
5
-
6
- # PyPI Trusted Publishing (OIDC) — no token is stored anywhere. The one-time setup is a pending
7
- # publisher on pypi.org naming this repo and this workflow; after the first release it becomes a
8
- # normal publisher. See README.
9
- permissions:
10
- id-token: write # PyPI Trusted Publishing
11
- contents: read
12
- packages: write # the image, to this repo's own container registry
13
-
14
- jobs:
15
- publish:
16
- runs-on: ubuntu-latest
17
- environment: pypi
18
- steps:
19
- - uses: actions/checkout@v4
20
- - uses: astral-sh/setup-uv@v5
21
-
22
- - name: build
23
- # No fetch step and no external token: the contract is committed here. A release depends
24
- # on nothing but this checkout and PyPI.
25
- run: uv build
26
-
27
- - name: the wheel must carry its contract
28
- run: |
29
- uv venv /tmp/probe
30
- uv pip install --python /tmp/probe/bin/python -q dist/*.whl
31
- /tmp/probe/bin/foundry-implementation-actor lint examples/ACME.PARTS.CAP.SUP.007.WID-implementation
32
-
33
- - name: the wheel must carry the actor's own cards
34
- # Same reason as the line above, for the other half of what this package ships. The
35
- # `-actor` suffix asserts a papeete-actor underneath (ADR-ECO-0022); a wheel published
36
- # without its cards makes the name a claim the artifact cannot honour, and a published
37
- # version is not something to discover that from.
38
- run: |
39
- /tmp/probe/bin/papeete-actor-synchronous-messaging lint-card \
40
- "$(/tmp/probe/bin/python -c 'from foundry_implementation_actor import cards_path; print(cards_path())')"
41
-
42
- - name: grounding renders a CLAUDE.md whose imports resolve
43
- # BEFORE the upload, not after. A wheel that ships without its schema, or whose grounding
44
- # emits an @-import to a file it never wrote, produces sessions grounded in nothing that
45
- # look identical to correct ones. That is not something to discover from a published
46
- # version.
47
- run: /tmp/probe/bin/python scripts/probe_grounding.py examples/ACME.PARTS.CAP.SUP.007.WID-implementation
48
-
49
- - name: publish to PyPI
50
- run: uv publish --trusted-publishing always
51
-
52
- # ── the image, from the same tag and the same checkout ────────────────────────────────────────
53
- #
54
- # TWO ARTIFACTS, ONE RELEASE (ADR-FIA-0005). A use pins an image; an embedder pins a wheel; both
55
- # are this package at this version. A tag that produced one and not the other leaves a consumer
56
- # unable to take the release at all — so this job builds the wheel again from the same checkout
57
- # rather than installing the one just uploaded: the image must not depend on PyPI having already
58
- # indexed it, and must not be able to pick up a different build of the same version.
59
- #
60
- # It runs AFTER the wheel is published, not beside it. A failed upload should not leave an image
61
- # published for a version that does not exist on PyPI; the reverse — a wheel with the image still
62
- # to come — is recoverable by re-running this job alone.
63
- image:
64
- needs: publish
65
- runs-on: ubuntu-latest
66
- steps:
67
- - uses: actions/checkout@v4
68
- - uses: astral-sh/setup-uv@v5
69
-
70
- - name: the version is the tag
71
- # The tag is what a consumer writes in a FROM line. A tag that disagrees with
72
- # pyproject.toml would publish an image whose own `version()` contradicts its name, which
73
- # is exactly the confusion the CI gate above exists to prevent for the wheel.
74
- id: version
75
- run: |
76
- tag="${GITHUB_REF_NAME#v}"
77
- declared="$(uv run --quiet python -c 'import tomllib,pathlib; print(tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')"
78
- [ "$tag" = "$declared" ] || { echo "::error::tag $GITHUB_REF_NAME does not match pyproject version $declared"; exit 1; }
79
- echo "version=$tag" >> "$GITHUB_OUTPUT"
80
-
81
- - name: build
82
- run: uv build
83
-
84
- - uses: docker/login-action@v3
85
- with:
86
- registry: ghcr.io
87
- username: ${{ github.actor }}
88
- password: ${{ secrets.GITHUB_TOKEN }}
89
-
90
- - name: the image, tagged and latest
91
- run: |
92
- image="ghcr.io/${{ github.repository }}"
93
- docker build -f docker/Dockerfile \
94
- -t "$image:${{ steps.version.outputs.version }}" \
95
- -t "$image:latest" .
96
- docker push "$image:${{ steps.version.outputs.version }}"
97
- docker push "$image:latest"
98
-
99
- - name: a use built on it is still an actor
100
- # BEFORE the image is something anyone can pin — the same reason the grounding probe runs
101
- # before the upload above. An image that builds but produces a use that does not conform is
102
- # not something to discover from a published tag.
103
- run: |
104
- docker build --build-arg ACTOR_IMAGE=ghcr.io/${{ github.repository }}:${{ steps.version.outputs.version }} \
105
- -t use:release examples/ACME.PARTS.CAP.SUP.007.WID-implementation
106
- docker run --rm use:release foundry-implementation-actor lint /actor