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
44 changes: 15 additions & 29 deletions labkit/src/labkit/gen_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,18 @@ def render_sync_upstream(config: PublishConfig) -> str:
--body-file "$body_file")"
echo "Opened generated PR: $pr_url"
else
pr_url="$existing"
echo "Updated existing PR: $existing"
fi
required_checks="$(gh api \
"repos/{config.target_slug}/branches/main/protection/required_status_checks" \
--jq '(.contexts // []) + ([.checks[]?.context] // []) | unique | length')"
if [[ "$required_checks" -lt 1 ]]; then
echo "BLOCKED: main has no required status checks; refusing unsafe auto-merge." >&2
exit 1
fi
gh pr merge "$pr_url" --auto --squash
echo "Native auto-merge requested; branch protection owns merge eligibility."
"""
)

Expand Down Expand Up @@ -457,17 +467,9 @@ def _release_tail_goreleaser() -> str:
def render_verify_automerge(config: PublishConfig) -> str:
"""Render the public-repo ``verify.yml`` for labs that opt in via ``sync.auto_merge``.

This systematizes Boatstack's hand-authored ``ci.yml`` "auto-merge-sync" job:
a cross-platform Go ``test`` matrix runs on every PR, and a second job merges
the sync PR — but only once the tests pass and the projection provenance
checks out (bot-authored, from the expected source repo, on the expected
``sync/intelligence-flow-<commit>`` branch).

The merge uses the **GitHub-App token**, not the default ``GITHUB_TOKEN``: a
``GITHUB_TOKEN`` merge would not trigger downstream workflows, so the App
token is what lets the merge-to-``main`` fire the release workflow. Because
the projection token deliberately cannot rewrite ``.github``, this is a
*staged* artifact — installed into the public repo as a bootstrap step.
A cross-platform Go matrix and a provenance job are required checks. The sync
workflow asks GitHub for native auto-merge as the publisher App; branch
protection, not this workflow, owns the merge decision.
"""
manifest = config.downstream_manifest_path
return (
Expand Down Expand Up @@ -503,45 +505,29 @@ def render_verify_automerge(config: PublishConfig) -> str:
- run: go test ./...
- run: go build ./...

auto-merge-sync:
verify-sync-provenance:
if: >-
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository &&
startsWith(github.head_ref, 'sync/intelligence-flow-')
needs: test
runs-on: ubuntu-latest
steps:
- name: Create publisher token
id: app-token
uses: actions/create-github-app-token@v3
with:
client-id: {_token_expr("client-id", config)}
private-key: {_token_expr("private-key", config)}
owner: {config.target.owner}
repositories: {config.target.repo}
permission-contents: write
permission-pull-requests: write
- uses: actions/checkout@v4
with:
ref: ${{{{ github.event.pull_request.head.sha }}}}
- name: Verify generated projection provenance
env:
APP_SLUG: ${{{{ steps.app-token.outputs.app-slug }}}}
HEAD_BRANCH: ${{{{ github.head_ref }}}}
PR_AUTHOR: ${{{{ github.event.pull_request.user.login }}}}
shell: bash
run: |
source_repo="$(jq -r '.source.repository' {manifest})"
source_commit="$(jq -r '.source.commit' {manifest})"
short="${{source_commit:0:12}}"
[[ "$PR_AUTHOR" == "${{APP_SLUG}}[bot]" ]]
[[ "$PR_AUTHOR" == "operator-stack-publisher[bot]" ]]
[[ "$source_repo" == "{config.source.repo}" ]]
[[ "$HEAD_BRANCH" == "sync/intelligence-flow-$short" ]]
- name: Merge verified sync PR
env:
GH_TOKEN: ${{{{ steps.app-token.outputs.token }}}}
PR_URL: ${{{{ github.event.pull_request.html_url }}}}
run: gh pr merge "$PR_URL" --squash
"""
)

Expand Down
24 changes: 24 additions & 0 deletions labkit/src/labkit/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,10 +298,34 @@ def _load_previous_manifest(repo: Path, projection: Projection) -> dict:
return {}
generator = projection.config.manifest.generator
if generator and value.get("generator") != generator:
if _legacy_manifest_equivalent(repo, projection, value):
return {
"schema_version": value.get("schema_version"),
"generator": generator,
"files": value["files"],
"_legacy_adopted": True,
}
return {}
return value


def _legacy_manifest_equivalent(repo: Path, projection: Projection, value: dict) -> bool:
"""Recognize a legacy surface manifest only when its ownership proof is exact."""
if set(value) != {"schema_version", "files"} or value.get("schema_version") != 1:
return False
owned = value.get("files")
if not isinstance(owned, dict) or set(owned) != set(projection.files):
return False
for relative, content in projection.files.items():
expected = sha256_bytes(content)
target = repo / relative
if owned.get(relative) != expected or not target.is_file():
return False
if sha256_bytes(target.read_bytes()) != expected:
return False
return True


def collisions(repo: Path, projection: Projection, adopt: bool) -> list[str]:
if adopt:
return []
Expand Down
23 changes: 13 additions & 10 deletions labkit/tests/test_labkit_gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,22 +158,25 @@ def test_auto_merge_is_opt_in():


def test_verify_automerge_gates_merge_on_tests_and_provenance():
# The verify job must pass before the merge job runs, the merge must be
# scoped to bot-authored sync branches, and it must use the App token (not
# the default GITHUB_TOKEN) so the merge-to-main can trigger the release.
# Verification supplies required checks; the sync workflow requests native
# auto-merge as the App and branch protection owns eligibility.
verify = render_verify_automerge(_config("21-interlock"))
assert "go test ./..." in verify # Go verification gates the merge
assert "needs: test" in verify # merge waits for the test matrix
assert "gh pr merge" in verify and "--squash" in verify
assert "go build ./..." in verify
assert "needs: test" in verify
assert "gh pr merge" not in verify
assert "startsWith(github.head_ref, 'sync/intelligence-flow-')" in verify
assert "create-github-app-token" in verify # merge runs as the publisher bot
# The merge step's token is the minted App token, never a bare GITHUB_TOKEN.
assert "GH_TOKEN: ${{ steps.app-token.outputs.token }}" in verify
assert "secrets.GITHUB_TOKEN" not in verify
# Provenance guard: bot author, expected source repo, matching sync branch.
assert "'.source.repository' UPSTREAM.json" in verify
assert '"$source_repo" == "operatorstack/intelligence-flow"' in verify
assert '"$PR_AUTHOR" == "${APP_SLUG}[bot]"' in verify
assert '"$PR_AUTHOR" == "operator-stack-publisher[bot]"' in verify

sync = render_sync_upstream(_config("21-interlock"))
assert 'gh pr merge "$pr_url" --auto --squash' in sync
assert "branches/main/protection/required_status_checks" in sync
assert "refusing unsafe auto-merge" in sync
assert "permission-contents: write" in sync
assert "permission-pull-requests: write" in sync


def test_auto_merge_requires_go_module():
Expand Down
37 changes: 37 additions & 0 deletions labkit/tests/test_labkit_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
collisions,
diff_repo,
resolve_surface,
sha256_bytes,
write_repo,
)

Expand Down Expand Up @@ -182,6 +183,42 @@ def test_collision_when_downstream_file_not_owned(tmp_path):
assert write_repo(repo, proj, adopt=True) == 0 # adopt bypasses


def test_equivalent_legacy_surface_manifest_is_adopted_automatically(tmp_path):
lab = _make_lab(tmp_path)
config = _projection_config([{"kind": "copy", "source": "README.md", "dest": "README.md"}],
generator="demo:gen")
proj = build_projection(config, lab, "a" * 40)
repo = tmp_path / "public"
repo.mkdir()
(repo / "README.md").write_bytes(proj.files["README.md"])
legacy = {"schema_version": 1, "files": {
"README.md": sha256_bytes(proj.files["README.md"]),
}}
(repo / "UPSTREAM.json").write_text(json.dumps(legacy))

assert collisions(repo, proj, adopt=False) == []
assert write_repo(repo, proj, adopt=False) == 0
adopted = json.loads((repo / "UPSTREAM.json").read_text())
assert adopted["generator"] == "demo:gen"
assert adopted["source"]["repository"] == "operatorstack/intelligence-flow"


def test_legacy_manifest_adoption_fails_closed_on_any_hash_mismatch(tmp_path):
lab = _make_lab(tmp_path)
config = _projection_config([{"kind": "copy", "source": "README.md", "dest": "README.md"}],
generator="demo:gen")
proj = build_projection(config, lab, "a" * 40)
repo = tmp_path / "public"
repo.mkdir()
(repo / "README.md").write_text("downstream edit\n")
legacy = {"schema_version": 1, "files": {
"README.md": sha256_bytes(proj.files["README.md"]),
}}
(repo / "UPSTREAM.json").write_text(json.dumps(legacy))

assert sorted(collisions(repo, proj, adopt=False)) == ["README.md", "UPSTREAM.json"]


# --------------------------------------------------------------------------- #
# Leak guard (fail-closed projection scan)
# --------------------------------------------------------------------------- #
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,5 +127,13 @@ jobs:
--body-file "$body_file")"
echo "Opened generated PR: $pr_url"
else
pr_url="$existing"
echo "Updated existing PR: $existing"
fi
required_checks="$(gh api "repos/operatorstack/boatstack/branches/main/protection/required_status_checks" --jq '(.contexts // []) + ([.checks[]?.context] // []) | unique | length')"
if [[ "$required_checks" -lt 1 ]]; then
echo "BLOCKED: main has no required status checks; refusing unsafe auto-merge." >&2
exit 1
fi
gh pr merge "$pr_url" --auto --squash
echo "Native auto-merge requested; branch protection owns merge eligibility."
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
### Upstream sync now defers merge eligibility to protected checks

Boatstack's generated upstream workflow now asks GitHub for native auto-merge as
the publisher App instead of treating workflow code as the merge-policy engine.
The request fails closed unless `main` has required status checks, so a missing
branch-protection rule cannot turn a newly opened projection PR into an
unchecked merge.

Concurrent mutation and operation locks also now recognize Windows'
`Access is denied` response as normal contention only when the lock file is
present. Real ACL failures still stop immediately, while duplicate workers wait
and converge on the same receipt as they do on Unix.
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,12 @@ const (
// Sentinel errors let callers (and tests) distinguish deterministic refusals
// from genuine I/O faults. Every refusal below leaves accepted state unchanged.
var (
ErrMutationInvalidCandidate = errors.New("mutation candidate failed validation before promotion")
ErrMutationStaleBase = errors.New("mutation rejected: a base artifact changed since it was read")
ErrMutationOutdatedAuthority = errors.New("mutation rejected: supervisor authority changed since it was authorized")
ErrMutationInvalidCandidate = errors.New("mutation candidate failed validation before promotion")
ErrMutationStaleBase = errors.New("mutation rejected: a base artifact changed since it was read")
ErrMutationOutdatedAuthority = errors.New("mutation rejected: supervisor authority changed since it was authorized")
ErrMutationVerificationFailed = errors.New("mutation rolled back: post-write verification failed")
ErrMutationScope = errors.New("mutation operation falls outside its declared scope")
ErrMutationConflict = errors.New("mutation cannot be undone: the artifact diverged from its recorded post-image")
ErrMutationScope = errors.New("mutation operation falls outside its declared scope")
ErrMutationConflict = errors.New("mutation cannot be undone: the artifact diverged from its recorded post-image")
)

// MutationOperation is a single file change within a transaction. Candidate holds
Expand Down Expand Up @@ -170,7 +170,7 @@ func withMutationLock(repo, id string, apply func() error) error {
defer os.Remove(lock)
return apply()
}
if !os.IsExist(openErr) {
if !isLockContention(openErr, lock) {
return openErr
}
if info, statErr := os.Stat(lock); statErr == nil && operationNow().Sub(info.ModTime()) > time.Minute {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ func withOperationLock(repo, id string, apply func() error) error {
defer os.Remove(lock)
return apply()
}
if !os.IsExist(openErr) {
if !isLockContention(openErr, lock) {
return openErr
}
if info, statErr := os.Stat(lock); statErr == nil && operationNow().Sub(info.ModTime()) > time.Minute {
Expand All @@ -258,6 +258,20 @@ func withOperationLock(repo, id string, apply func() error) error {
return fmt.Errorf("operation %s is busy", id)
}

// Windows can report ERROR_ACCESS_DENIED when another process owns an O_EXCL
// lock file. Treat that as contention only when the lock path actually exists;
// genuine directory/ACL permission failures still fail closed.
func isLockContention(openErr error, lock string) bool {
if os.IsExist(openErr) {
return true
}
if !os.IsPermission(openErr) {
return false
}
_, statErr := os.Stat(lock)
return statErr == nil
}

func PrepareOperation(options OperationPrepareOptions) (OperationReceipt, error) {
repo, err := ResolveRepository(options.Repo)
if err != nil {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,5 +109,13 @@ jobs:
--body-file "$body_file")"
echo "Opened generated PR: $pr_url"
else
pr_url="$existing"
echo "Updated existing PR: $existing"
fi
required_checks="$(gh api "repos/operatorstack/value-map/branches/main/protection/required_status_checks" --jq '(.contexts // []) + ([.checks[]?.context] // []) | unique | length')"
if [[ "$required_checks" -lt 1 ]]; then
echo "BLOCKED: main has no required status checks; refusing unsafe auto-merge." >&2
exit 1
fi
gh pr merge "$pr_url" --auto --squash
echo "Native auto-merge requested; branch protection owns merge eligibility."
8 changes: 8 additions & 0 deletions labs/15-pitot/.labkit/generated/sync-upstream.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,5 +109,13 @@ jobs:
--body-file "$body_file")"
echo "Opened generated PR: $pr_url"
else
pr_url="$existing"
echo "Updated existing PR: $existing"
fi
required_checks="$(gh api "repos/operatorstack/pitot/branches/main/protection/required_status_checks" --jq '(.contexts // []) + ([.checks[]?.context] // []) | unique | length')"
if [[ "$required_checks" -lt 1 ]]; then
echo "BLOCKED: main has no required status checks; refusing unsafe auto-merge." >&2
exit 1
fi
gh pr merge "$pr_url" --auto --squash
echo "Native auto-merge requested; branch protection owns merge eligibility."
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
### Upstream sync now requires protected native auto-merge

Pitot's generated upstream workflow now leaves merge eligibility to GitHub's
required checks while the publisher App remains the authenticated change actor.
It refuses to request auto-merge when `main` has no required status checks,
making missing repository protection a visible, fail-closed configuration error.
8 changes: 8 additions & 0 deletions labs/21-interlock/.labkit/generated/sync-upstream.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,5 +109,13 @@ jobs:
--body-file "$body_file")"
echo "Opened generated PR: $pr_url"
else
pr_url="$existing"
echo "Updated existing PR: $existing"
fi
required_checks="$(gh api "repos/operatorstack/interlock/branches/main/protection/required_status_checks" --jq '(.contexts // []) + ([.checks[]?.context] // []) | unique | length')"
if [[ "$required_checks" -lt 1 ]]; then
echo "BLOCKED: main has no required status checks; refusing unsafe auto-merge." >&2
exit 1
fi
gh pr merge "$pr_url" --auto --squash
echo "Native auto-merge requested; branch protection owns merge eligibility."
20 changes: 2 additions & 18 deletions labs/21-interlock/.labkit/generated/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,42 +31,26 @@ jobs:
- run: go test ./...
- run: go build ./...

auto-merge-sync:
verify-sync-provenance:
if: >-
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository &&
startsWith(github.head_ref, 'sync/intelligence-flow-')
needs: test
runs-on: ubuntu-latest
steps:
- name: Create publisher token
id: app-token
uses: actions/create-github-app-token@v3
with:
client-id: ${{ vars.OPERATOR_STACK_PUBLISHER_APP_CLIENT_ID || vars.BOATSTACK_APP_CLIENT_ID }}
private-key: ${{ secrets.OPERATOR_STACK_PUBLISHER_APP_PRIVATE_KEY || secrets.BOATSTACK_APP_PRIVATE_KEY }}
owner: operatorstack
repositories: interlock
permission-contents: write
permission-pull-requests: write
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Verify generated projection provenance
env:
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
HEAD_BRANCH: ${{ github.head_ref }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
shell: bash
run: |
source_repo="$(jq -r '.source.repository' UPSTREAM.json)"
source_commit="$(jq -r '.source.commit' UPSTREAM.json)"
short="${source_commit:0:12}"
[[ "$PR_AUTHOR" == "${APP_SLUG}[bot]" ]]
[[ "$PR_AUTHOR" == "operator-stack-publisher[bot]" ]]
[[ "$source_repo" == "operatorstack/intelligence-flow" ]]
[[ "$HEAD_BRANCH" == "sync/intelligence-flow-$short" ]]
- name: Merge verified sync PR
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
PR_URL: ${{ github.event.pull_request.html_url }}
run: gh pr merge "$PR_URL" --squash
2 changes: 2 additions & 0 deletions labs/21-interlock/interlock/cmd/interlock/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ func main() {
err = cmdInstall(os.Args[2:])
case "upgrade":
err = cmdUpgrade(os.Args[2:])
case "__apply-upgrade":
err = cmdApplyUpgrade(os.Args[2:])
case "derive":
err = cmdDerive(os.Args[2:])
case "compile":
Expand Down
Loading
Loading