Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 114 additions & 2 deletions .github/workflows/workfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ on:
push:
branches: [main]

permissions:
contents: read
# Nothing at the top level, so a job added by hand starts from no permissions
# rather than inheriting these — the shape the generated template uses, and the
# reason the two jobs below can differ so sharply.
permissions: {}

jobs:
doctor:
Expand All @@ -16,6 +18,8 @@ jobs:
# Validating the protocol with the tree's own code is also the honest
# dogfood: a PR is checked by the exact behavior it ships.
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
Expand All @@ -29,3 +33,111 @@ jobs:
run: node ./packages/workfile/dist/bin/workfile.js doctor --json
- name: Check generated agent instructions
run: node ./packages/workfile/dist/bin/workfile.js agents check --json

# The two-job split T-0189 generates, ported to the local build for the same
# reason the doctor job is: inside this repository `npx @illodev/workfile`
# resolves to the checkout's own package.json, so the published spec would
# install nothing runnable.
#
# The job that runs card-declared commands holds nothing, and the job that
# holds a write token runs no repository code. They cannot be one job: a
# criterion bound to a command can only be checked by running it, and a
# process a pull request configured must not be handed a token.
cards:
# A card diff needs a base to be taken against, and a push to main has none.
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 20
# Nothing at all. This job runs commands the pull request declared.
# `actions/checkout` works unauthenticated here because the repository is
# public; a private one would need `contents: read`, which is a real cost of
# this shape and not a detail.
permissions: {}
steps:
- uses: actions/checkout@v7
with:
# The diff is taken from the merge base, and a shallow clone has none.
# `changedPaths` reports that as "cannot answer" rather than as an
# empty diff, so this would fail the job rather than silently verify
# nothing — but it would still fail.
fetch-depth: 0
persist-credentials: false
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: "22"
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run build:core
# Values arrive through `env:`, never interpolated into the script: an
# expression inside a `run:` block is expanded before the shell sees it,
# so a branch name is code there and data here.
- name: Verify the cards this branch touched
env:
BASE_REF: ${{ github.base_ref }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
node ./packages/workfile/dist/bin/workfile.js card verify --changed \
--base "origin/$BASE_REF" \
--close --run "$RUN_URL" --commit "$HEAD_SHA" \
--json | tee workfile-cards.json
- name: Collect what the run wrote
if: always()
run: git diff -- .project > workfile-cards.patch || true
- uses: actions/upload-artifact@v4
if: always()
with:
name: workfile-cards
path: |
workfile-cards.json
workfile-cards.patch
if-no-files-found: ignore

record:
needs: cards
# Same-repository pull requests only. A fork gets a read-only token whatever
# this says, so the push there fails rather than being refused by us; the
# condition keeps the job from starting in order to say so.
if: >-
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write
steps:
# The head branch, not the merge commit: a commit is being pushed to it.
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.ref }}
- uses: actions/download-artifact@v4
with:
name: workfile-cards
# This job runs no Workfile command, deliberately. Every one of them
# `import()`s project.config.mjs from the checkout, which is the code this
# job exists not to execute while holding a write token.
- name: Refuse a patch that reaches outside the protocol directory
run: |
test -s workfile-cards.patch || exit 0
git apply --check workfile-cards.patch
if git apply --numstat workfile-cards.patch | cut -f3 |
grep -qv '^.project/'; then
echo "::error::refusing a patch that reaches outside .project/"
exit 1
fi
# No loop, for two independent reasons and the first is the load-bearing
# one: a push made with GITHUB_TOKEN does not start a workflow run, so
# this does not re-enter. Observed on PR #36 as two runs created in
# `action_required` that never executed. And if one did run, it would find
# the boxes already checked, produce an empty patch and commit nothing.
- name: Commit the evidence
run: |
test -s workfile-cards.patch || exit 0
git apply workfile-cards.patch
git add -- .project
git diff --cached --quiet && exit 0
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git commit -m "Record card verification from CI"
git push
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
---
id: T-0161
title: A card can be written with itself as its origin
status: backlog
status: done
type: bug
priority: low
area: core
effort: S
scope: [packages/workfile/src/modules/cards/validation.ts]
origin: [T-0156]
created: 2026-08-04
updated: 2026-08-04
updated: 2026-08-07
verify:
- id: self-reference
run: [node, --test, packages/workfile/test/self-reference.test.ts]
criteria: ["sha256:ae2edc316ff93cb65b575452945408f5f712ad33d6b5c7ed4fb61e9a5bb8af1b", "sha256:08de66f38da03ca41f2963e76624fc4140e4fd240792af7fbd1f8b826d7d38b9", "sha256:ee509c4d091e14ef3f3d6ec54722acb30bc019b2d2d5de59db82058afc24e497", "sha256:193e044572b952be47178266f43cdb70cc42ef043b03bdaff153cb343de4d96b"]
verified:
at: "2026-08-07T21:43:04.036Z"
method: ci
commit: 5b61847f93bd6627f5062612f1f7d65b24f90e12
run: "https://github.com/illodev/workfile/actions/runs/31220115910"
digest: "sha256:f2995cfc1c6bad2b4ffb1aa5b08437eedb13148bf16918e93da009cad8069487"
---

Found in the 0.6.0 smoke test, against the published package. On a fresh
Expand Down Expand Up @@ -52,8 +62,19 @@ that does not exist yet.

## Acceptance criteria

- [ ] `card create --origin` naming the ID being allocated is refused
- [ ] `card patch` setting a card's own ID as its origin is refused
- [ ] The error code reads like its two neighbours
- [ ] The doctor rule stays, for records written before this landed
- [ ] `pnpm run check` green, doctor 0/0
- [x] `card create --origin` naming the ID being allocated is refused
- [x] `card patch` setting a card's own ID as its origin is refused
- [x] The error code reads like its two neighbours
- [x] The doctor rule stays, for records written before this landed
- [x] `pnpm run check` green, doctor 0/0

## Activity

- 2026-08-07 21:19Z illodev@local#42eb42f5 · claimed
- 2026-08-07 21:24Z illodev@local#42eb42f5 · verify self-reference: node --test packages/workfile/test/self-reference.test.ts passed, checked #1, #2, #3, #4
- 2026-08-07 21:28Z runner@local · verify self-reference: node --test packages/workfile/test/self-reference.test.ts passed, checked #1, #2, #3, #4
- 2026-08-07 21:43Z illodev@local#42eb42f5 · released

## Notes

- 2026-08-07 21:43Z illodev@local#42eb42f5 — ci verification: Four of five criteria checked by the card's own declared command, run by CI on PR #36 and recorded in commit 5b61847 by the job that holds the write token. Criterion 5 is the gate: pnpm run check green at 477 + 10 tests, doctor 0/0. The card's premise was wrong and is corrected on the record: validateCardCandidate runs against id 'pending', so no self-reference check there can fire on a create, and a self parent is refused by CARD_PARENT_NOT_FOUND rather than by its own guard. The origin check therefore sits at the allocation, where the id exists, and a refused create writes no card and does not consume the id.
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
---
id: T-0189
title: CI runs a card's declared checks and writes back the evidence
status: backlog
status: review
type: feature
priority: medium
area: infra
parent: T-0183
tags: [protocol, acceptance]
effort: L
created: 2026-08-05
updated: 2026-08-05
updated: 2026-08-07
origin: [ADR-0016]
depends: [T-0188, T-0186]
scope: [packages/workfile/src/modules/ci, packages/workfile/src/modules/cards/runner.ts, packages/workfile/bin/workfile.ts]
---

The `ci` method from ADR-0016, and the only tier with a witness. The generated
Expand All @@ -33,7 +34,31 @@ Open questions to settle before implementing, not after:

## Acceptance criteria

- [ ] The generated GitHub workflow runs the declared checks for cards touched by the branch.
- [ ] A passing run writes `verified` with `method: ci`, the commit and the run URL.
- [x] The generated GitHub workflow runs the declared checks for cards touched by the branch.
- [x] A passing run writes `verified` with `method: ci`, the commit and the run URL.
- [ ] A fork PR either records evidence safely or records none; it never fails open.
- [ ] The behaviour is documented in the CLI/CI reference, including what it does not do.
- [x] The behaviour is documented in the CLI/CI reference, including what it does not do.

## Activity

- 2026-08-07 20:49Z illodev@local#42eb42f5 · claimed
- 2026-08-07 21:43Z illodev@local#42eb42f5 · doing → review

## Notes

- 2026-08-07 21:09Z illodev@local#42eb42f5 — The three open questions the card said to settle first, settled.

Where the evidence is written from a PR that cannot push: two jobs, and the owner chose it. The job that runs card-declared commands holds `permissions: {}` and leaves no credentials in .git/config; a second job holds `contents: write` and runs no repository code at all — not even Workfile, because every Workfile command import()s project.config.mjs from the checkout. It applies a patch bounded to the protocol directory. A fork gets a read-only token from GitHub for pull_request, so nothing is recorded there whatever the workflow says, and the job condition declines to start in order to say so rather than fail at the last step.

Whether a failing check blocks the merge or refuses done: it refuses done, which the card guessed right. A failing command unchecks the criteria it owns, and assertAcceptanceMet then refuses the transition. The job also exits non-zero so the run is visibly red, but nothing is gated on that.

One job per card or one for all: one for all. The card format is a flat command list and the CI config decides the shape, per ADR-0016.

And one thing the card did not anticipate. T-0188 left a tripwire in ci-targets.test.ts, asserting that no generated target contains the string `card verify` or an Actions expression, commented as "the pin that keeps the next card honest" — and this is that card. What it was protecting is real and stands: a verify[].run is an argument vector so that no shell parses it, and an Actions expression inside a run: block is expanded before the shell sees it. Neither is what invoking the runner does: the card's command never appears in the workflow, and the tool spawns it with no shell. So the two blanket assertions are replaced by the two rules they stood for — no target reads a card's verify block into the template, and no Actions expression reaches a shell line, checked by indentation across run: and script: in all three formats.

The first version of that second check was broken in the dangerous direction: a mutation putting an Actions expression on a continuation line passed it. Found by mutating, not by reading. It is a line-based scan now, with a per-format floor so a scan that stops matching fails loudly instead of reporting a clean sweep over nothing.
- 2026-08-07 21:43Z illodev@local#42eb42f5 — Criterion 1 proven on PR #36, which is this branch. The `cards` job discovered T-0161 as a card the branch touched, ran its declared command, checked the four criteria bound to it, left the fifth alone, and the `record` job pushed commit 5b61847 — a github-actions[bot] commit whose only change is those four boxes plus a trail line naming runner@local and the command. Run: https://github.com/illodev/workfile/actions/runs/31220115910

And one thing the run corrected. The workflow comment claimed the push converges because the second run finds nothing to write. True, but not the operative reason: a push made with GITHUB_TOKEN does not start a workflow run at all. Observed as two runs created in action_required that never executed. Both reasons are stated now, first one first, because someone debugging a loop would otherwise look at the wrong one.

Criterion 3 is argued and pinned rather than run: demonstrating it needs a pull request from a fork, which needs a second account. What GitHub enforces is that `pull_request` from a fork gets a read-only token, so the push cannot land; the job condition declines to start on top of that, and a test pins the condition.
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: CHG-0154
title: CI runs the checks a card declares and records what they proved
type: added
area: infra
visibility: public
cards: [T-0189, T-0161]
created: 2026-08-07
updated: 2026-08-07
---

A card may bind an acceptance criterion to a command, and the generated GitHub workflow now runs those commands for every card a branch touched and writes the result back. Two jobs, because they cannot be one: the job that runs commands a pull request declared holds no permissions at all, and the job that holds a write token runs no repository code. A fork records nothing, which GitHub enforces by issuing a read-only token. And CI closes a card only when every one of its criteria is bound to a command, because a narrative criterion is not something a runner has an opinion about.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"overrides": {
"fast-uri": "^3.1.5",
"hono": "^4.12.34",
"nanoid": "^3.3.17",
"js-yaml": "^4.3.1"
}
}
Expand Down
68 changes: 61 additions & 7 deletions packages/workfile/bin/workfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import {
parseAcceptance,
resolveActor,
runCardVerification,
verifyChangedCards,
setCardAcceptance,
unreadableCriteria,
ValidationError,
Expand Down Expand Up @@ -160,6 +161,7 @@ const USAGE: Record<string, string[]> = {
"workfile card note ID --text TEXT [--section NAME] [--actor ACTOR]",
"workfile card ac ID [--check N] [--uncheck N] # repeatable; no flags lists them",
"workfile card verify ID [--only ENTRY,ENTRY] [--actor ACTOR] # run the card's declared commands",
"workfile card verify --changed --base main [--close --run URL] # every card this branch touched",
"workfile card write ID [--body-file FILE] # or pipe the body on stdin",
"workfile card renumber ID|FILE [--to T-0123] [--actor ACTOR]",
"workfile card renumber --duplicates [--actor ACTOR] # heal after a merge"
Expand Down Expand Up @@ -418,7 +420,12 @@ const COMMAND_FLAGS: Record<string, string[]> = {
],
"card verify": [
"--actor",
"--only"
"--base",
"--changed",
"--close",
"--commit",
"--only",
"--run"
],
"card write": [
"--body-file",
Expand Down Expand Up @@ -1546,12 +1553,18 @@ async function cardCommand(workspace, action) {
const result = await createCard(workspace, input);
return print(has("--json") ? result.card : `${result.id} ${result.file}`);
}
// `card renumber --duplicates` is a sweep and names no record. It reached
// here only because the id position was read raw and `--duplicates` is a
// truthy string — the accident this guard was written to depend on without
// anyone saying so.
const sweeping = action === "renumber" && has("--duplicates");
if (!id && !sweeping) {
// Two card actions name no record, and each says so with a flag.
// `renumber --duplicates` sweeps the whole board; `verify --changed` takes
// its list from the branch. Stated as a rule rather than as one special
// case, because the first of them only ever reached here by accident: the
// id position was read raw and `--duplicates` is a truthy string, so the
// guard was depending on something nobody had written down. `--changed`
// does not get that accident — the id position is empty for it, since
// `--base` consumes the word after it.
const namesNoCard =
(action === "renumber" && has("--duplicates")) ||
(action === "verify" && has("--changed"));
if (!id && !namesNoCard) {
throw new ValidationError(
"CLI_ARGUMENT_REQUIRED",
`card ${action} requires an ID`
Expand Down Expand Up @@ -1604,6 +1617,47 @@ async function cardCommand(workspace, action) {
return;
}
if (action === "verify") {
if (has("--changed")) {
// No ID: the branch names the cards. `--base` is required rather
// than defaulted to `main`, because guessing it wrong means running
// the declared commands of cards this branch never opened, and
// writing to them.
const report = await verifyChangedCards(workspace, {
base: option("--base") || "",
actor: option("--actor") || defaultActor(),
close: has("--close"),
run: option("--run") || null,
// Undefined rather than null: the close door reads undefined as
// "resolve HEAD yourself" and null as "there is no commit".
commit: option("--commit") || undefined
});
// Unresolved is a failure, not an empty run. Git could not answer
// which cards this branch touched, so nothing here is a statement
// about any card.
process.exitCode = report.resolved && report.ok ? 0 : 1;
if (has("--json")) return print(report);
if (!report.resolved) {
console.error(
`Could not diff against ${report.base || "(no base)"}: the ref is ` +
"unknown here, or this is a shallow checkout with no merge base. " +
"No card was verified."
);
return;
}
console.log(
`${report.cards.length} card${report.cards.length === 1 ? "" : "s"} ` +
`touched since ${report.base}`
);
for (const card of report.cards) {
const closed = card.closed ? " · closed with method: ci" : "";
console.log(` ${card.id} — ${card.outcome}${closed}`);
if (card.heldOpen) console.log(` ${card.heldOpen}`);
for (const entry of card.report?.entries || []) {
console.log(` ${describeVerifyEntry(entry)}`);
}
}
return;
}
const report = await runCardVerification(
workspace,
requireId("card", action, id),
Expand Down
Loading
Loading